├── .envrc ├── server ├── subscription │ ├── subscription.go │ ├── data │ │ └── models.go │ ├── container.go │ ├── provider.go │ ├── domain │ │ └── subscription.go │ ├── repository │ │ └── repository.go │ └── service │ │ └── service.go ├── internal │ ├── livestream │ │ ├── monitor_test.go │ │ ├── status.go │ │ ├── utils.go │ │ ├── livestream_test.go │ │ └── monitor.go │ ├── worker.go │ ├── pool.go │ ├── stack.go │ ├── balancer.go │ ├── common_types.go │ ├── playlist.go │ ├── message_queue.go │ └── memory_db.go ├── archive │ ├── data │ │ └── models.go │ ├── container.go │ ├── archive.go │ ├── provider.go │ ├── utils.go │ ├── domain │ │ └── archive.go │ ├── service │ │ └── service.go │ └── repository │ │ └── repository.go ├── rest │ ├── common.go │ ├── provider.go │ └── container.go ├── cli │ └── ascii.go ├── twitch │ ├── types.go │ ├── rest.go │ ├── auth.go │ └── client.go ├── updater │ └── update.go ├── playlist │ ├── types.go │ └── modifiers.go ├── middleware │ ├── utils.go │ ├── cors.go │ └── jwt.go ├── openid │ ├── middleware.go │ └── config.go ├── common │ └── types.go ├── status │ ├── status.go │ ├── domain │ │ └── status.go │ ├── rest │ │ └── handler.go │ ├── service │ │ └── service.go │ └── repository │ │ └── repository.go ├── formats │ ├── types.go │ └── parser.go ├── archiver │ └── archiver.go ├── rpc │ ├── wrapper.go │ ├── container.go │ └── handlers.go ├── logging │ ├── observable_logger.go │ ├── file_logger.go │ └── handler.go ├── sys │ └── fs.go ├── user │ └── handlers.go ├── dbutil │ └── migrate.go └── config │ └── config.go ├── .gitattributes ├── .deploystack └── docker-run.txt ├── frontend ├── src │ ├── assets │ │ ├── favicon.ico │ │ ├── i18n.yaml │ │ └── i18n │ │ │ ├── zh_CN.yaml │ │ │ ├── ja.yaml │ │ │ ├── ko.yaml │ │ │ ├── pl.yaml │ │ │ ├── ca.yaml │ │ │ ├── ru.yaml │ │ │ └── es.yaml │ ├── hooks │ │ ├── useI18n.ts │ │ ├── useRPC.ts │ │ ├── toast.ts │ │ ├── useFetch.ts │ │ └── observable.ts │ ├── App.tsx │ ├── atoms │ │ ├── toast.ts │ │ ├── ui.ts │ │ ├── downloads.ts │ │ ├── rpc.ts │ │ ├── status.ts │ │ └── downloadTemplate.ts │ ├── components │ │ ├── LoadingBackdrop.tsx │ │ ├── FreeSpaceIndicator.tsx │ │ ├── ResolutionBadge.tsx │ │ ├── VersionIndicator.tsx │ │ ├── Logout.tsx │ │ ├── TwitchIcon.tsx │ │ ├── subscriptions │ │ │ ├── SubscriptionsSpeedDial.tsx │ │ │ └── NoSubscriptions.tsx │ │ ├── Downloads.tsx │ │ ├── AppBar.tsx │ │ ├── UpdateBinaryButton.tsx │ │ ├── CustomArgsTextField.tsx │ │ ├── Drawer.tsx │ │ ├── livestream │ │ │ ├── LivestreamSpeedDial.tsx │ │ │ └── NoLivestreams.tsx │ │ ├── ThemeToggler.tsx │ │ ├── Splash.tsx │ │ ├── EmptyArchive.tsx │ │ ├── HomeActions.tsx │ │ ├── ErrorBoundary.tsx │ │ ├── DownloadsGridView.tsx │ │ ├── TemplateTextField.tsx │ │ ├── ExtraDownloadOptions.tsx │ │ ├── LogTerminal.tsx │ │ ├── Footer.tsx │ │ ├── HomeSpeedDial.tsx │ │ ├── SocketSubscriber.tsx │ │ └── ArchiveCard.tsx │ ├── index.tsx │ ├── views │ │ ├── Home.tsx │ │ ├── Terminal.tsx │ │ ├── Twitch.tsx │ │ └── Login.tsx │ ├── services │ │ └── subscriptions.ts │ ├── lib │ │ ├── httpClient.ts │ │ └── i18n.ts │ ├── providers │ │ └── ToasterProvider.tsx │ ├── utils.ts │ ├── types │ │ └── index.ts │ └── router.tsx ├── vite.config.mts ├── index.html ├── tsconfig.json └── package.json ├── examples ├── Caddyfile ├── docker-compose-nginx │ ├── docker-compose.yml │ └── app.conf └── nginx.conf ├── nix ├── devShell.nix ├── common.nix ├── tests │ └── default.nix ├── frontend.nix └── server.nix ├── .github ├── workflows │ ├── test-container.yml │ └── docker-publish.yml ├── ISSUE_TEMPLATE │ └── bug_report.md └── FUNDING.yml ├── docker-compose.yml ├── .dockerignore ├── .gitignore ├── Makefile ├── openapi └── index.html ├── .vscode └── launch.json ├── go.mod ├── .devcontainer └── devcontainer.json ├── Dockerfile ├── proto └── yt-dlp.proto ├── flake.nix └── main.go /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /server/subscription/subscription.go: -------------------------------------------------------------------------------- 1 | package subscription 2 | -------------------------------------------------------------------------------- /server/internal/livestream/monitor_test.go: -------------------------------------------------------------------------------- 1 | package livestream 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.tsx linguist-detectable=false 2 | *.html linguist-detectable=false 3 | -------------------------------------------------------------------------------- /.deploystack/docker-run.txt: -------------------------------------------------------------------------------- 1 | docker run -d -p 3033:3033 -v /downloads:/downloads marcobaobao/yt-dlp-webui 2 | -------------------------------------------------------------------------------- /frontend/src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcopiovanello/yt-dlp-web-ui/HEAD/frontend/src/assets/favicon.ico -------------------------------------------------------------------------------- /examples/Caddyfile: -------------------------------------------------------------------------------- 1 | resource.yourdomain.tld { 2 | handle_path /yt-dlp-webui/* { 3 | reverse_proxy 127.0.0.1:3033 4 | } 5 | } -------------------------------------------------------------------------------- /server/subscription/data/models.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | type Subscription struct { 4 | Id string 5 | URL string 6 | Params string 7 | CronExpr string 8 | } 9 | -------------------------------------------------------------------------------- /nix/devShell.nix: -------------------------------------------------------------------------------- 1 | { inputsFrom ? [ ], mkShell, yt-dlp, nodejs, go }: 2 | mkShell { 3 | inherit inputsFrom; 4 | packages = [ 5 | yt-dlp 6 | nodejs 7 | go 8 | ]; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/hooks/useI18n.ts: -------------------------------------------------------------------------------- 1 | import Translator from '../lib/i18n' 2 | 3 | export const useI18n = () => { 4 | const instance = Translator.instance 5 | 6 | return { 7 | i18n: instance, 8 | t: instance.t 9 | } 10 | } -------------------------------------------------------------------------------- /frontend/src/hooks/useRPC.ts: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { rpcClientState } from '../atoms/rpc' 3 | 4 | export const useRPC = () => { 5 | const client = useAtomValue(rpcClientState) 6 | 7 | return { 8 | client 9 | } 10 | } -------------------------------------------------------------------------------- /server/archive/data/models.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import "time" 4 | 5 | type ArchiveEntry struct { 6 | Id string 7 | Title string 8 | Path string 9 | Thumbnail string 10 | Source string 11 | Metadata string 12 | CreatedAt time.Time 13 | } 14 | -------------------------------------------------------------------------------- /server/rest/common.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal" 7 | ) 8 | 9 | type ContainerArgs struct { 10 | DB *sql.DB 11 | MDB *internal.MemoryDB 12 | MQ *internal.MessageQueue 13 | } 14 | -------------------------------------------------------------------------------- /nix/common.nix: -------------------------------------------------------------------------------- 1 | { lib }: { 2 | version = "v3.1.2"; 3 | meta = { 4 | description = "A terrible web ui for yt-dlp. Designed to be self-hosted."; 5 | homepage = "https://github.com/marcopiovanello/yt-dlp-web-ui"; 6 | license = lib.licenses.mpl20; 7 | }; 8 | } 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { RouterProvider } from 'react-router-dom' 2 | import { Provider } from 'jotai' 3 | import { router } from './router' 4 | 5 | export function App() { 6 | return ( 7 | 8 | 9 | 10 | ) 11 | } -------------------------------------------------------------------------------- /server/internal/livestream/status.go: -------------------------------------------------------------------------------- 1 | package livestream 2 | 3 | import "time" 4 | 5 | type LiveStreamStatus = map[string]Status 6 | 7 | type Status = struct { 8 | Status int `json:"status"` 9 | WaitTime time.Duration `json:"waitTime"` 10 | LiveDate time.Time `json:"liveDate"` 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/atoms/toast.ts: -------------------------------------------------------------------------------- 1 | import { AlertColor } from '@mui/material' 2 | import { atom } from 'jotai' 3 | 4 | export type Toast = { 5 | open: boolean, 6 | message: string 7 | autoClose: boolean 8 | createdAt: number, 9 | severity?: AlertColor 10 | } 11 | 12 | export const toastListState = atom([]) -------------------------------------------------------------------------------- /frontend/src/atoms/ui.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { activeDownloadsState } from './downloads' 3 | 4 | export const loadingAtom = atom(true) 5 | 6 | export const totalDownloadSpeedState = atom((get) => 7 | get(activeDownloadsState) 8 | .map(d => d.progress.speed) 9 | .reduce((curr, next) => curr + next, 0) 10 | ) -------------------------------------------------------------------------------- /.github/workflows/test-container.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | pull_request: 5 | branches: [ "master" ] 6 | 7 | jobs: 8 | 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Build the Docker image 16 | run: docker build . --file Dockerfile --tag my-image-name:$(date +%s) -------------------------------------------------------------------------------- /server/internal/worker.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | type Worker struct { 4 | requests chan *Process // downloads to do 5 | pending int // downloads pending 6 | index int // index in the heap 7 | } 8 | 9 | func (w *Worker) Work(done chan *Worker) { 10 | for { 11 | req := <-w.requests 12 | req.Start() 13 | done <- w 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/archive/container.go: -------------------------------------------------------------------------------- 1 | package archive 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain" 7 | ) 8 | 9 | func Container(db *sql.DB) (domain.RestHandler, domain.Service) { 10 | var ( 11 | r = provideRepository(db) 12 | s = provideService(r) 13 | h = provideHandler(s) 14 | ) 15 | return h, s 16 | } 17 | -------------------------------------------------------------------------------- /frontend/vite.config.mts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react-swc' 2 | import ViteYaml from '@modyfi/vite-plugin-yaml' 3 | import { defineConfig } from 'vite' 4 | 5 | export default defineConfig(() => { 6 | return { 7 | plugins: [ 8 | react(), 9 | ViteYaml(), 10 | ], 11 | base: '', 12 | build: { 13 | emptyOutDir: true, 14 | } 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /server/cli/ascii.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | const ( 4 | // FG 5 | Red = "\033[31m" 6 | Green = "\033[32m" 7 | Yellow = "\033[33m" 8 | Blue = "\033[34m" 9 | Magenta = "\033[35m" 10 | Cyan = "\033[36m" 11 | Reset = "\033[0m" 12 | // BG 13 | BgRed = "\033[1;41m" 14 | BgBlue = "\033[1;44m" 15 | BgGreen = "\033[1;42m" 16 | BgMagenta = "\033[1;45m" 17 | ) 18 | -------------------------------------------------------------------------------- /server/twitch/types.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import "time" 4 | 5 | type StreamInfo struct { 6 | ID string 7 | UserName string 8 | Title string 9 | GameName string 10 | StartedAt time.Time 11 | IsLive bool 12 | } 13 | 14 | type VodInfo struct { 15 | ID string 16 | Title string 17 | URL string 18 | Duration string 19 | CreatedAt time.Time 20 | } 21 | -------------------------------------------------------------------------------- /examples/docker-compose-nginx/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | image: marcobaobao/yt-dlp-webui 4 | volumes: 5 | - ./downloads:/downloads 6 | restart: unless-stopped 7 | nginx: 8 | image: nginx:alpine 9 | restart: unless-stopped 10 | volumes: 11 | - ./app.conf:/etc/nginx/conf.d/app.conf 12 | depends_on: 13 | - app 14 | ports: 15 | - 80:80 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | yt-dlp-webui: 3 | image: marcobaobao/yt-dlp-webui 4 | ports: 5 | - 3033:3033 6 | volumes: 7 | - :/downloads # replace with a directory on your host system 8 | - :/config # directory where config.yml will be stored 9 | healthcheck: 10 | test: curl -f http://localhost:3033 || exit 1 11 | restart: unless-stopped 12 | -------------------------------------------------------------------------------- /server/internal/livestream/utils.go: -------------------------------------------------------------------------------- 1 | package livestream 2 | 3 | import "bufio" 4 | 5 | var stdoutSplitFunc = func(data []byte, atEOF bool) (advance int, token []byte, err error) { 6 | for i := 0; i < len(data); i++ { 7 | if data[i] == '\r' || data[i] == '\n' { 8 | return i + 1, data[:i], nil 9 | } 10 | } 11 | if !atEOF { 12 | return 0, nil, nil 13 | } 14 | 15 | return 0, data, bufio.ErrFinalToken 16 | } 17 | -------------------------------------------------------------------------------- /server/updater/update.go: -------------------------------------------------------------------------------- 1 | package updater 2 | 3 | import ( 4 | "os/exec" 5 | 6 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 7 | ) 8 | 9 | // Update using the builtin function of yt-dlp 10 | func UpdateExecutable() error { 11 | cmd := exec.Command(config.Instance().DownloaderPath, "-U") 12 | 13 | err := cmd.Start() 14 | if err != nil { 15 | return err 16 | } 17 | 18 | return cmd.Wait() 19 | } 20 | -------------------------------------------------------------------------------- /server/archive/archive.go: -------------------------------------------------------------------------------- 1 | package archive 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/go-chi/chi/v5" 7 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain" 8 | ) 9 | 10 | // alias type 11 | // TODO: remove after refactoring 12 | type Service = domain.Service 13 | type Entity = domain.ArchiveEntry 14 | 15 | func ApplyRouter(db *sql.DB) func(chi.Router) { 16 | handler, _ := Container(db) 17 | return handler.ApplyRouter() 18 | } 19 | -------------------------------------------------------------------------------- /server/playlist/types.go: -------------------------------------------------------------------------------- 1 | package playlist 2 | 3 | import "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common" 4 | 5 | type Metadata struct { 6 | Entries []common.DownloadInfo `json:"entries"` 7 | Count int `json:"playlist_count"` 8 | PlaylistTitle string `json:"title"` 9 | Type string `json:"_type"` 10 | } 11 | 12 | func (m *Metadata) IsPlaylist() bool { return m.Type == "playlist" } 13 | -------------------------------------------------------------------------------- /server/subscription/container.go: -------------------------------------------------------------------------------- 1 | package subscription 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/domain" 7 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/task" 8 | ) 9 | 10 | func Container(db *sql.DB, runner task.TaskRunner) domain.RestHandler { 11 | var ( 12 | r = provideRepository(db) 13 | s = provideService(r, runner) 14 | h = provideHandler(s) 15 | ) 16 | return h 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/components/LoadingBackdrop.tsx: -------------------------------------------------------------------------------- 1 | import { Backdrop, CircularProgress } from '@mui/material' 2 | 3 | type Props = { 4 | isLoading: boolean 5 | } 6 | 7 | const LoadingBackdrop: React.FC = ({ isLoading }) => { 8 | return ( 9 | theme.zIndex.drawer + 1 }} 11 | open={isLoading} 12 | > 13 | 14 | 15 | ) 16 | } 17 | 18 | export default LoadingBackdrop -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .pre-commit-config.yaml 2 | .direnv/ 3 | result/ 4 | result 5 | dist 6 | .pnpm-store/ 7 | .pnpm-debug.log 8 | node_modules 9 | .env 10 | *.mp4 11 | *.ytdl 12 | *.part 13 | *.db 14 | downloads 15 | .DS_Store 16 | build/ 17 | yt-dlp-webui 18 | session.dat 19 | config.yml 20 | cookies.txt 21 | __debug* 22 | ui/ 23 | .idea 24 | .idea/ 25 | frontend/.pnp.cjs 26 | frontend/.pnp.loader.mjs 27 | frontend/.yarn/install-state.gz 28 | .db.lock 29 | livestreams.dat 30 | .vite/deps 31 | archive.txt 32 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { App } from './App' 4 | 5 | import '@fontsource/roboto/300.css' 6 | import '@fontsource/roboto/400.css' 7 | import '@fontsource/roboto/500.css' 8 | import '@fontsource/roboto/700.css' 9 | 10 | import '@fontsource/roboto-mono' 11 | 12 | const root = createRoot(document.getElementById('root')!) 13 | 14 | root.render( 15 | 16 | 17 | 18 | ) 19 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | yt-dlp Web UI 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /frontend/src/atoms/downloads.ts: -------------------------------------------------------------------------------- 1 | import * as O from 'fp-ts/Option' 2 | import { pipe } from 'fp-ts/lib/function' 3 | import { RPCResult } from '../types' 4 | import { atom } from 'jotai' 5 | 6 | export const downloadsState = atom>(O.none) 7 | 8 | export const loadingDownloadsState = atom((get) => O.isNone(get(downloadsState))) 9 | 10 | export const activeDownloadsState = atom((get) => pipe( 11 | get(downloadsState), 12 | O.getOrElse(() => new Array()) 13 | )) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pre-commit-config.yaml 2 | .direnv/ 3 | result/ 4 | result 5 | dist 6 | .pnpm-store/ 7 | .pnpm-debug.log 8 | node_modules 9 | .env 10 | *.mp4 11 | *.ytdl 12 | *.part 13 | *.db 14 | downloads 15 | .DS_Store 16 | build/ 17 | yt-dlp-webui 18 | session.dat 19 | config.yml 20 | cookies.txt 21 | __debug* 22 | ui/ 23 | .idea 24 | .idea/ 25 | frontend/.pnp.cjs 26 | frontend/.pnp.loader.mjs 27 | frontend/.yarn/install-state.gz 28 | .db.lock 29 | livestreams.dat 30 | .vite/deps 31 | archive.txt 32 | twitch-monitor.dat 33 | -------------------------------------------------------------------------------- /nix/tests/default.nix: -------------------------------------------------------------------------------- 1 | { self, pkgs }: { 2 | testServiceStarts = pkgs.testers.runNixOSTest (_: { 3 | name = "service-starts"; 4 | nodes = { 5 | machine = _: { 6 | imports = [ 7 | self.nixosModules.default 8 | ]; 9 | 10 | services.yt-dlp-web-ui = { 11 | enable = true; 12 | downloadDir = "/var/lib/yt-dlp-web-ui"; 13 | }; 14 | }; 15 | }; 16 | testScript = '' 17 | machine.wait_for_unit("yt-dlp-web-ui") 18 | ''; 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /server/middleware/utils.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 7 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/openid" 8 | ) 9 | 10 | func ApplyAuthenticationByConfig(next http.Handler) http.Handler { 11 | handler := next 12 | 13 | if config.Instance().RequireAuth { 14 | handler = Authenticated(handler) 15 | } 16 | if config.Instance().UseOpenId { 17 | handler = openid.Middleware(handler) 18 | } 19 | 20 | return handler 21 | } -------------------------------------------------------------------------------- /server/openid/middleware.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import "net/http" 4 | 5 | func Middleware(next http.Handler) http.Handler { 6 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 7 | token, err := r.Cookie("oid-token") 8 | if err != nil { 9 | http.Error(w, err.Error(), http.StatusBadRequest) 10 | return 11 | } 12 | 13 | if _, err := verifier.Verify(r.Context(), token.Value); err != nil { 14 | http.Error(w, err.Error(), http.StatusBadRequest) 15 | return 16 | } 17 | 18 | next.ServeHTTP(w, r) 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/hooks/toast.ts: -------------------------------------------------------------------------------- 1 | import { AlertColor } from '@mui/material' 2 | import { toastListState } from '../atoms/toast' 3 | import { useSetAtom } from 'jotai' 4 | 5 | export const useToast = () => { 6 | const setToasts = useSetAtom(toastListState) 7 | 8 | return { 9 | pushMessage: (message: string, severity?: AlertColor) => { 10 | setToasts(state => [...state, { 11 | open: true, 12 | message: message, 13 | severity: severity, 14 | autoClose: true, 15 | createdAt: Date.now() 16 | }]) 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /frontend/src/assets/i18n.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Check the i18n src/assets/i18n folder. 3 | # 4 | # This file maps the language name to its translations file 5 | # english -> /src/assets/i18n/en_US.yaml 6 | 7 | languages: 8 | catalan: ca.yaml 9 | german: de.yaml 10 | english: en_US.yaml 11 | spanish: es.yaml 12 | french: fr.yaml 13 | italian: it_IT.yaml 14 | japanese: ja.yaml 15 | korean: ko.yaml 16 | polish: pl.yaml 17 | portuguese-br: pt_BR.yaml 18 | russian: ru.yaml 19 | swedish: sv.yaml 20 | ukrainian: uk.yaml 21 | chinese: zh_CN.yaml 22 | hungarian: hu.yaml -------------------------------------------------------------------------------- /frontend/src/atoms/rpc.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { RPCClient } from '../lib/rpcClient' 3 | import { rpcHTTPEndpoint, rpcWebSocketEndpoint } from './settings' 4 | import { atomWithStorage } from 'jotai/utils' 5 | 6 | export const rpcClientState = atom((get) => 7 | new RPCClient( 8 | get(rpcHTTPEndpoint), 9 | get(rpcWebSocketEndpoint), 10 | localStorage.getItem('token') ?? '' 11 | ), 12 | ) 13 | 14 | export const rpcPollingTimeState = atomWithStorage( 15 | 'rpc-polling-time', 16 | Number(localStorage.getItem('rpc-polling-time')) || 1000 17 | ) -------------------------------------------------------------------------------- /server/rest/provider.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | var ( 8 | service *Service 9 | handler *Handler 10 | 11 | serviceOnce sync.Once 12 | handlerOnce sync.Once 13 | ) 14 | 15 | func ProvideService(args *ContainerArgs) *Service { 16 | serviceOnce.Do(func() { 17 | service = &Service{ 18 | mdb: args.MDB, 19 | db: args.DB, 20 | mq: args.MQ, 21 | } 22 | }) 23 | return service 24 | } 25 | 26 | func ProvideHandler(svc *Service) *Handler { 27 | handlerOnce.Do(func() { 28 | handler = &Handler{ 29 | service: svc, 30 | } 31 | }) 32 | return handler 33 | } 34 | -------------------------------------------------------------------------------- /server/middleware/cors.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import "net/http" 4 | 5 | // Middleware for applying CORS policy for ALL hosts and for 6 | // allowing ALL request headers. 7 | func CORS(next http.Handler) http.Handler { 8 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 9 | w.Header().Set("Access-Control-Allow-Origin", "*") 10 | w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") 11 | w.Header().Set("Access-Control-Allow-Headers", "*") 12 | w.Header().Set("Access-Control-Allow-Credentials", "true") 13 | next.ServeHTTP(w, r) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /server/internal/pool.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | // Pool implements heap.Interface interface as a standard priority queue 4 | type Pool []*Worker 5 | 6 | func (h Pool) Len() int { return len(h) } 7 | func (h Pool) Less(i, j int) bool { return h[i].pending < h[j].pending } 8 | 9 | func (h Pool) Swap(i, j int) { 10 | h[i], h[j] = h[j], h[i] 11 | h[i].index = i 12 | h[j].index = j 13 | } 14 | 15 | func (h *Pool) Push(x any) { *h = append(*h, x.(*Worker)) } 16 | 17 | func (h *Pool) Pop() any { 18 | old := *h 19 | n := len(old) 20 | x := old[n-1] 21 | old[n-1] = nil 22 | *h = old[0 : n-1] 23 | return x 24 | } 25 | -------------------------------------------------------------------------------- /server/common/types.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "time" 4 | 5 | // Used to deser the yt-dlp -J output 6 | type DownloadInfo struct { 7 | URL string `json:"url"` 8 | Title string `json:"title"` 9 | Thumbnail string `json:"thumbnail"` 10 | Resolution string `json:"resolution"` 11 | Size int32 `json:"filesize_approx"` 12 | VCodec string `json:"vcodec"` 13 | ACodec string `json:"acodec"` 14 | Extension string `json:"ext"` 15 | OriginalURL string `json:"original_url"` 16 | FileName string `json:"filename"` 17 | CreatedAt time.Time `json:"created_at"` 18 | } 19 | -------------------------------------------------------------------------------- /server/status/status.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "github.com/go-chi/chi/v5" 5 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal" 6 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/status/repository" 7 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/status/rest" 8 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/status/service" 9 | ) 10 | 11 | func ApplyRouter(mdb *internal.MemoryDB) func(chi.Router) { 12 | var ( 13 | r = repository.New(mdb) 14 | s = service.New(r, nil) //TODO: nil, wtf? 15 | h = rest.New(s) 16 | ) 17 | 18 | return func(r chi.Router) { 19 | r.Get("/", h.Status()) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/components/FreeSpaceIndicator.tsx: -------------------------------------------------------------------------------- 1 | import StorageIcon from '@mui/icons-material/Storage' 2 | import { freeSpaceBytesState } from '../atoms/status' 3 | import { formatSize } from '../utils' 4 | import { useAtomValue } from 'jotai' 5 | 6 | const FreeSpaceIndicator = () => { 7 | const freeSpace = useAtomValue(freeSpaceBytesState) 8 | 9 | return ( 10 |
16 | 17 | 18 | {formatSize(freeSpace)} 19 | 20 |
21 | ) 22 | } 23 | 24 | export default FreeSpaceIndicator -------------------------------------------------------------------------------- /frontend/src/components/ResolutionBadge.tsx: -------------------------------------------------------------------------------- 1 | import EightK from '@mui/icons-material/EightK' 2 | import FourK from '@mui/icons-material/FourK' 3 | import Hd from '@mui/icons-material/Hd' 4 | import Sd from '@mui/icons-material/Sd' 5 | 6 | const ResolutionBadge: React.FC<{ resolution?: string }> = ({ resolution }) => { 7 | if (!resolution) return null 8 | if (resolution.includes('4320')) return 9 | if (resolution.includes('2160')) return 10 | if (resolution.includes('1080')) return 11 | if (resolution.includes('720')) return 12 | return null 13 | } 14 | 15 | export default ResolutionBadge -------------------------------------------------------------------------------- /server/status/domain/status.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type Status struct { 9 | Downloading int `json:"downloading"` 10 | Pending int `json:"pending"` 11 | Completed int `json:"completed"` 12 | DownloadSpeed int `json:"download_speed"` 13 | } 14 | 15 | type Repository interface { 16 | Pending(ctx context.Context) int 17 | Completed(ctx context.Context) int 18 | Downloading(ctx context.Context) int 19 | DownloadSpeed(ctx context.Context) int64 20 | } 21 | 22 | type Service interface { 23 | Status(ctx context.Context) (*Status, error) 24 | } 25 | 26 | type RestHandler interface { 27 | Status() http.HandlerFunc 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/components/VersionIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { Chip } from '@mui/material' 2 | import { ytdlpRpcVersionState } from '../atoms/status' 3 | import { useAtomValue } from 'jotai' 4 | 5 | const VersionIndicator: React.FC = () => { 6 | const version = useAtomValue(ytdlpRpcVersionState) 7 | 8 | return ( 9 |
10 | 11 | 12 | 13 |
14 | ) 15 | } 16 | 17 | export default VersionIndicator -------------------------------------------------------------------------------- /frontend/src/views/Home.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Container 3 | } from '@mui/material' 4 | import { loadingAtom } from '../atoms/ui' 5 | import Downloads from '../components/Downloads' 6 | import HomeActions from '../components/HomeActions' 7 | import LoadingBackdrop from '../components/LoadingBackdrop' 8 | import Splash from '../components/Splash' 9 | import { useAtomValue } from 'jotai' 10 | 11 | export default function Home() { 12 | const isLoading = useAtomValue(loadingAtom) 13 | 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY : fe clean all 2 | 3 | default: 4 | go run main.go 5 | 6 | fe: 7 | cd frontend && pnpm install && pnpm build 8 | 9 | dev: 10 | cd frontend && pnpm install && pnpm dev 11 | 12 | all: fe 13 | CGO_ENABLED=0 go build -o yt-dlp-webui main.go 14 | 15 | multiarch: fe 16 | mkdir -p build 17 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/yt-dlp-webui_linux-amd64 main.go 18 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o build/yt-dlp-webui_linux-arm64 main.go 19 | CGO_ENABLED=0 GOOS=linux GOARM=6 GOARCH=arm go build -o build/yt-dlp-webui_linux-armv6 main.go 20 | CGO_ENABLED=0 GOOS=linux GOARM=7 GOARCH=arm go build -o build/yt-dlp-webui_linux-armv7 main.go 21 | 22 | clean: 23 | rm -rf build 24 | -------------------------------------------------------------------------------- /frontend/src/views/Terminal.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Paper, Typography } from '@mui/material' 2 | import LogTerminal from '../components/LogTerminal' 3 | import { useI18n } from '../hooks/useI18n' 4 | 5 | const Terminal: React.FC = () => { 6 | const { i18n } = useI18n() 7 | 8 | return ( 9 | 10 | 17 | 18 | {i18n.t('logsTitle')} 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | export default Terminal -------------------------------------------------------------------------------- /frontend/src/components/Logout.tsx: -------------------------------------------------------------------------------- 1 | import LogoutIcon from '@mui/icons-material/Logout' 2 | import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material' 3 | import { useNavigate } from 'react-router-dom' 4 | import { useI18n } from '../hooks/useI18n' 5 | 6 | export default function Logout() { 7 | const navigate = useNavigate() 8 | 9 | const logout = async () => { 10 | localStorage.removeItem('token') 11 | navigate('/login') 12 | } 13 | 14 | const { i18n } = useI18n() 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | } -------------------------------------------------------------------------------- /openapi/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | SwaggerUI 9 | 10 | 11 | 12 | 13 |
14 | 15 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /frontend/src/components/TwitchIcon.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { settingsState } from '../atoms/settings' 3 | 4 | const TwitchIcon: React.FC = () => { 5 | const { theme } = useAtomValue(settingsState) 6 | 7 | return ( 8 | 16 | Twitch 17 | 18 | 19 | ) 20 | } 21 | 22 | export default TwitchIcon -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help improve the project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Version running** 11 | Provide the docker label or release tag you're running. 12 | 13 | **Describe the bug** 14 | A clear and concise description of what the bug is. 15 | 16 | **To Reproduce** 17 | Steps to reproduce the behavior: 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See error 22 | 23 | **Expected behavior** 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: marcopiovanello 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ['https://paypal.me/marcofw'] 14 | -------------------------------------------------------------------------------- /nix/frontend.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , stdenv 3 | , nodejs 4 | , pnpm 5 | }: 6 | let common = import ./common.nix { inherit lib; }; in 7 | stdenv.mkDerivation (finalAttrs: { 8 | pname = "yt-dlp-web-ui-frontend"; 9 | 10 | inherit (common) version; 11 | 12 | src = lib.fileset.toSource { 13 | root = ../frontend; 14 | fileset = ../frontend; 15 | }; 16 | 17 | buildPhase = '' 18 | npm run build 19 | ''; 20 | 21 | installPhase = '' 22 | mkdir -p $out/dist 23 | cp -r dist/* $out/dist 24 | ''; 25 | 26 | nativeBuildInputs = [ 27 | nodejs 28 | pnpm.configHook 29 | ]; 30 | 31 | pnpmDeps = pnpm.fetchDeps { 32 | inherit (finalAttrs) pname version src; 33 | hash = "sha256-NvXNDXkuoJ4vGeQA3bOhhc+KLBfke593qK0edcvzWTo="; 34 | }; 35 | 36 | inherit (common) meta; 37 | }) 38 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "target": "ES2018", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "noEmit": true 22 | }, 23 | "include": [ 24 | "./index.ts", 25 | "./src/**/*" 26 | ], 27 | "exclude": [ 28 | "node_modules" 29 | ] 30 | } -------------------------------------------------------------------------------- /frontend/src/components/subscriptions/SubscriptionsSpeedDial.tsx: -------------------------------------------------------------------------------- 1 | import AddCircleIcon from '@mui/icons-material/AddCircle' 2 | import { SpeedDial, SpeedDialAction, SpeedDialIcon } from '@mui/material' 3 | import { useI18n } from '../../hooks/useI18n' 4 | 5 | type Props = { 6 | onOpen: () => void 7 | } 8 | 9 | const SubscriptionsSpeedDial: React.FC = ({ onOpen }) => { 10 | const { i18n } = useI18n() 11 | 12 | return ( 13 | } 17 | > 18 | } 20 | tooltipTitle={i18n.t('newSubscriptionButton')} 21 | onClick={onOpen} 22 | /> 23 | 24 | ) 25 | } 26 | 27 | export default SubscriptionsSpeedDial -------------------------------------------------------------------------------- /server/internal/livestream/livestream_test.go: -------------------------------------------------------------------------------- 1 | package livestream 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 8 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal" 9 | ) 10 | 11 | func setupTest() { 12 | config.Instance().DownloaderPath = "build/yt-dlp" 13 | } 14 | 15 | const URL = "https://www.youtube.com/watch?v=pwoAyLGOysU" 16 | 17 | func TestLivestream(t *testing.T) { 18 | setupTest() 19 | 20 | done := make(chan *LiveStream) 21 | 22 | ls := New(URL, done, &internal.MessageQueue{}, &internal.MemoryDB{}) 23 | go ls.Start() 24 | 25 | time.AfterFunc(time.Second*20, func() { 26 | ls.Kill() 27 | }) 28 | 29 | for { 30 | select { 31 | case wt := <-ls.WaitTime(): 32 | t.Log(wt) 33 | case <-done: 34 | t.Log("done") 35 | return 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/docker-compose-nginx/app.conf: -------------------------------------------------------------------------------- 1 | map $http_upgrade $connection_upgrade { 2 | default upgrade; 3 | '' close; 4 | } 5 | 6 | server { 7 | listen 80; 8 | server_name localhost; 9 | 10 | location / { 11 | proxy_pass http://app:3033; 12 | proxy_redirect off; 13 | proxy_set_header Host $host; 14 | proxy_set_header X-Real-IP $remote_addr; 15 | proxy_set_header X-Forwarded-Proto $scheme; 16 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 17 | 18 | proxy_set_header Upgrade $http_upgrade; 19 | proxy_set_header Connection $connection_upgrade; 20 | 21 | client_max_body_size 20000m; 22 | proxy_connect_timeout 5000; 23 | proxy_send_timeout 5000; 24 | proxy_read_timeout 5000; 25 | send_timeout 5000; 26 | } 27 | } -------------------------------------------------------------------------------- /server/internal/stack.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | type Node[T any] struct { 4 | Value T 5 | } 6 | 7 | type Stack[T any] struct { 8 | Nodes []*Node[T] 9 | count int 10 | } 11 | 12 | func NewStack[T any]() *Stack[T] { 13 | return &Stack[T]{ 14 | Nodes: make([]*Node[T], 10), 15 | } 16 | } 17 | 18 | func (s *Stack[T]) Push(val T) { 19 | if s.count >= len(s.Nodes) { 20 | Nodes := make([]*Node[T], len(s.Nodes)*2) 21 | copy(Nodes, s.Nodes) 22 | s.Nodes = Nodes 23 | } 24 | s.Nodes[s.count] = &Node[T]{Value: val} 25 | s.count++ 26 | } 27 | 28 | func (s *Stack[T]) Pop() *Node[T] { 29 | if s.count == 0 { 30 | return nil 31 | } 32 | node := s.Nodes[s.count-1] 33 | s.count-- 34 | return node 35 | } 36 | 37 | func (s *Stack[T]) IsEmpty() bool { 38 | return s.count == 0 39 | } 40 | 41 | func (s *Stack[T]) IsNotEmpty() bool { 42 | return s.count != 0 43 | } 44 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "go", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "debug", 12 | "program": "main.go" 13 | }, 14 | { 15 | "type": "chrome", 16 | "request": "launch", 17 | "name": "Launch Chrome against localhost", 18 | "url": "http://localhost:5173", 19 | "webRoot": "${workspaceFolder}", 20 | "sourceMapPathOverrides": { 21 | "/__parcel_source_root/*": "${webRoot}/*" 22 | } 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /frontend/src/components/Downloads.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom, useAtomValue } from 'jotai' 2 | import { useEffect } from 'react' 3 | import { loadingDownloadsState } from '../atoms/downloads' 4 | import { listViewState } from '../atoms/settings' 5 | import { loadingAtom } from '../atoms/ui' 6 | import DownloadsGridView from './DownloadsGridView' 7 | import DownloadsTableView from './DownloadsTableView' 8 | 9 | const Downloads: React.FC = () => { 10 | const tableView = useAtomValue(listViewState) 11 | const loadingDownloads = useAtomValue(loadingDownloadsState) 12 | 13 | const [isLoading, setIsLoading] = useAtom(loadingAtom) 14 | 15 | useEffect(() => { 16 | if (loadingDownloads) { 17 | return setIsLoading(true) 18 | } 19 | setIsLoading(false) 20 | }, [loadingDownloads, isLoading]) 21 | 22 | if (tableView) return 23 | 24 | return 25 | } 26 | 27 | export default Downloads -------------------------------------------------------------------------------- /server/formats/types.go: -------------------------------------------------------------------------------- 1 | package formats 2 | 3 | // Used to deser the formats in the -J output 4 | type Metadata struct { 5 | Type string `json:"_type"` 6 | Formats []Format `json:"formats"` 7 | Best Format `json:"best"` 8 | Thumbnail string `json:"thumbnail"` 9 | Title string `json:"title"` 10 | URL string `json:"url"` 11 | Entries []Metadata `json:"entries"` // populated if url is playlist 12 | } 13 | 14 | func (m *Metadata) IsPlaylist() bool { 15 | return m.Type == "playlist" 16 | } 17 | 18 | // A skimmed yt-dlp format node 19 | type Format struct { 20 | Format_id string `json:"format_id"` 21 | Format_note string `json:"format_note"` 22 | FPS float32 `json:"fps"` 23 | Resolution string `json:"resolution"` 24 | VCodec string `json:"vcodec"` 25 | ACodec string `json:"acodec"` 26 | Size float64 `json:"filesize_approx"` 27 | Language string `json:"language"` 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/hooks/useFetch.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from 'fp-ts/lib/function' 2 | import { matchW } from 'fp-ts/lib/TaskEither' 3 | import { useAtomValue } from 'jotai' 4 | import { useEffect, useState } from 'react' 5 | import { serverURL } from '../atoms/settings' 6 | import { ffetch } from '../lib/httpClient' 7 | import { useToast } from './toast' 8 | 9 | const useFetch = (resource: string) => { 10 | const base = useAtomValue(serverURL) 11 | 12 | const { pushMessage } = useToast() 13 | 14 | const [data, setData] = useState() 15 | const [error, setError] = useState() 16 | 17 | const fetcher = () => pipe( 18 | ffetch(`${base}${resource}`), 19 | matchW( 20 | (l) => { 21 | setError(l) 22 | pushMessage(l, 'error') 23 | }, 24 | (r) => setData(r) 25 | ) 26 | )() 27 | 28 | useEffect(() => { 29 | fetcher() 30 | }, []) 31 | 32 | return { data, error, fetcher } 33 | } 34 | 35 | export default useFetch -------------------------------------------------------------------------------- /frontend/src/components/AppBar.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from '@mui/material' 2 | import MuiAppBar, { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar' 3 | 4 | interface AppBarProps extends MuiAppBarProps { 5 | open?: boolean 6 | } 7 | 8 | const drawerWidth = 240 9 | 10 | const AppBar = styled(MuiAppBar, { 11 | shouldForwardProp: (prop) => prop !== 'open', 12 | })(({ theme, open }) => ({ 13 | zIndex: theme.zIndex.drawer + 1, 14 | transition: theme.transitions.create(['width', 'margin'], { 15 | easing: theme.transitions.easing.sharp, 16 | duration: theme.transitions.duration.leavingScreen, 17 | }), 18 | ...(open && { 19 | marginLeft: drawerWidth, 20 | width: `calc(100% - ${drawerWidth}px)`, 21 | transition: theme.transitions.create(['width', 'margin'], { 22 | easing: theme.transitions.easing.sharp, 23 | duration: theme.transitions.duration.enteringScreen, 24 | }), 25 | }), 26 | })) 27 | 28 | export default AppBar -------------------------------------------------------------------------------- /server/status/rest/handler.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/status/domain" 8 | ) 9 | 10 | type RestHandler struct { 11 | service domain.Service 12 | } 13 | 14 | // Status implements domain.RestHandler. 15 | func (h *RestHandler) Status() http.HandlerFunc { 16 | return func(w http.ResponseWriter, r *http.Request) { 17 | defer r.Body.Close() 18 | 19 | w.Header().Set("Content-Type", "application/json") 20 | 21 | status, err := h.service.Status(r.Context()) 22 | if err != nil { 23 | http.Error(w, err.Error(), http.StatusInternalServerError) 24 | return 25 | } 26 | 27 | if err := json.NewEncoder(w).Encode(status); err != nil { 28 | http.Error(w, err.Error(), http.StatusInternalServerError) 29 | return 30 | } 31 | } 32 | } 33 | 34 | func New(service domain.Service) domain.RestHandler { 35 | return &RestHandler{ 36 | service: service, 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /server/openid/config.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/coreos/go-oidc/v3/oidc" 7 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 8 | "golang.org/x/oauth2" 9 | ) 10 | 11 | var ( 12 | oauth2Config oauth2.Config 13 | verifier *oidc.IDTokenVerifier 14 | ) 15 | 16 | func Configure() { 17 | if !config.Instance().UseOpenId { 18 | return 19 | } 20 | 21 | provider, err := oidc.NewProvider(context.Background(), config.Instance().OpenIdProviderURL) 22 | if err != nil { 23 | panic(err) 24 | } 25 | 26 | oauth2Config = oauth2.Config{ 27 | ClientID: config.Instance().OpenIdClientId, 28 | ClientSecret: config.Instance().OpenIdClientSecret, 29 | RedirectURL: config.Instance().OpenIdRedirectURL, 30 | Endpoint: provider.Endpoint(), 31 | Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, 32 | } 33 | 34 | verifier = provider.Verifier(&oidc.Config{ 35 | ClientID: config.Instance().OpenIdClientId, 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /server/archiver/archiver.go: -------------------------------------------------------------------------------- 1 | package archiver 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "log/slog" 7 | 8 | evbus "github.com/asaskevich/EventBus" 9 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive" 10 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 11 | ) 12 | 13 | const QueueName = "process:archive" 14 | 15 | var ( 16 | eventBus = evbus.New() 17 | archiveService archive.Service 18 | ) 19 | 20 | type Message = archive.Entity 21 | 22 | func Register(db *sql.DB) { 23 | _, s := archive.Container(db) 24 | archiveService = s 25 | } 26 | 27 | func init() { 28 | eventBus.Subscribe(QueueName, func(m *Message) { 29 | slog.Info( 30 | "archiving completed download", 31 | slog.String("title", m.Title), 32 | slog.String("source", m.Source), 33 | ) 34 | archiveService.Archive(context.Background(), m) 35 | }) 36 | } 37 | 38 | func Publish(m *Message) { 39 | if config.Instance().AutoArchive { 40 | eventBus.Publish(QueueName, m) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/services/subscriptions.ts: -------------------------------------------------------------------------------- 1 | // import { PaginatedResponse } from '../types' 2 | 3 | export type Subscription = { 4 | id: string 5 | url: string 6 | params: string 7 | cron_expression: string 8 | } 9 | 10 | // class SubscriptionService { 11 | // private _baseURL: string = '' 12 | 13 | // public set baseURL(v: string) { 14 | // this._baseURL = v 15 | // } 16 | 17 | // public async delete(id: string): Promise { 18 | 19 | // } 20 | 21 | // public async listPaginated(start: number, limit: number = 50): Promise> { 22 | // const res = await fetch(`${this._baseURL}/subscriptions?id=${start}&limit=${limit}`) 23 | // const data: PaginatedResponse = await res.json() 24 | 25 | // return data 26 | // } 27 | 28 | // public async submit(sub: Subscription): Promise { 29 | 30 | // } 31 | 32 | // public async edit(sub: Subscription): Promise { 33 | 34 | // } 35 | // } 36 | 37 | // export default SubscriptionService -------------------------------------------------------------------------------- /server/rpc/wrapper.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/rpc/jsonrpc" 7 | ) 8 | 9 | // Wrapper for jsonrpc.ServeConn that simplifies its usage 10 | type rpcRequest struct { 11 | r io.Reader 12 | rw io.ReadWriter 13 | done chan bool 14 | } 15 | 16 | // Takes a reader that can be an *http.Request or anthing that implements 17 | // io.ReadWriter interface. 18 | // Call() will perform the jsonRPC call and write or read from the ReadWriter 19 | func newRequest(r io.Reader) *rpcRequest { 20 | var buf bytes.Buffer 21 | done := make(chan bool) 22 | return &rpcRequest{r, &buf, done} 23 | } 24 | 25 | func (r *rpcRequest) Read(p []byte) (n int, err error) { 26 | return r.r.Read(p) 27 | } 28 | 29 | func (r *rpcRequest) Write(p []byte) (n int, err error) { 30 | return r.rw.Write(p) 31 | } 32 | 33 | func (r *rpcRequest) Close() error { 34 | r.done <- true 35 | return nil 36 | } 37 | 38 | func (r *rpcRequest) Call() io.Reader { 39 | go jsonrpc.ServeConn(r) 40 | <-r.done 41 | return r.rw 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/components/UpdateBinaryButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, CircularProgress } from '@mui/material' 2 | import { useI18n } from '../hooks/useI18n' 3 | import { useRPC } from '../hooks/useRPC' 4 | import { useState } from 'react' 5 | import { useToast } from '../hooks/toast' 6 | 7 | const UpdateBinaryButton: React.FC = () => { 8 | const { i18n } = useI18n() 9 | const { client } = useRPC() 10 | const { pushMessage } = useToast() 11 | 12 | const [isLoading, setIsLoading] = useState(false) 13 | 14 | const updateBinary = () => { 15 | setIsLoading(true) 16 | client 17 | .updateExecutable() 18 | .then(() => pushMessage(i18n.t('toastUpdated'), 'success')) 19 | .then(() => setIsLoading(false)) 20 | } 21 | 22 | return ( 23 | 30 | ) 31 | } 32 | 33 | export default UpdateBinaryButton -------------------------------------------------------------------------------- /server/archive/provider.go: -------------------------------------------------------------------------------- 1 | package archive 2 | 3 | import ( 4 | "database/sql" 5 | "sync" 6 | 7 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain" 8 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/repository" 9 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/rest" 10 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/service" 11 | ) 12 | 13 | var ( 14 | repo domain.Repository 15 | svc domain.Service 16 | hand domain.RestHandler 17 | 18 | repoOnce sync.Once 19 | svcOnce sync.Once 20 | handOnce sync.Once 21 | ) 22 | 23 | func provideRepository(db *sql.DB) domain.Repository { 24 | repoOnce.Do(func() { 25 | repo = repository.New(db) 26 | }) 27 | return repo 28 | } 29 | 30 | func provideService(r domain.Repository) domain.Service { 31 | svcOnce.Do(func() { 32 | svc = service.New(r) 33 | }) 34 | return svc 35 | } 36 | 37 | func provideHandler(s domain.Service) domain.RestHandler { 38 | handOnce.Do(func() { 39 | hand = rest.New(s) 40 | }) 41 | return hand 42 | } 43 | -------------------------------------------------------------------------------- /examples/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | 8 | http { 9 | include mime.types; 10 | 11 | default_type application/octet-stream; 12 | 13 | sendfile on; 14 | keepalive_timeout 65; 15 | 16 | gzip on; 17 | 18 | server { 19 | listen 80; 20 | server_name localhost; 21 | 22 | location ~/yt-dlp/(.*)$ { 23 | proxy_pass http://127.0.0.1:3033/$1; 24 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 25 | proxy_set_header Host $http_host; 26 | proxy_http_version 1.1; 27 | proxy_set_header Upgrade $http_upgrade; 28 | proxy_set_header Connection "upgrade"; 29 | 30 | client_max_body_size 20000m; 31 | proxy_connect_timeout 3000; 32 | proxy_send_timeout 3000; 33 | proxy_read_timeout 3000; 34 | send_timeout 3000; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /server/rpc/container.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "github.com/go-chi/chi/v5" 5 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 6 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal" 7 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal/livestream" 8 | middlewares "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/middleware" 9 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/openid" 10 | ) 11 | 12 | // Dependency injection container. 13 | func Container(db *internal.MemoryDB, mq *internal.MessageQueue, lm *livestream.Monitor) *Service { 14 | return &Service{ 15 | db: db, 16 | mq: mq, 17 | lm: lm, 18 | } 19 | } 20 | 21 | // RPC service must be registered before applying this router! 22 | func ApplyRouter() func(chi.Router) { 23 | return func(r chi.Router) { 24 | if config.Instance().RequireAuth { 25 | r.Use(middlewares.Authenticated) 26 | } 27 | if config.Instance().UseOpenId { 28 | r.Use(openid.Middleware) 29 | } 30 | r.Get("/ws", WebSocket) 31 | r.Post("/http", Post) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/components/CustomArgsTextField.tsx: -------------------------------------------------------------------------------- 1 | import { TextField } from '@mui/material' 2 | import { useAtom, useAtomValue } from 'jotai' 3 | import { customArgsState } from '../atoms/downloadTemplate' 4 | import { settingsState } from '../atoms/settings' 5 | import { useI18n } from '../hooks/useI18n' 6 | import { useEffect } from 'react' 7 | 8 | const CustomArgsTextField: React.FC = () => { 9 | const { i18n } = useI18n() 10 | 11 | const settings = useAtomValue(settingsState) 12 | 13 | const [customArgs, setCustomArgs] = useAtom(customArgsState) 14 | 15 | useEffect(() => { 16 | setCustomArgs('') 17 | }, []) 18 | 19 | const handleCustomArgsChange = (e: React.ChangeEvent) => { 20 | setCustomArgs(e.target.value) 21 | } 22 | 23 | return ( 24 | 32 | ) 33 | } 34 | 35 | export default CustomArgsTextField -------------------------------------------------------------------------------- /frontend/src/components/Drawer.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from '@mui/material' 2 | import MuiDrawer from '@mui/material/Drawer' 3 | 4 | const drawerWidth = 240 5 | 6 | const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open' })( 7 | ({ theme, open }) => ({ 8 | '& .MuiDrawer-paper': { 9 | position: 'relative', 10 | whiteSpace: 'nowrap', 11 | width: drawerWidth, 12 | transition: theme.transitions.create('width', { 13 | easing: theme.transitions.easing.sharp, 14 | duration: theme.transitions.duration.enteringScreen, 15 | }), 16 | boxSizing: 'border-box', 17 | ...(!open && { 18 | overflowX: 'hidden', 19 | transition: theme.transitions.create('width', { 20 | easing: theme.transitions.easing.sharp, 21 | duration: theme.transitions.duration.leavingScreen, 22 | }), 23 | width: theme.spacing(7), 24 | [theme.breakpoints.up('sm')]: { 25 | width: theme.spacing(9), 26 | }, 27 | }), 28 | }, 29 | }), 30 | ) 31 | 32 | export default Drawer -------------------------------------------------------------------------------- /frontend/src/components/livestream/LivestreamSpeedDial.tsx: -------------------------------------------------------------------------------- 1 | import AddCircleIcon from '@mui/icons-material/AddCircle' 2 | import DeleteForeverIcon from '@mui/icons-material/DeleteForever' 3 | import { SpeedDial, SpeedDialAction, SpeedDialIcon } from '@mui/material' 4 | import { useI18n } from '../../hooks/useI18n' 5 | 6 | type Props = { 7 | onOpen: () => void 8 | onStopAll: () => void 9 | } 10 | 11 | const LivestreamSpeedDial: React.FC = ({ onOpen, onStopAll }) => { 12 | const { i18n } = useI18n() 13 | 14 | return ( 15 | } 19 | > 20 | } 22 | tooltipTitle={i18n.t('abortAllButton')} 23 | onClick={onStopAll} 24 | /> 25 | } 27 | tooltipTitle={i18n.t('newDownloadButton')} 28 | onClick={onOpen} 29 | /> 30 | 31 | ) 32 | } 33 | 34 | export default LivestreamSpeedDial -------------------------------------------------------------------------------- /frontend/src/components/livestream/NoLivestreams.tsx: -------------------------------------------------------------------------------- 1 | import LiveTvIcon from '@mui/icons-material/LiveTv' 2 | import { Container, SvgIcon, Typography, styled } from '@mui/material' 3 | import { useI18n } from '../../hooks/useI18n' 4 | 5 | const FlexContainer = styled(Container)({ 6 | display: 'flex', 7 | minWidth: '100%', 8 | minHeight: '80vh', 9 | alignItems: 'center', 10 | justifyContent: 'center', 11 | flexDirection: 'column' 12 | }) 13 | 14 | const Title = styled(Typography)({ 15 | display: 'flex', 16 | width: '100%', 17 | alignItems: 'center', 18 | justifyContent: 'center', 19 | paddingBottom: '0.5rem' 20 | }) 21 | 22 | 23 | export default function NoLivestreams() { 24 | const { i18n } = useI18n() 25 | 26 | return ( 27 | 28 | 29 | <SvgIcon sx={{ fontSize: '200px' }}> 30 | <LiveTvIcon /> 31 | </SvgIcon> 32 | 33 | 34 | {i18n.t('livestreamNoMonitoring')} 35 | 36 | 37 | ) 38 | } -------------------------------------------------------------------------------- /frontend/src/lib/httpClient.ts: -------------------------------------------------------------------------------- 1 | import { tryCatch } from 'fp-ts/TaskEither' 2 | import * as J from 'fp-ts/Json' 3 | import * as E from 'fp-ts/Either' 4 | import { pipe } from 'fp-ts/lib/function' 5 | 6 | async function fetcher(url: string, opt?: RequestInit, controller?: AbortController): Promise { 7 | const jwt = localStorage.getItem('token') 8 | 9 | if (opt && !opt.headers) { 10 | opt.headers = { 11 | 'Content-Type': 'application/json', 12 | } 13 | } 14 | 15 | const res = await fetch(url, { 16 | ...opt, 17 | headers: { 18 | ...opt?.headers, 19 | 'X-Authentication': jwt ?? '' 20 | }, 21 | signal: controller?.signal 22 | }) 23 | 24 | if (!res.ok) { 25 | throw await res.text() 26 | } 27 | 28 | 29 | 30 | return res.text() 31 | } 32 | 33 | export const ffetch = (url: string, opt?: RequestInit, controller?: AbortController) => tryCatch( 34 | async () => pipe( 35 | await fetcher(url, opt, controller), 36 | J.parse, 37 | E.match( 38 | (l) => l as T, 39 | (r) => r as T 40 | ) 41 | ), 42 | (e) => `error while fetching: ${e}` 43 | ) 44 | -------------------------------------------------------------------------------- /frontend/src/components/subscriptions/NoSubscriptions.tsx: -------------------------------------------------------------------------------- 1 | import UpdateIcon from '@mui/icons-material/Update' 2 | import { Container, SvgIcon, Typography, styled } from '@mui/material' 3 | import { useI18n } from '../../hooks/useI18n' 4 | 5 | const FlexContainer = styled(Container)({ 6 | display: 'flex', 7 | minWidth: '100%', 8 | minHeight: '80vh', 9 | alignItems: 'center', 10 | justifyContent: 'center', 11 | flexDirection: 'column' 12 | }) 13 | 14 | const Title = styled(Typography)({ 15 | display: 'flex', 16 | width: '100%', 17 | alignItems: 'center', 18 | justifyContent: 'center', 19 | paddingBottom: '0.5rem' 20 | }) 21 | 22 | 23 | export default function NoSubscriptions() { 24 | const { i18n } = useI18n() 25 | 26 | return ( 27 | 28 | 29 | <SvgIcon sx={{ fontSize: '200px' }}> 30 | <UpdateIcon /> 31 | </SvgIcon> 32 | 33 | 34 | {i18n.t('subscriptionsEmptyLabel')} 35 | 36 | 37 | ) 38 | } -------------------------------------------------------------------------------- /frontend/src/atoms/status.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from 'fp-ts/lib/function' 2 | import { of } from 'fp-ts/lib/Task' 3 | import { getOrElse } from 'fp-ts/lib/TaskEither' 4 | import { ffetch } from '../lib/httpClient' 5 | import { RPCVersion } from '../types' 6 | import { rpcClientState } from './rpc' 7 | import { serverURL } from './settings' 8 | import { atom } from 'jotai' 9 | 10 | export const connectedState = atom(false) 11 | 12 | export const freeSpaceBytesState = atom(async (get) => { 13 | const res = await get(rpcClientState) 14 | .freeSpace() 15 | .catch(() => ({ result: 0 })) 16 | return res.result 17 | }) 18 | 19 | export const availableDownloadPathsState = atom(async (get) => { 20 | const res = await get(rpcClientState).directoryTree() 21 | .catch(() => ({ result: [] })) 22 | return res.result 23 | }) 24 | 25 | export const ytdlpRpcVersionState = atom>(async (get) => await pipe( 26 | ffetch(`${get(serverURL)}/api/v1/version`), 27 | getOrElse(() => pipe( 28 | { 29 | rpcVersion: 'unknown version', 30 | ytdlpVersion: 'unknown version', 31 | }, 32 | of 33 | )), 34 | )()) -------------------------------------------------------------------------------- /frontend/src/atoms/downloadTemplate.ts: -------------------------------------------------------------------------------- 1 | import { getOrElse } from 'fp-ts/lib/Either' 2 | import { pipe } from 'fp-ts/lib/function' 3 | import { atom } from 'jotai' 4 | import { atomWithStorage } from 'jotai/utils' 5 | import { ffetch } from '../lib/httpClient' 6 | import { CustomTemplate } from '../types' 7 | import { serverSideCookiesState, serverURL } from './settings' 8 | 9 | export const cookiesTemplateState = atom>(async (get) => 10 | await get(serverSideCookiesState) 11 | ? '--cookies=cookies.txt' 12 | : '' 13 | ) 14 | 15 | export const customArgsState = atomWithStorage( 16 | 'customArgs', 17 | localStorage.getItem('customArgs') ?? '' 18 | ) 19 | 20 | export const filenameTemplateState = atomWithStorage( 21 | 'lastFilenameTemplate', 22 | localStorage.getItem('lastFilenameTemplate') ?? '' 23 | ) 24 | 25 | export const savedTemplatesState = atom>(async (get) => { 26 | const task = ffetch(`${get(serverURL)}/api/v1/template/all`) 27 | const either = await task() 28 | 29 | return pipe( 30 | either, 31 | getOrElse(() => new Array()) 32 | ) 33 | } 34 | ) -------------------------------------------------------------------------------- /server/formats/parser.go: -------------------------------------------------------------------------------- 1 | package formats 2 | 3 | import ( 4 | "encoding/json" 5 | "log/slog" 6 | "os/exec" 7 | "sync" 8 | 9 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 10 | ) 11 | 12 | func ParseURL(url string) (*Metadata, error) { 13 | cmd := exec.Command(config.Instance().DownloaderPath, url, "-J") 14 | 15 | stdout, err := cmd.Output() 16 | if err != nil { 17 | slog.Error("failed to retrieve metadata", slog.String("err", err.Error())) 18 | return nil, err 19 | } 20 | 21 | slog.Info( 22 | "retrieving metadata", 23 | slog.String("caller", "getFormats"), 24 | slog.String("url", url), 25 | ) 26 | 27 | info := &Metadata{URL: url} 28 | best := &Format{} 29 | 30 | var ( 31 | wg sync.WaitGroup 32 | decodingError error 33 | ) 34 | 35 | wg.Add(2) 36 | 37 | go func() { 38 | decodingError = json.Unmarshal(stdout, &info) 39 | wg.Done() 40 | }() 41 | 42 | go func() { 43 | decodingError = json.Unmarshal(stdout, &best) 44 | wg.Done() 45 | }() 46 | 47 | wg.Wait() 48 | 49 | if decodingError != nil { 50 | return nil, err 51 | } 52 | 53 | info.Best = *best 54 | 55 | return info, nil 56 | } 57 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/marcopiovanello/yt-dlp-web-ui/v3 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef 7 | github.com/coreos/go-oidc/v3 v3.12.0 8 | github.com/go-chi/chi/v5 v5.2.0 9 | github.com/go-chi/cors v1.2.1 10 | github.com/golang-jwt/jwt/v5 v5.2.1 11 | github.com/google/uuid v1.6.0 12 | github.com/gorilla/websocket v1.5.3 13 | github.com/robfig/cron/v3 v3.0.0 14 | golang.org/x/oauth2 v0.25.0 15 | golang.org/x/sync v0.10.0 16 | golang.org/x/sys v0.29.0 17 | gopkg.in/yaml.v3 v3.0.1 18 | modernc.org/sqlite v1.34.5 19 | ) 20 | 21 | require ( 22 | github.com/dustin/go-humanize v1.0.1 // indirect 23 | github.com/go-jose/go-jose/v4 v4.0.4 // indirect 24 | github.com/mattn/go-isatty v0.0.20 // indirect 25 | github.com/ncruces/go-strftime v0.1.9 // indirect 26 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 27 | golang.org/x/crypto v0.32.0 // indirect 28 | golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect 29 | modernc.org/libc v1.61.11 // indirect 30 | modernc.org/mathutil v1.7.1 // indirect 31 | modernc.org/memory v1.8.2 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /server/logging/observable_logger.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | /* 8 | Logger implementation using the observable pattern. 9 | Implements io.Writer interface. 10 | 11 | The observable is an event source which drops everythigng unless there's 12 | a subscriber connected. 13 | 14 | The observer implementatios are a http ServerSentEvents handler and a 15 | websocket one in handler.go 16 | */ 17 | type ObservableLogger struct { 18 | logsChan chan []byte 19 | } 20 | 21 | func NewObservableLogger() *ObservableLogger { 22 | return &ObservableLogger{ 23 | logsChan: make(chan []byte, 100), 24 | } 25 | } 26 | 27 | func (o *ObservableLogger) Write(p []byte) (n int, err error) { 28 | select { 29 | case o.logsChan <- p: 30 | n = len(p) 31 | err = nil 32 | return 33 | default: 34 | return 35 | } 36 | } 37 | 38 | func (o *ObservableLogger) Observe(ctx context.Context) <-chan string { 39 | logs := make(chan string) 40 | 41 | go func() { 42 | for { 43 | select { 44 | case <-ctx.Done(): 45 | return 46 | case logLine := <-o.logsChan: 47 | logs <- string(logLine) 48 | } 49 | } 50 | }() 51 | 52 | return logs 53 | } 54 | -------------------------------------------------------------------------------- /server/internal/balancer.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "container/heap" 5 | "log/slog" 6 | ) 7 | 8 | type LoadBalancer struct { 9 | pool Pool 10 | done chan *Worker 11 | } 12 | 13 | func NewLoadBalancer(numWorker int) *LoadBalancer { 14 | var pool Pool 15 | 16 | doneChan := make(chan *Worker) 17 | 18 | for i := range numWorker { 19 | w := &Worker{ 20 | requests: make(chan *Process, 1), 21 | index: i, 22 | } 23 | go w.Work(doneChan) 24 | pool = append(pool, w) 25 | 26 | slog.Info("spawned worker", slog.Int("index", i)) 27 | } 28 | 29 | return &LoadBalancer{ 30 | pool: pool, 31 | done: doneChan, 32 | } 33 | } 34 | 35 | func (b *LoadBalancer) Balance(work chan *Process) { 36 | for { 37 | select { 38 | case req := <-work: 39 | b.dispatch(req) 40 | case w := <-b.done: 41 | b.completed(w) 42 | } 43 | } 44 | } 45 | 46 | func (b *LoadBalancer) dispatch(req *Process) { 47 | w := heap.Pop(&b.pool).(*Worker) 48 | w.requests <- req 49 | w.pending++ 50 | heap.Push(&b.pool, w) 51 | } 52 | 53 | func (b *LoadBalancer) completed(w *Worker) { 54 | w.pending-- 55 | heap.Remove(&b.pool, w.index) 56 | heap.Push(&b.pool, w) 57 | } 58 | -------------------------------------------------------------------------------- /server/subscription/provider.go: -------------------------------------------------------------------------------- 1 | package subscription 2 | 3 | import ( 4 | "database/sql" 5 | "sync" 6 | 7 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/domain" 8 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/repository" 9 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/rest" 10 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/service" 11 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/task" 12 | ) 13 | 14 | var ( 15 | repo domain.Repository 16 | svc domain.Service 17 | hand domain.RestHandler 18 | 19 | repoOnce sync.Once 20 | svcOnce sync.Once 21 | handOnce sync.Once 22 | ) 23 | 24 | func provideRepository(db *sql.DB) domain.Repository { 25 | repoOnce.Do(func() { 26 | repo = repository.New(db) 27 | }) 28 | return repo 29 | } 30 | 31 | func provideService(r domain.Repository, runner task.TaskRunner) domain.Service { 32 | svcOnce.Do(func() { 33 | svc = service.New(r, runner) 34 | }) 35 | return svc 36 | } 37 | 38 | func provideHandler(s domain.Service) domain.RestHandler { 39 | handOnce.Do(func() { 40 | hand = rest.New(s) 41 | }) 42 | return hand 43 | } 44 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/go 3 | { 4 | "name": "Go", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/go:1-1.23-bookworm", 7 | "features": { 8 | "ghcr.io/devcontainers-extra/features/pnpm:2": {}, 9 | "ghcr.io/devcontainers-extra/features/ffmpeg-apt-get:1": {}, 10 | "ghcr.io/devcontainers-extra/features/yt-dlp:2": {} 11 | } 12 | 13 | // Features to add to the dev container. More info: https://containers.dev/features. 14 | // "features": {}, 15 | 16 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 17 | // "forwardPorts": [], 18 | 19 | // Use 'postCreateCommand' to run commands after the container is created. 20 | // "postCreateCommand": "go version" 21 | 22 | // Configure tool-specific properties. 23 | // "customizations": {}, 24 | 25 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 26 | // "remoteUser": "root" 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/components/ThemeToggler.tsx: -------------------------------------------------------------------------------- 1 | import Brightness4 from '@mui/icons-material/Brightness4' 2 | import Brightness5 from '@mui/icons-material/Brightness5' 3 | import BrightnessAuto from '@mui/icons-material/BrightnessAuto' 4 | import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material' 5 | import { Theme, themeState } from '../atoms/settings' 6 | import { useI18n } from '../hooks/useI18n' 7 | import { useAtom } from 'jotai' 8 | 9 | const ThemeToggler: React.FC = () => { 10 | const [theme, setTheme] = useAtom(themeState) 11 | 12 | const actions: Record = { 13 | system: , 14 | light: , 15 | dark: , 16 | } 17 | 18 | const themes: Theme[] = ['system', 'light', 'dark'] 19 | const currentTheme = themes.indexOf(theme) 20 | 21 | const { i18n } = useI18n() 22 | 23 | return ( 24 | { 25 | setTheme(themes[(currentTheme + 1) % themes.length]) 26 | }}> 27 | 28 | {actions[theme]} 29 | 30 | 31 | 32 | ) 33 | } 34 | 35 | export default ThemeToggler -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yt-dlp-webui", 3 | "version": "3.2.6", 4 | "description": "Frontend compontent of yt-dlp-webui", 5 | "scripts": { 6 | "dev": "vite --host 0.0.0.0", 7 | "build": "vite build" 8 | }, 9 | "type": "module", 10 | "author": "marcopiovanello", 11 | "license": "GPL-3.0-only", 12 | "private": true, 13 | "dependencies": { 14 | "@emotion/react": "^11.14.0", 15 | "@emotion/styled": "^11.14.0", 16 | "@fontsource/roboto": "^5.0.13", 17 | "@fontsource/roboto-mono": "^5.0.18", 18 | "@mui/icons-material": "^6.2.0", 19 | "@mui/material": "^6.2.0", 20 | "fp-ts": "^2.16.5", 21 | "jotai": "^2.10.3", 22 | "react": "^19.0.0", 23 | "react-dom": "^19.0.0", 24 | "react-router-dom": "^6.23.1", 25 | "react-virtuoso": "^4.7.11", 26 | "rxjs": "^7.8.1" 27 | }, 28 | "devDependencies": { 29 | "@modyfi/vite-plugin-yaml": "^1.1.0", 30 | "@types/node": "^20.14.2", 31 | "@types/react": "^19.0.1", 32 | "@types/react-dom": "^19.0.2", 33 | "@types/react-helmet": "^6.1.11", 34 | "@types/react-router-dom": "^5.3.3", 35 | "@vitejs/plugin-react-swc": "^3.7.2", 36 | "typescript": "^5.7.2", 37 | "vite": "^6.0.3" 38 | } 39 | } -------------------------------------------------------------------------------- /server/rpc/handlers.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | 7 | "github.com/gorilla/websocket" 8 | ) 9 | 10 | var upgrader = websocket.Upgrader{ 11 | CheckOrigin: func(r *http.Request) bool { 12 | return true 13 | }, 14 | } 15 | 16 | // WebSockets JSON-RPC handler 17 | func WebSocket(w http.ResponseWriter, r *http.Request) { 18 | c, err := upgrader.Upgrade(w, r, nil) 19 | if err != nil { 20 | http.Error(w, err.Error(), http.StatusInternalServerError) 21 | return 22 | } 23 | 24 | defer c.Close() 25 | 26 | // notify client that conn is open and ok 27 | c.WriteJSON(struct{ Status string }{Status: "connected"}) 28 | 29 | for { 30 | mtype, reader, err := c.NextReader() 31 | if err != nil { 32 | break 33 | } 34 | 35 | res := newRequest(reader).Call() 36 | 37 | writer, err := c.NextWriter(mtype) 38 | if err != nil { 39 | http.Error(w, err.Error(), http.StatusInternalServerError) 40 | break 41 | } 42 | 43 | io.Copy(writer, res) 44 | } 45 | } 46 | 47 | // HTTP-POST JSON-RPC handler 48 | func Post(w http.ResponseWriter, r *http.Request) { 49 | defer r.Body.Close() 50 | 51 | res := newRequest(r.Body).Call() 52 | _, err := io.Copy(w, res) 53 | 54 | if err != nil { 55 | http.Error(w, err.Error(), http.StatusInternalServerError) 56 | return 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /server/rest/container.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "github.com/go-chi/chi/v5" 5 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 6 | middlewares "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/middleware" 7 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/openid" 8 | ) 9 | 10 | func Container(args *ContainerArgs) *Handler { 11 | var ( 12 | service = ProvideService(args) 13 | handler = ProvideHandler(service) 14 | ) 15 | return handler 16 | } 17 | 18 | func ApplyRouter(args *ContainerArgs) func(chi.Router) { 19 | h := Container(args) 20 | 21 | return func(r chi.Router) { 22 | if config.Instance().RequireAuth { 23 | r.Use(middlewares.Authenticated) 24 | } 25 | if config.Instance().UseOpenId { 26 | r.Use(openid.Middleware) 27 | } 28 | r.Post("/exec", h.Exec()) 29 | r.Post("/execPlaylist", h.ExecPlaylist()) 30 | r.Post("/execLivestream", h.ExecLivestream()) 31 | r.Get("/running", h.Running()) 32 | r.Get("/version", h.GetVersion()) 33 | r.Get("/cookies", h.GetCookies()) 34 | r.Post("/cookies", h.SetCookies()) 35 | r.Delete("/cookies", h.DeleteCookies()) 36 | r.Post("/template", h.AddTemplate()) 37 | r.Patch("/template", h.UpdateTemplate()) 38 | r.Get("/template/all", h.GetTemplates()) 39 | r.Delete("/template/{id}", h.DeleteTemplate()) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /frontend/src/hooks/observable.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { Observable } from 'rxjs' 3 | 4 | /** 5 | * Handles the subscription and unsubscription from an observable. 6 | * Automatically disposes the subscription. 7 | * @param source$ source observable 8 | * @param nextHandler subscriber function 9 | * @param errHandler error catching callback 10 | */ 11 | export function useSubscription( 12 | source$: Observable, 13 | nextHandler: (value: T) => void, 14 | errHandler?: (err: any) => void, 15 | ) { 16 | useEffect(() => { 17 | if (source$) { 18 | const sub = source$.subscribe({ 19 | next: nextHandler, 20 | error: errHandler, 21 | }) 22 | return () => sub.unsubscribe() 23 | } 24 | }, [source$]) 25 | } 26 | 27 | /** 28 | * Use an observable as state 29 | * @param source$ source observable 30 | * @param initialState the initial state prior to the emission 31 | * @param errHandler error catching callback 32 | * @returns value emitted to the observable 33 | */ 34 | export function useObservable( 35 | source$: Observable, 36 | initialState: T, 37 | errHandler?: (err: any) => void, 38 | ): T { 39 | const [value, setValue] = useState(initialState) 40 | 41 | useSubscription(source$, setValue, errHandler) 42 | 43 | return value 44 | } -------------------------------------------------------------------------------- /frontend/src/components/Splash.tsx: -------------------------------------------------------------------------------- 1 | import CloudDownloadIcon from '@mui/icons-material/CloudDownload' 2 | import { Container, SvgIcon, Typography, styled } from '@mui/material' 3 | import { activeDownloadsState } from '../atoms/downloads' 4 | import { useI18n } from '../hooks/useI18n' 5 | import { useAtomValue } from 'jotai' 6 | 7 | const FlexContainer = styled(Container)({ 8 | display: 'flex', 9 | minWidth: '100%', 10 | minHeight: '80vh', 11 | alignItems: 'center', 12 | justifyContent: 'center', 13 | flexDirection: 'column' 14 | }) 15 | 16 | const Title = styled(Typography)({ 17 | display: 'flex', 18 | width: '100%', 19 | alignItems: 'center', 20 | justifyContent: 'center', 21 | paddingBottom: '0.5rem' 22 | }) 23 | 24 | export default function Splash() { 25 | const { i18n } = useI18n() 26 | const activeDownloads = useAtomValue(activeDownloadsState) 27 | 28 | if (activeDownloads.length !== 0) { 29 | return null 30 | } 31 | 32 | return ( 33 | 34 | 35 | <SvgIcon sx={{ fontSize: '200px' }}> 36 | <CloudDownloadIcon /> 37 | </SvgIcon> 38 | 39 | 40 | {i18n.t('splashText')} 41 | 42 | 43 | ) 44 | } -------------------------------------------------------------------------------- /frontend/src/providers/ToasterProvider.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, Snackbar } from "@mui/material" 2 | import { Toast, toastListState } from '../atoms/toast' 3 | import { useEffect } from 'react' 4 | import { useAtom } from 'jotai' 5 | 6 | const Toaster: React.FC = () => { 7 | const [toasts, setToasts] = useAtom(toastListState) 8 | 9 | const deletePredicate = (t: Toast) => (Date.now() - t.createdAt) < 2000 10 | 11 | useEffect(() => { 12 | if (toasts.length > 0) { 13 | const closer = setInterval(() => { 14 | setToasts(t => t.map(t => ({ ...t, open: deletePredicate(t) }))) 15 | }, 900) 16 | 17 | const cleaner = setInterval(() => { 18 | setToasts(t => t.filter(deletePredicate)) 19 | }, 2005) 20 | 21 | return () => { 22 | clearInterval(closer) 23 | clearInterval(cleaner) 24 | } 25 | } 26 | }, [setToasts, toasts.length]) 27 | 28 | return ( 29 | <> 30 | {toasts.map((toast, index) => ( 31 | 0 ? { marginBottom: index * 6.5 } : null} 35 | > 36 | 37 | {toast.message} 38 | 39 | 40 | ))} 41 | 42 | ) 43 | } 44 | 45 | export default Toaster -------------------------------------------------------------------------------- /frontend/src/components/EmptyArchive.tsx: -------------------------------------------------------------------------------- 1 | import ArchiveIcon from '@mui/icons-material/Archive' 2 | import { Container, SvgIcon, Typography, styled } from '@mui/material' 3 | import { activeDownloadsState } from '../atoms/downloads' 4 | import { useI18n } from '../hooks/useI18n' 5 | import { useAtomValue } from 'jotai' 6 | 7 | const FlexContainer = styled(Container)({ 8 | display: 'flex', 9 | minWidth: '100%', 10 | minHeight: '80vh', 11 | alignItems: 'center', 12 | justifyContent: 'center', 13 | flexDirection: 'column' 14 | }) 15 | 16 | const Title = styled(Typography)({ 17 | display: 'flex', 18 | width: '100%', 19 | alignItems: 'center', 20 | justifyContent: 'center', 21 | paddingBottom: '0.5rem' 22 | }) 23 | 24 | export default function EmptyArchive() { 25 | const { i18n } = useI18n() 26 | const activeDownloads = useAtomValue(activeDownloadsState) 27 | 28 | if (activeDownloads.length !== 0) { 29 | return null 30 | } 31 | 32 | return ( 33 | 34 | 35 | <SvgIcon sx={{ fontSize: '200px' }}> 36 | <ArchiveIcon /> 37 | </SvgIcon> 38 | 39 | 40 | {/* {i18n.t('splashText')} */} 41 | Empty Archive 42 | 43 | 44 | ) 45 | } -------------------------------------------------------------------------------- /nix/server.nix: -------------------------------------------------------------------------------- 1 | { yt-dlp-web-ui-frontend, buildGo123Module, lib, makeWrapper, yt-dlp, ... }: 2 | let 3 | fs = lib.fileset; 4 | common = import ./common.nix { inherit lib; }; 5 | in 6 | buildGo123Module { 7 | pname = "yt-dlp-web-ui"; 8 | inherit (common) version; 9 | src = fs.toSource rec { 10 | root = ../.; 11 | fileset = fs.difference root (fs.unions [ 12 | ### LIST OF FILES TO IGNORE ### 13 | # frontend (this is included by the frontend.nix drv instead) 14 | ../frontend 15 | # documentation 16 | ../examples 17 | # docker 18 | ../Dockerfile 19 | ../docker-compose.yml 20 | # nix 21 | ./devShell.nix 22 | ../.envrc 23 | ./tests 24 | # make 25 | ../Makefile # this derivation does not use the project Makefile 26 | # repo commons 27 | ../.github 28 | ../README.md 29 | ../LICENSE 30 | ../.gitignore 31 | ../.vscode 32 | ]); 33 | }; 34 | 35 | # https://github.com/golang/go/issues/44507 36 | preBuild = '' 37 | cp -r ${yt-dlp-web-ui-frontend} frontend 38 | ''; 39 | 40 | nativeBuildInputs = [ makeWrapper ]; 41 | 42 | postInstall = '' 43 | wrapProgram $out/bin/yt-dlp-web-ui \ 44 | --prefix PATH : ${lib.makeBinPath [ yt-dlp ]} 45 | ''; 46 | 47 | vendorHash = "sha256-c7IdCmYJEn5qJn3K8wt0qz3t0Nq9rbgWp1eONlCJOwM="; 48 | 49 | meta = common.meta // { 50 | mainProgram = "yt-dlp-web-ui"; 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /frontend/src/components/HomeActions.tsx: -------------------------------------------------------------------------------- 1 | import { useSetAtom } from 'jotai' 2 | import { Suspense, useState } from 'react' 3 | import { loadingAtom } from '../atoms/ui' 4 | import { useToast } from '../hooks/toast' 5 | import DownloadDialog from './DownloadDialog' 6 | import HomeSpeedDial from './HomeSpeedDial' 7 | import TemplatesEditor from './TemplatesEditor' 8 | 9 | const HomeActions: React.FC = () => { 10 | const setIsLoading = useSetAtom(loadingAtom) 11 | 12 | const [openDownload, setOpenDownload] = useState(false) 13 | const [openEditor, setOpenEditor] = useState(false) 14 | 15 | const { pushMessage } = useToast() 16 | 17 | return ( 18 | <> 19 | setOpenDownload(true)} 21 | onEditorOpen={() => setOpenEditor(true)} 22 | /> 23 | 24 | { 27 | setOpenDownload(false) 28 | setIsLoading(true) 29 | }} 30 | // TODO: handle optimistic UI update 31 | onDownloadStart={(url) => { 32 | pushMessage(`Requested ${url}`, 'info') 33 | setOpenDownload(false) 34 | setIsLoading(true) 35 | }} 36 | /> 37 | 38 | setOpenEditor(false)} 41 | /> 42 | 43 | ) 44 | } 45 | 46 | export default HomeActions -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Node (pnpm) ------------------------------------------------------------------ 2 | FROM node:22-slim AS ui 3 | ENV PNPM_HOME="/pnpm" 4 | ENV PATH="$PNPM_HOME:$PATH" 5 | RUN corepack prepare pnpm@10.0.0 --activate && corepack enable 6 | COPY . /usr/src/yt-dlp-webui 7 | 8 | WORKDIR /usr/src/yt-dlp-webui/frontend 9 | 10 | RUN rm -rf node_modules 11 | 12 | RUN pnpm install 13 | RUN pnpm run build 14 | # ----------------------------------------------------------------------------- 15 | 16 | # Go -------------------------------------------------------------------------- 17 | FROM golang AS build 18 | 19 | WORKDIR /usr/src/yt-dlp-webui 20 | 21 | COPY . . 22 | COPY --from=ui /usr/src/yt-dlp-webui/frontend /usr/src/yt-dlp-webui/frontend 23 | 24 | RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui 25 | # ----------------------------------------------------------------------------- 26 | 27 | # Runtime --------------------------------------------------------------------- 28 | FROM python:3.13.2-alpine3.21 29 | 30 | RUN apk update && \ 31 | apk add ffmpeg ca-certificates curl wget gnutls --no-cache && \ 32 | pip install "yt-dlp[default,curl-cffi,mutagen,pycryptodomex,phantomjs,secretstorage]" 33 | 34 | VOLUME /downloads /config 35 | 36 | WORKDIR /app 37 | 38 | COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app 39 | 40 | ENV JWT_SECRET=secret 41 | 42 | EXPOSE 3033 43 | ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml", "--db", "/config/local.db" ] 44 | -------------------------------------------------------------------------------- /server/archive/utils.go: -------------------------------------------------------------------------------- 1 | package archive 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | 11 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 12 | ) 13 | 14 | // Perform a search on the archive.txt file an determines if a download 15 | // has already be done. 16 | func DownloadExists(ctx context.Context, url string) (bool, error) { 17 | cmd := exec.CommandContext( 18 | ctx, 19 | config.Instance().DownloaderPath, 20 | "--print", 21 | "%(extractor)s %(id)s", 22 | url, 23 | ) 24 | stdout, err := cmd.Output() 25 | if err != nil { 26 | return false, err 27 | } 28 | 29 | extractorAndURL := bytes.Trim(stdout, "\n") 30 | 31 | fd, err := os.Open(filepath.Join(config.Instance().Dir(), "archive.txt")) 32 | if err != nil { 33 | return false, err 34 | } 35 | defer fd.Close() 36 | 37 | scanner := bufio.NewScanner(fd) 38 | 39 | // search linearly for lower memory usage... 40 | // the a pre-sorted with hashed values version of the archive.txt file can be loaded in memory 41 | // and perform a binary search on it. 42 | for scanner.Scan() { 43 | if bytes.Equal(scanner.Bytes(), extractorAndURL) { 44 | return true, nil 45 | } 46 | } 47 | 48 | // data, err := io.ReadAll(fd) 49 | // if err != nil { 50 | // return false, err 51 | // } 52 | 53 | // slices.BinarySearchFunc(data, extractorAndURL, func(a []byte, b []byte) int { 54 | // return hash(a).Compare(hash(b)) 55 | // }) 56 | 57 | return false, nil 58 | } 59 | -------------------------------------------------------------------------------- /proto/yt-dlp.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | message Empty {} 4 | 5 | message BaseRequest { 6 | string id = 1; 7 | string url = 2; 8 | } 9 | 10 | message DownloadRequest { 11 | string id = 1; 12 | string url = 2; 13 | string path = 3; 14 | string rename = 4; 15 | repeated string params = 5; 16 | } 17 | 18 | message ExecResponse { 19 | string id = 1; 20 | } 21 | 22 | message DownloadProgress { 23 | int32 status = 1; 24 | string percentage = 2; 25 | float speed = 3; 26 | float eta = 4; 27 | } 28 | 29 | message DownloadInfo { 30 | string url = 1; 31 | string title = 2; 32 | string thumbnail = 3; 33 | string resolution = 4; 34 | int32 size = 5; 35 | string vcodec = 6; 36 | string acodec = 7; 37 | string extension = 8; 38 | string originalURL = 9; 39 | string createdAt = 10; 40 | } 41 | 42 | message DownloadOutput { 43 | string path = 1; 44 | string filename = 2; 45 | string savedFilePath = 3; 46 | } 47 | 48 | message ProcessResponse { 49 | string id = 1; 50 | DownloadProgress progress = 2; 51 | DownloadInfo info = 3; 52 | DownloadOutput output = 4; 53 | repeated string params = 5; 54 | } 55 | 56 | service Ytdlp { 57 | rpc Exec (DownloadRequest) returns (ExecResponse); 58 | rpc ExecPlaylist (DownloadRequest) returns (ExecResponse); 59 | 60 | rpc Progress (BaseRequest) returns (DownloadProgress); 61 | rpc Running (Empty) returns (stream ProcessResponse); 62 | 63 | rpc Kill (BaseRequest) returns (ExecResponse); 64 | rpc KillAll (Empty) returns (stream ExecResponse); 65 | } -------------------------------------------------------------------------------- /frontend/src/lib/i18n.ts: -------------------------------------------------------------------------------- 1 | //@ts-ignore 2 | import i18n from '../assets/i18n.yaml' 3 | //@ts-ignore 4 | import fallback from '../assets/i18n/en_US.yaml' 5 | 6 | export default class Translator { 7 | static #instance: Translator 8 | 9 | private language: string 10 | private current: string[] = [] 11 | 12 | constructor() { 13 | this.language = localStorage.getItem('language')?.replaceAll('"', '') ?? 'english' 14 | this.setLanguage(this.language) 15 | } 16 | 17 | getLanguage(): string { 18 | return this.language 19 | } 20 | 21 | async setLanguage(language: string): Promise { 22 | this.language = language 23 | 24 | let isoCodeFile: string = i18n.languages[language] 25 | 26 | // extension needs to be in source code to help vite bundle all yaml files 27 | if (isoCodeFile.endsWith('.yaml')) { 28 | isoCodeFile = isoCodeFile.replaceAll('.yaml', '') 29 | } 30 | 31 | if (isoCodeFile) { 32 | const { default: translations } = await import(`../assets/i18n/${isoCodeFile}.yaml`) 33 | 34 | this.current = translations.keys 35 | } 36 | } 37 | 38 | t(key: string): string { 39 | if (this.current) { 40 | //@ts-ignore 41 | return this.current[key] ?? fallback.keys[key] ?? 'caption not defined' 42 | } 43 | return 'caption not defined' 44 | } 45 | 46 | public static get instance(): Translator { 47 | if (!Translator.#instance) { 48 | Translator.#instance = new Translator() 49 | } 50 | 51 | return Translator.#instance 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /server/middleware/jwt.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "time" 9 | 10 | "github.com/golang-jwt/jwt/v5" 11 | ) 12 | 13 | func validateToken(tokenValue string) error { 14 | token, err := jwt.Parse(tokenValue, func(t *jwt.Token) (interface{}, error) { 15 | if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { 16 | return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) 17 | } 18 | return []byte(os.Getenv("JWT_SECRET")), nil 19 | }) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { 25 | expiresAt, err := time.Parse(time.RFC3339, claims["expiresAt"].(string)) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | if time.Now().After(expiresAt) { 31 | return errors.New("token expired") 32 | } 33 | } else { 34 | return errors.New("invalid token") 35 | } 36 | 37 | return nil 38 | } 39 | 40 | // Authentication does NOT use http-Only cookies since there's not risk for XSS 41 | // By exposing the server through https it's completely safe to use httpheaders 42 | 43 | func Authenticated(next http.Handler) http.Handler { 44 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 45 | token := r.Header.Get("X-Authentication") 46 | if token == "" { 47 | token = r.URL.Query().Get("token") 48 | } 49 | 50 | if err := validateToken(token); err != nil { 51 | http.Error(w, err.Error(), http.StatusBadRequest) 52 | return 53 | } 54 | 55 | next.ServeHTTP(w, r) 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /server/subscription/domain/subscription.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi/v5" 8 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/data" 9 | ) 10 | 11 | type Subscription struct { 12 | Id string `json:"id"` 13 | URL string `json:"url"` 14 | Params string `json:"params"` 15 | CronExpr string `json:"cron_expression"` 16 | } 17 | 18 | type PaginatedResponse[T any] struct { 19 | First int64 `json:"first"` 20 | Next int64 `json:"next"` 21 | Data T `json:"data"` 22 | } 23 | 24 | type Repository interface { 25 | Submit(ctx context.Context, sub *data.Subscription) (*data.Subscription, error) 26 | List(ctx context.Context, start int64, limit int) (*[]data.Subscription, error) 27 | UpdateByExample(ctx context.Context, example *data.Subscription) error 28 | Delete(ctx context.Context, id string) error 29 | GetCursor(ctx context.Context, id string) (int64, error) 30 | } 31 | 32 | type Service interface { 33 | Submit(ctx context.Context, sub *Subscription) (*Subscription, error) 34 | List(ctx context.Context, start int64, limit int) (*PaginatedResponse[[]Subscription], error) 35 | UpdateByExample(ctx context.Context, example *Subscription) error 36 | Delete(ctx context.Context, id string) error 37 | GetCursor(ctx context.Context, id string) (int64, error) 38 | } 39 | 40 | type RestHandler interface { 41 | Submit() http.HandlerFunc 42 | List() http.HandlerFunc 43 | UpdateByExample() http.HandlerFunc 44 | Delete() http.HandlerFunc 45 | GetCursor() http.HandlerFunc 46 | ApplyRouter() func(chi.Router) 47 | } 48 | -------------------------------------------------------------------------------- /server/twitch/rest.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "slices" 7 | 8 | "github.com/go-chi/chi/v5" 9 | ) 10 | 11 | type addUserReq struct { 12 | User string `json:"user"` 13 | } 14 | 15 | func MonitorUserHandler(m *Monitor) http.HandlerFunc { 16 | return func(w http.ResponseWriter, r *http.Request) { 17 | var req addUserReq 18 | 19 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 20 | http.Error(w, err.Error(), http.StatusInternalServerError) 21 | return 22 | } 23 | 24 | m.Add(req.User) 25 | 26 | if err := json.NewEncoder(w).Encode("ok"); err != nil { 27 | http.Error(w, err.Error(), http.StatusInternalServerError) 28 | return 29 | } 30 | } 31 | } 32 | 33 | func GetMonitoredUsers(m *Monitor) http.HandlerFunc { 34 | return func(w http.ResponseWriter, r *http.Request) { 35 | it := m.GetMonitoredUsers() 36 | 37 | users := slices.Collect(it) 38 | if users == nil { 39 | users = make([]string, 0) 40 | } 41 | 42 | if err := json.NewEncoder(w).Encode(users); err != nil { 43 | http.Error(w, err.Error(), http.StatusInternalServerError) 44 | return 45 | } 46 | } 47 | } 48 | 49 | func DeleteUser(m *Monitor) http.HandlerFunc { 50 | return func(w http.ResponseWriter, r *http.Request) { 51 | user := chi.URLParam(r, "user") 52 | 53 | if user == "" { 54 | http.Error(w, "empty user", http.StatusBadRequest) 55 | return 56 | } 57 | 58 | m.DeleteUser(user) 59 | 60 | if err := json.NewEncoder(w).Encode("ok"); err != nil { 61 | http.Error(w, err.Error(), http.StatusInternalServerError) 62 | return 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A terrible web ui for yt-dlp. Designed to be self-hosted."; 3 | 4 | inputs = { 5 | flake-parts.url = "github:hercules-ci/flake-parts"; 6 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 7 | pre-commit-hooks-nix.url = "github:cachix/pre-commit-hooks.nix"; 8 | }; 9 | 10 | outputs = inputs@{ self, flake-parts, ... }: 11 | flake-parts.lib.mkFlake { inherit inputs; } { 12 | imports = [ 13 | inputs.pre-commit-hooks-nix.flakeModule 14 | ]; 15 | systems = [ 16 | "x86_64-linux" 17 | ]; 18 | perSystem = { config, self', pkgs, ... }: { 19 | 20 | packages = { 21 | yt-dlp-web-ui-frontend = pkgs.callPackage ./nix/frontend.nix { }; 22 | default = pkgs.callPackage ./nix/server.nix { 23 | inherit (self'.packages) yt-dlp-web-ui-frontend; 24 | }; 25 | }; 26 | 27 | checks = import ./nix/tests { inherit self pkgs; }; 28 | 29 | pre-commit = { 30 | check.enable = true; 31 | settings = { 32 | hooks = { 33 | ${self'.formatter.pname}.enable = true; 34 | deadnix.enable = true; 35 | nil.enable = true; 36 | statix.enable = true; 37 | }; 38 | }; 39 | }; 40 | 41 | devShells.default = pkgs.callPackage ./nix/devShell.nix { 42 | inputsFrom = [ config.pre-commit.devShell ]; 43 | }; 44 | 45 | formatter = pkgs.nixpkgs-fmt; 46 | }; 47 | flake = { 48 | nixosModules.default = import ./nix/module.nix self.packages; 49 | }; 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /server/status/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/rest" 8 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/status/domain" 9 | ) 10 | 11 | type Service struct { 12 | repository domain.Repository 13 | utilityService *rest.Service 14 | } 15 | 16 | // Version implements domain.Status. 17 | func (s *Service) Status(ctx context.Context) (*domain.Status, error) { 18 | // rpcVersion, downloaderVersion, err := s.utilityService.GetVersion(ctx) 19 | // if err != nil { 20 | // return nil, err 21 | // } 22 | 23 | var ( 24 | wg sync.WaitGroup 25 | pending int 26 | downloading int 27 | completed int 28 | speed int64 29 | // version = fmt.Sprintf("RPC: %s yt-dlp: %s", rpcVersion, downloaderVersion) 30 | ) 31 | 32 | wg.Add(4) 33 | 34 | go func() { 35 | pending = s.repository.Pending(ctx) 36 | wg.Done() 37 | }() 38 | 39 | go func() { 40 | downloading = s.repository.Downloading(ctx) 41 | wg.Done() 42 | }() 43 | 44 | go func() { 45 | completed = s.repository.Completed(ctx) 46 | wg.Done() 47 | }() 48 | 49 | go func() { 50 | speed = s.repository.DownloadSpeed(ctx) 51 | wg.Done() 52 | }() 53 | 54 | wg.Wait() 55 | 56 | return &domain.Status{ 57 | Downloading: downloading, 58 | Pending: pending, 59 | Completed: completed, 60 | DownloadSpeed: int(speed), 61 | }, nil 62 | } 63 | 64 | func New(repository domain.Repository, utilityService *rest.Service) domain.Service { 65 | return &Service{ 66 | repository: repository, 67 | utilityService: utilityService, 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /server/sys/fs.go: -------------------------------------------------------------------------------- 1 | package sys 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 8 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal" 9 | "golang.org/x/sys/unix" 10 | ) 11 | 12 | // package containing fs related operation (unix only) 13 | 14 | // FreeSpace gets the available Bytes writable to download directory 15 | func FreeSpace() (uint64, error) { 16 | var stat unix.Statfs_t 17 | unix.Statfs(config.Instance().DownloadPath, &stat) 18 | return (stat.Bavail * uint64(stat.Bsize)), nil 19 | } 20 | 21 | // Build a directory tree started from the specified path using DFS. 22 | // Then return the flattened tree represented as a list. 23 | func DirectoryTree() (*[]string, error) { 24 | type Node struct { 25 | path string 26 | children []Node 27 | } 28 | 29 | var ( 30 | rootPath = config.Instance().DownloadPath 31 | 32 | stack = internal.NewStack[Node]() 33 | flattened = make([]string, 0) 34 | ) 35 | 36 | stack.Push(Node{path: rootPath}) 37 | 38 | flattened = append(flattened, rootPath) 39 | 40 | for stack.IsNotEmpty() { 41 | current := stack.Pop().Value 42 | 43 | children, err := os.ReadDir(current.path) 44 | if err != nil { 45 | return nil, err 46 | } 47 | for _, entry := range children { 48 | var ( 49 | childPath = filepath.Join(current.path, entry.Name()) 50 | childNode = Node{path: childPath} 51 | ) 52 | if entry.IsDir() { 53 | current.children = append(current.children, childNode) 54 | stack.Push(childNode) 55 | flattened = append(flattened, childNode.path) 56 | } 57 | } 58 | } 59 | return &flattened, nil 60 | } 61 | -------------------------------------------------------------------------------- /frontend/src/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import ErrorIcon from '@mui/icons-material/Error' 2 | import { Button, Container, SvgIcon, Typography, styled } from '@mui/material' 3 | import { Link } from 'react-router-dom' 4 | 5 | const FlexContainer = styled(Container)({ 6 | display: 'flex', 7 | minWidth: '100%', 8 | minHeight: '80vh', 9 | alignItems: 'center', 10 | justifyContent: 'center', 11 | flexDirection: 'column' 12 | }) 13 | 14 | const Title = styled(Typography)({ 15 | display: 'flex', 16 | width: '100%', 17 | alignItems: 'center', 18 | justifyContent: 'center', 19 | paddingBottom: '0.5rem' 20 | }) 21 | 22 | const ErrorBoundary: React.FC = () => { 23 | return ( 24 | 25 | 26 | <SvgIcon sx={{ fontSize: '200px' }}> 27 | <ErrorIcon /> 28 | </SvgIcon> 29 | 30 | 31 | An error occurred :\ 32 | 33 | 34 | Check your settings! 35 | 36 | 37 | 40 | 41 | 42 | Or login if authentification is enabled 43 | 44 | 45 | 48 | 49 | 50 | ) 51 | } 52 | 53 | export default ErrorBoundary 54 | -------------------------------------------------------------------------------- /frontend/src/components/DownloadsGridView.tsx: -------------------------------------------------------------------------------- 1 | import { Grid2 } from '@mui/material' 2 | import { useAtomValue } from 'jotai' 3 | import { useTransition } from 'react' 4 | import { activeDownloadsState } from '../atoms/downloads' 5 | import { useToast } from '../hooks/toast' 6 | import { useI18n } from '../hooks/useI18n' 7 | import { useRPC } from '../hooks/useRPC' 8 | import { ProcessStatus, RPCResult } from '../types' 9 | import DownloadCard from './DownloadCard' 10 | import LoadingBackdrop from './LoadingBackdrop' 11 | 12 | const DownloadsGridView: React.FC = () => { 13 | const downloads = useAtomValue(activeDownloadsState) 14 | 15 | const { i18n } = useI18n() 16 | const { client } = useRPC() 17 | const { pushMessage } = useToast() 18 | 19 | const [isPending, startTransition] = useTransition() 20 | 21 | const stop = async (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED 22 | ? await client.clear(r.id) 23 | : await client.kill(r.id) 24 | 25 | return ( 26 | <> 27 | 28 | 29 | { 30 | downloads.map(download => ( 31 | 32 | startTransition(async () => { 35 | await stop(download) 36 | })} 37 | onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')} 38 | /> 39 | 40 | )) 41 | } 42 | 43 | 44 | ) 45 | } 46 | 47 | export default DownloadsGridView -------------------------------------------------------------------------------- /server/archive/domain/archive.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/go-chi/chi/v5" 9 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/data" 10 | ) 11 | 12 | type ArchiveEntry struct { 13 | Id string `json:"id"` 14 | Title string `json:"title"` 15 | Path string `json:"path"` 16 | Thumbnail string `json:"thumbnail"` 17 | Source string `json:"source"` 18 | Metadata string `json:"metadata"` 19 | CreatedAt time.Time `json:"created_at"` 20 | } 21 | 22 | type PaginatedResponse[T any] struct { 23 | First int64 `json:"first"` 24 | Next int64 `json:"next"` 25 | Data T `json:"data"` 26 | } 27 | 28 | type Repository interface { 29 | Archive(ctx context.Context, model *data.ArchiveEntry) error 30 | SoftDelete(ctx context.Context, id string) (*data.ArchiveEntry, error) 31 | HardDelete(ctx context.Context, id string) (*data.ArchiveEntry, error) 32 | List(ctx context.Context, startRowId int, limit int) (*[]data.ArchiveEntry, error) 33 | GetCursor(ctx context.Context, id string) (int64, error) 34 | } 35 | 36 | type Service interface { 37 | Archive(ctx context.Context, entity *ArchiveEntry) error 38 | SoftDelete(ctx context.Context, id string) (*ArchiveEntry, error) 39 | HardDelete(ctx context.Context, id string) (*ArchiveEntry, error) 40 | List(ctx context.Context, startRowId int, limit int) (*PaginatedResponse[[]ArchiveEntry], error) 41 | GetCursor(ctx context.Context, id string) (int64, error) 42 | } 43 | 44 | type RestHandler interface { 45 | List() http.HandlerFunc 46 | Archive() http.HandlerFunc 47 | SoftDelete() http.HandlerFunc 48 | HardDelete() http.HandlerFunc 49 | GetCursor() http.HandlerFunc 50 | ApplyRouter() func(chi.Router) 51 | } 52 | -------------------------------------------------------------------------------- /server/user/handlers.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "os" 7 | "time" 8 | 9 | "github.com/golang-jwt/jwt/v5" 10 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 11 | ) 12 | 13 | const TOKEN_COOKIE_NAME = "jwt-yt-dlp-webui" 14 | 15 | type LoginRequest struct { 16 | Username string `json:"username"` 17 | Password string `json:"password"` 18 | } 19 | 20 | func Login(w http.ResponseWriter, r *http.Request) { 21 | var req LoginRequest 22 | 23 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 24 | http.Error(w, err.Error(), http.StatusInternalServerError) 25 | return 26 | } 27 | 28 | var ( 29 | username = config.Instance().Username 30 | password = config.Instance().Password 31 | ) 32 | 33 | if username != req.Username || password != req.Password { 34 | http.Error(w, "invalid username or password", http.StatusBadRequest) 35 | return 36 | } 37 | 38 | expiresAt := time.Now().Add(time.Hour * 24 * 30) 39 | 40 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 41 | "expiresAt": expiresAt, 42 | "username": req.Username, 43 | }) 44 | 45 | tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET"))) 46 | if err != nil { 47 | http.Error(w, err.Error(), http.StatusInternalServerError) 48 | return 49 | } 50 | 51 | if err := json.NewEncoder(w).Encode(tokenString); err != nil { 52 | http.Error(w, err.Error(), http.StatusInternalServerError) 53 | return 54 | } 55 | } 56 | 57 | func Logout(w http.ResponseWriter, r *http.Request) { 58 | cookie := &http.Cookie{ 59 | Name: TOKEN_COOKIE_NAME, 60 | HttpOnly: true, 61 | Secure: false, 62 | Expires: time.Now(), 63 | Value: "", 64 | Path: "/", 65 | } 66 | 67 | http.SetCookie(w, cookie) 68 | } 69 | -------------------------------------------------------------------------------- /server/status/repository/repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "slices" 6 | 7 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal" 8 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/status/domain" 9 | ) 10 | 11 | type Repository struct { 12 | mdb *internal.MemoryDB 13 | } 14 | 15 | // DownloadSpeed implements domain.Repository. 16 | func (r *Repository) DownloadSpeed(ctx context.Context) int64 { 17 | processes := r.mdb.All() 18 | 19 | var downloadSpeed float64 20 | 21 | for _, p := range *processes { 22 | downloadSpeed += p.Progress.Speed 23 | } 24 | 25 | return int64(downloadSpeed) 26 | } 27 | 28 | // Completed implements domain.Repository. 29 | func (r *Repository) Completed(ctx context.Context) int { 30 | processes := r.mdb.All() 31 | 32 | completed := slices.DeleteFunc(*processes, func(p internal.ProcessResponse) bool { 33 | return p.Progress.Status != internal.StatusCompleted 34 | }) 35 | 36 | return len(completed) 37 | } 38 | 39 | // Downloading implements domain.Repository. 40 | func (r *Repository) Downloading(ctx context.Context) int { 41 | processes := r.mdb.All() 42 | 43 | downloading := slices.DeleteFunc(*processes, func(p internal.ProcessResponse) bool { 44 | return p.Progress.Status != internal.StatusDownloading 45 | }) 46 | 47 | return len(downloading) 48 | } 49 | 50 | // Pending implements domain.Repository. 51 | func (r *Repository) Pending(ctx context.Context) int { 52 | processes := r.mdb.All() 53 | 54 | pending := slices.DeleteFunc(*processes, func(p internal.ProcessResponse) bool { 55 | return p.Progress.Status != internal.StatusPending 56 | }) 57 | 58 | return len(pending) 59 | } 60 | 61 | func New(mdb *internal.MemoryDB) domain.Repository { 62 | return &Repository{ 63 | mdb: mdb, 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /server/logging/file_logger.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "compress/gzip" 5 | "io" 6 | "log/slog" 7 | "os" 8 | "strings" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | /* 14 | implements io.Writer interface 15 | 16 | File base logger with log-rotate capabilities. 17 | The rotate process must be initiated from an external goroutine. 18 | 19 | After rotation the previous logs file are compressed with gzip algorithm. 20 | 21 | The rotated log follows this naming: [filename].UTC time.gz 22 | */ 23 | type LogRotateWriter struct { 24 | mu sync.Mutex 25 | fd *os.File 26 | filename string 27 | } 28 | 29 | func NewRotableLogger(filename string) (*LogRotateWriter, error) { 30 | fd, err := os.Create(filename) 31 | if err != nil { 32 | return nil, err 33 | } 34 | w := &LogRotateWriter{filename: filename, fd: fd} 35 | return w, nil 36 | } 37 | 38 | func (w *LogRotateWriter) Write(b []byte) (int, error) { 39 | w.mu.Lock() 40 | defer w.mu.Unlock() 41 | return w.fd.Write(b) 42 | } 43 | 44 | func (w *LogRotateWriter) Rotate() error { 45 | slog.Info("started log rotation") 46 | 47 | w.mu.Lock() 48 | 49 | gzFile, err := os.Create(strings.TrimSuffix(w.filename, ".log") + "-" + time.Now().Format(time.RFC3339) + ".log.gz") 50 | if err != nil { 51 | return err 52 | } 53 | 54 | zw := gzip.NewWriter(gzFile) 55 | 56 | defer func() { 57 | zw.Close() 58 | zw.Flush() 59 | gzFile.Close() 60 | }() 61 | 62 | if _, err := os.Stat(w.filename); err != nil { 63 | return err 64 | } 65 | 66 | fd, _ := os.Open(w.filename) 67 | io.Copy(zw, fd) 68 | fd.Close() 69 | 70 | w.fd.Close() 71 | 72 | if err := os.Remove(w.filename); err != nil { 73 | return err 74 | } 75 | 76 | w.fd, _ = os.Create(w.filename) 77 | 78 | w.mu.Unlock() 79 | slog.Info("ended log rotation") 80 | 81 | return err 82 | } 83 | -------------------------------------------------------------------------------- /server/dbutil/migrate.go: -------------------------------------------------------------------------------- 1 | package dbutil 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 10 | ) 11 | 12 | var lockFilePath = filepath.Join(config.Instance().Dir(), ".db.lock") 13 | 14 | // Run the table migration 15 | func Migrate(ctx context.Context, db *sql.DB) error { 16 | conn, err := db.Conn(ctx) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | defer func() { 22 | conn.Close() 23 | createLockFile() 24 | }() 25 | 26 | if _, err := db.ExecContext( 27 | ctx, 28 | `CREATE TABLE IF NOT EXISTS templates ( 29 | id CHAR(36) PRIMARY KEY, 30 | name VARCHAR(255) NOT NULL, 31 | content TEXT NOT NULL 32 | )`, 33 | ); err != nil { 34 | return err 35 | } 36 | 37 | if _, err := db.ExecContext( 38 | ctx, 39 | `CREATE TABLE IF NOT EXISTS archive ( 40 | id CHAR(36) PRIMARY KEY, 41 | title VARCHAR(255) NOT NULL, 42 | path VARCHAR(255) NOT NULL, 43 | thumbnail TEXT, 44 | source VARCHAR(255), 45 | metadata TEXT, 46 | created_at DATETIME 47 | )`, 48 | ); err != nil { 49 | return err 50 | } 51 | 52 | if _, err := db.ExecContext( 53 | ctx, 54 | `CREATE TABLE IF NOT EXISTS subscriptions ( 55 | id CHAR(36) PRIMARY KEY, 56 | url VARCHAR(2048) UNIQUE NOT NULL, 57 | params TEXT NOT NULL, 58 | cron TEXT 59 | )`, 60 | ); err != nil { 61 | return err 62 | } 63 | 64 | if lockFileExists() { 65 | return nil 66 | } 67 | 68 | db.ExecContext( 69 | ctx, 70 | `INSERT INTO templates (id, name, content) VALUES 71 | ($1, $2, $3), 72 | ($4, $5, $6);`, 73 | "0", "default", "--no-mtime", 74 | "1", "audio only", "-x", 75 | ) 76 | 77 | return nil 78 | } 79 | 80 | func createLockFile() { os.Create(lockFilePath) } 81 | 82 | func lockFileExists() bool { 83 | _, err := os.Stat(lockFilePath) 84 | return os.IsExist(err) 85 | } 86 | -------------------------------------------------------------------------------- /server/twitch/auth.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "time" 9 | ) 10 | 11 | const authURL = "https://id.twitch.tv/oauth2/token" 12 | 13 | type AuthResponse struct { 14 | AccessToken string `json:"access_token"` 15 | ExpiresIn int `json:"expires_in"` 16 | TokenType string `json:"token_type"` 17 | } 18 | 19 | type AccessToken struct { 20 | Token string 21 | Expiry time.Time 22 | } 23 | 24 | type AuthenticationManager struct { 25 | clientId string 26 | clientSecret string 27 | accesToken *AccessToken 28 | } 29 | 30 | func NewAuthenticationManager(clientId, clientSecret string) *AuthenticationManager { 31 | return &AuthenticationManager{ 32 | clientId: clientId, 33 | clientSecret: clientSecret, 34 | accesToken: &AccessToken{}, 35 | } 36 | } 37 | 38 | func (a *AuthenticationManager) GetAccessToken() (*AccessToken, error) { 39 | if a.accesToken != nil && a.accesToken.Token != "" && a.accesToken.Expiry.After(time.Now()) { 40 | return a.accesToken, nil 41 | } 42 | 43 | data := url.Values{} 44 | data.Set("client_id", a.clientId) 45 | data.Set("client_secret", a.clientSecret) 46 | data.Set("grant_type", "client_credentials") 47 | 48 | resp, err := http.PostForm(authURL, data) 49 | if err != nil { 50 | return nil, fmt.Errorf("errore richiesta token: %w", err) 51 | } 52 | defer resp.Body.Close() 53 | 54 | if resp.StatusCode != http.StatusOK { 55 | return nil, fmt.Errorf("status non OK: %s", resp.Status) 56 | } 57 | 58 | var auth AuthResponse 59 | if err := json.NewDecoder(resp.Body).Decode(&auth); err != nil { 60 | return nil, fmt.Errorf("errore decoding JSON: %w", err) 61 | } 62 | 63 | token := &AccessToken{ 64 | Token: auth.AccessToken, 65 | Expiry: time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second), 66 | } 67 | 68 | a.accesToken = token 69 | 70 | return token, nil 71 | } 72 | 73 | func (a *AuthenticationManager) GetClientId() string { 74 | return a.clientId 75 | } 76 | -------------------------------------------------------------------------------- /frontend/src/components/TemplateTextField.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from 'react' 2 | 3 | import DeleteIcon from '@mui/icons-material/Delete' 4 | import EditIcon from '@mui/icons-material/Edit' 5 | import { 6 | Button, 7 | Grid, 8 | TextField 9 | } from '@mui/material' 10 | import { useI18n } from '../hooks/useI18n' 11 | import { CustomTemplate } from '../types' 12 | 13 | interface Props { 14 | template: CustomTemplate 15 | onChange: (template: CustomTemplate) => void 16 | onDelete: (id: string) => void 17 | } 18 | 19 | const TemplateTextField: FC = ({ template, onChange, onDelete }) => { 20 | const { i18n } = useI18n() 21 | 22 | const [editedTemplate, setEditedTemplate] = useState(template) 23 | 24 | return ( 25 | 33 | 34 | setEditedTemplate({ ...editedTemplate, name: e.target.value })} 39 | /> 40 | 41 | 42 | setEditedTemplate({ ...editedTemplate, content: e.target.value })} 47 | InputProps={{ 48 | endAdornment:
49 | 54 | 59 |
60 | }} 61 | /> 62 |
63 |
64 | ) 65 | } 66 | 67 | export default TemplateTextField -------------------------------------------------------------------------------- /server/playlist/modifiers.go: -------------------------------------------------------------------------------- 1 | package playlist 2 | 3 | import ( 4 | "slices" 5 | "strconv" 6 | 7 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common" 8 | ) 9 | 10 | /* 11 | Applicable modifiers 12 | 13 | full | short | description 14 | --------------------------------------------------------------------------------- 15 | --playlist-start NUMBER | -I NUMBER: | discard first N entries 16 | --playlist-end NUMBER | -I :NUMBER | discard last N entries 17 | --playlist-reverse | -I ::-1 | self explanatory 18 | --max-downloads NUMBER | | stops after N completed downloads 19 | */ 20 | 21 | func ApplyModifiers(entries *[]common.DownloadInfo, args []string) error { 22 | for i, modifier := range args { 23 | switch modifier { 24 | case "--playlist-start": 25 | return playlistStart(i, modifier, args, entries) 26 | 27 | case "--playlist-end": 28 | return playlistEnd(i, modifier, args, entries) 29 | 30 | case "--max-downloads": 31 | return maxDownloads(i, modifier, args, entries) 32 | 33 | case "--playlist-reverse": 34 | slices.Reverse(*entries) 35 | return nil 36 | } 37 | } 38 | return nil 39 | } 40 | 41 | func playlistStart(i int, modifier string, args []string, entries *[]common.DownloadInfo) error { 42 | if !guard(i, len(modifier)) { 43 | return nil 44 | } 45 | 46 | n, err := strconv.Atoi(args[i+1]) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | *entries = (*entries)[n:] 52 | 53 | return nil 54 | } 55 | 56 | func playlistEnd(i int, modifier string, args []string, entries *[]common.DownloadInfo) error { 57 | if !guard(i, len(modifier)) { 58 | return nil 59 | } 60 | 61 | n, err := strconv.Atoi(args[i+1]) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | *entries = (*entries)[:n] 67 | 68 | return nil 69 | } 70 | 71 | func maxDownloads(i int, modifier string, args []string, entries *[]common.DownloadInfo) error { 72 | if !guard(i, len(modifier)) { 73 | return nil 74 | } 75 | 76 | n, err := strconv.Atoi(args[i+1]) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | *entries = (*entries)[0:n] 82 | 83 | return nil 84 | } 85 | 86 | func guard(i, len int) bool { return i+1 < len-1 } 87 | -------------------------------------------------------------------------------- /server/twitch/client.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | const twitchAPIURL = "https://api.twitch.tv/helix" 11 | 12 | type Client struct { 13 | authenticationManager AuthenticationManager 14 | } 15 | 16 | func NewTwitchClient(am *AuthenticationManager) *Client { 17 | return &Client{ 18 | authenticationManager: *am, 19 | } 20 | } 21 | 22 | type streamResp struct { 23 | Data []struct { 24 | ID string `json:"id"` 25 | UserName string `json:"user_name"` 26 | Title string `json:"title"` 27 | GameName string `json:"game_name"` 28 | StartedAt string `json:"started_at"` 29 | } `json:"data"` 30 | } 31 | 32 | func (c *Client) doRequest(endpoint string, params map[string]string) ([]byte, error) { 33 | token, err := c.authenticationManager.GetAccessToken() 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | reqURL := twitchAPIURL + endpoint 39 | req, err := http.NewRequest("GET", reqURL, nil) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | q := req.URL.Query() 45 | for k, v := range params { 46 | q.Set(k, v) 47 | } 48 | req.URL.RawQuery = q.Encode() 49 | 50 | req.Header.Set("Client-Id", c.authenticationManager.GetClientId()) 51 | req.Header.Set("Authorization", "Bearer "+token.Token) 52 | 53 | resp, err := http.DefaultClient.Do(req) 54 | if err != nil { 55 | return nil, err 56 | } 57 | defer resp.Body.Close() 58 | 59 | return io.ReadAll(resp.Body) 60 | } 61 | 62 | func (c *Client) PollStream(channel string, liveChannel chan<- *StreamInfo) error { 63 | body, err := c.doRequest("/streams", map[string]string{"user_login": channel}) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | var sr streamResp 69 | if err := json.Unmarshal(body, &sr); err != nil { 70 | return err 71 | } 72 | 73 | if len(sr.Data) == 0 { 74 | liveChannel <- &StreamInfo{UserName: channel, IsLive: false} 75 | return nil 76 | } 77 | 78 | s := sr.Data[0] 79 | started, _ := time.Parse(time.RFC3339, s.StartedAt) 80 | 81 | liveChannel <- &StreamInfo{ 82 | ID: s.ID, 83 | UserName: s.UserName, 84 | Title: s.Title, 85 | GameName: s.GameName, 86 | StartedAt: started, 87 | IsLive: true, 88 | } 89 | 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /server/internal/common_types.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common" 5 | ) 6 | 7 | // Used to unmarshall yt-dlp progress 8 | type ProgressTemplate struct { 9 | Percentage string `json:"percentage"` 10 | Speed float64 `json:"speed"` 11 | Size string `json:"size"` 12 | Eta float64 `json:"eta"` 13 | } 14 | 15 | type PostprocessTemplate struct { 16 | FilePath string `json:"filepath"` 17 | } 18 | 19 | // Defines where and how the download needs to be saved 20 | type DownloadOutput struct { 21 | Path string 22 | Filename string 23 | SavedFilePath string `json:"savedFilePath"` 24 | } 25 | 26 | // Progress for the Running call 27 | type DownloadProgress struct { 28 | Status int `json:"process_status"` 29 | Percentage string `json:"percentage"` 30 | Speed float64 `json:"speed"` 31 | ETA float64 `json:"eta"` 32 | } 33 | 34 | // struct representing the response sent to the client 35 | // as JSON-RPC result field 36 | type ProcessResponse struct { 37 | Id string `json:"id"` 38 | Progress DownloadProgress `json:"progress"` 39 | Info common.DownloadInfo `json:"info"` 40 | Output DownloadOutput `json:"output"` 41 | Params []string `json:"params"` 42 | } 43 | 44 | // struct representing the current status of the memoryDB 45 | // used for serializaton/persistence reasons 46 | type Session struct { 47 | Processes []ProcessResponse `json:"processes"` 48 | } 49 | 50 | // struct representing the intent to stop a specific process 51 | type AbortRequest struct { 52 | Id string `json:"id"` 53 | } 54 | 55 | // struct representing the intent to start a download 56 | type DownloadRequest struct { 57 | Id string 58 | URL string `json:"url"` 59 | Path string `json:"path"` 60 | Rename string `json:"rename"` 61 | Params []string `json:"params"` 62 | } 63 | 64 | // struct representing request of creating a netscape cookies file 65 | type SetCookiesRequest struct { 66 | Cookies string `json:"cookies"` 67 | } 68 | 69 | // represents a user defined collection of yt-dlp arguments 70 | type CustomTemplate struct { 71 | Id string `json:"id"` 72 | Name string `json:"name"` 73 | Content string `json:"content"` 74 | } 75 | -------------------------------------------------------------------------------- /frontend/src/components/ExtraDownloadOptions.tsx: -------------------------------------------------------------------------------- 1 | import { Autocomplete, Box, TextField, Typography } from '@mui/material' 2 | import { useAtomValue, useSetAtom } from 'jotai' 3 | import { useEffect } from 'react' 4 | import { customArgsState, savedTemplatesState } from '../atoms/downloadTemplate' 5 | import { useI18n } from '../hooks/useI18n' 6 | 7 | const ExtraDownloadOptions: React.FC = () => { 8 | const { i18n } = useI18n() 9 | 10 | const customTemplates = useAtomValue(savedTemplatesState) 11 | const setCustomArgs = useSetAtom(customArgsState) 12 | 13 | useEffect(() => { 14 | setCustomArgs( 15 | customTemplates 16 | .find(f => f.name.toLocaleLowerCase() === 'default') 17 | ?.content ?? '' 18 | ) 19 | }, []) 20 | 21 | return ( 22 | <> 23 | ({ label: name, content }))} 26 | autoHighlight 27 | defaultValue={ 28 | customTemplates 29 | .filter(({ id, name }) => id === "0" || name.toLowerCase() === "default") 30 | .map(({ name, content }) => ({ label: name, content })) 31 | .at(0) 32 | } 33 | getOptionLabel={(option) => option.label} 34 | onChange={(_, value) => { 35 | setCustomArgs(value?.content!) 36 | }} 37 | renderOption={(props, option) => ( 38 | 42 | 50 | 51 | {option.label} 52 | 53 | 54 | {option.content} 55 | 56 | 57 | 58 | )} 59 | sx={{ width: '100%', mt: 2 }} 60 | renderInput={(params) => } 61 | /> 62 | 63 | ) 64 | } 65 | 66 | export default ExtraDownloadOptions -------------------------------------------------------------------------------- /frontend/src/components/LogTerminal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef, useState } from 'react' 2 | import { serverURL } from '../atoms/settings' 3 | import { useI18n } from '../hooks/useI18n' 4 | import { useAtomValue } from 'jotai' 5 | 6 | const token = localStorage.getItem('token') 7 | 8 | const LogTerminal: React.FC = () => { 9 | const [logBuffer, setLogBuffer] = useState([]) 10 | const [isConnecting, setIsConnecting] = useState(true) 11 | 12 | const boxRef = useRef(null) 13 | 14 | const serverAddr = useAtomValue(serverURL) 15 | 16 | const { i18n } = useI18n() 17 | 18 | const eventSource = useMemo( 19 | () => new EventSource(`${serverAddr}/log/sse?token=${token}`), 20 | [serverAddr] 21 | ) 22 | 23 | useEffect(() => { 24 | eventSource.addEventListener('log', event => { 25 | const msg: string = JSON.parse(event.data) 26 | setLogBuffer(buff => [...buff, msg].slice(-500)) 27 | 28 | boxRef.current?.scrollTo(0, boxRef.current.scrollHeight) 29 | }) 30 | 31 | // TODO: in dev mode it breaks sse 32 | return () => eventSource.close() 33 | }, [eventSource]) 34 | 35 | useEffect(() => { 36 | eventSource.onopen = () => setIsConnecting(false) 37 | }, [eventSource]) 38 | 39 | const logEntryStyle = (data: string) => { 40 | const sx = {} 41 | 42 | if (data.includes("level=ERROR")) { 43 | return { ...sx, color: 'red' } 44 | } 45 | if (data.includes("level=WARN")) { 46 | return { ...sx, color: 'orange' } 47 | } 48 | 49 | return sx 50 | } 51 | 52 | return ( 53 | 54 |
69 | {isConnecting ?
{'Connecting...'}
:
{'Connected!'}
} 70 | 71 | {logBuffer.length === 0 &&
{i18n.t('awaitingLogs')}
} 72 | 73 | {logBuffer.map((log, idx) => ( 74 |
75 | {log} 76 |
77 | ))} 78 |
79 | 80 | ) 81 | } 82 | 83 | export default LogTerminal -------------------------------------------------------------------------------- /frontend/src/views/Twitch.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Chip, 3 | Container, 4 | Paper 5 | } from '@mui/material' 6 | import { matchW } from 'fp-ts/lib/Either' 7 | import { pipe } from 'fp-ts/lib/function' 8 | import { useAtomValue } from 'jotai' 9 | import { useState, useTransition } from 'react' 10 | import { serverURL } from '../atoms/settings' 11 | import LoadingBackdrop from '../components/LoadingBackdrop' 12 | import NoSubscriptions from '../components/subscriptions/NoSubscriptions' 13 | import SubscriptionsSpeedDial from '../components/subscriptions/SubscriptionsSpeedDial' 14 | import TwitchDialog from '../components/twitch/TwitchDialog' 15 | import { useToast } from '../hooks/toast' 16 | import useFetch from '../hooks/useFetch' 17 | import { ffetch } from '../lib/httpClient' 18 | 19 | const TwitchView: React.FC = () => { 20 | const { pushMessage } = useToast() 21 | 22 | const baseURL = useAtomValue(serverURL) 23 | 24 | const [openDialog, setOpenDialog] = useState(false) 25 | 26 | const { data: users, fetcher: refetch } = useFetch>('/twitch/users') 27 | 28 | const [isPending, startTransition] = useTransition() 29 | 30 | const deleteUser = async (user: string) => { 31 | const task = ffetch(`${baseURL}/twitch/user/${user}`, { 32 | method: 'DELETE', 33 | }) 34 | const either = await task() 35 | 36 | pipe( 37 | either, 38 | matchW( 39 | (l) => pushMessage(l, 'error'), 40 | () => refetch() 41 | ) 42 | ) 43 | } 44 | 45 | return ( 46 | <> 47 | 48 | 49 | setOpenDialog(s => !s)} /> 50 | 51 | { 52 | setOpenDialog(s => !s) 53 | refetch() 54 | }} /> 55 | 56 | { 57 | !users || users.length === 0 ? 58 | : 59 | 60 | 64 | {users.map(user => ( 65 | startTransition(async () => await deleteUser(user))} 68 | /> 69 | ))} 70 | 71 | 72 | } 73 | 74 | ) 75 | } 76 | 77 | export default TwitchView -------------------------------------------------------------------------------- /frontend/src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import DownloadIcon from '@mui/icons-material/Download' 2 | import SettingsEthernet from '@mui/icons-material/SettingsEthernet' 3 | import { AppBar, CircularProgress, Divider, Toolbar } from '@mui/material' 4 | import { Suspense } from 'react' 5 | import { settingsState } from '../atoms/settings' 6 | import { connectedState } from '../atoms/status' 7 | import { totalDownloadSpeedState } from '../atoms/ui' 8 | import { useI18n } from '../hooks/useI18n' 9 | import { formatSpeedMiB } from '../utils' 10 | import FreeSpaceIndicator from './FreeSpaceIndicator' 11 | import VersionIndicator from './VersionIndicator' 12 | import { useAtomValue } from 'jotai' 13 | 14 | const Footer: React.FC = () => { 15 | const settings = useAtomValue(settingsState) 16 | const isConnected = useAtomValue(connectedState) 17 | const totalDownloadSpeed = useAtomValue(totalDownloadSpeedState) 18 | 19 | const mode = settings.theme 20 | const { i18n } = useI18n() 21 | 22 | return ( 23 | 32 | 37 | }> 38 | 39 | 40 |
41 |
48 | 49 | 50 | {formatSpeedMiB(totalDownloadSpeed)} 51 | 52 | 53 | 54 | 55 | {isConnected ? settings.serverAddr : i18n.t('notConnectedText')} 56 | 57 |
58 | 59 | 60 | 61 | 62 |
63 |
64 |
65 | ) 66 | } 67 | 68 | export default Footer -------------------------------------------------------------------------------- /server/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "sync" 7 | "time" 8 | 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | type Config struct { 13 | LogPath string `yaml:"log_path"` 14 | EnableFileLogging bool `yaml:"enable_file_logging"` 15 | BaseURL string `yaml:"base_url"` 16 | Host string `yaml:"host"` 17 | Port int `yaml:"port"` 18 | DownloadPath string `yaml:"downloadPath"` 19 | DownloaderPath string `yaml:"downloaderPath"` 20 | RequireAuth bool `yaml:"require_auth"` 21 | Username string `yaml:"username"` 22 | Password string `yaml:"password"` 23 | QueueSize int `yaml:"queue_size"` 24 | LocalDatabasePath string `yaml:"local_database_path"` 25 | SessionFilePath string `yaml:"session_file_path"` 26 | path string // private 27 | UseOpenId bool `yaml:"use_openid"` 28 | OpenIdProviderURL string `yaml:"openid_provider_url"` 29 | OpenIdClientId string `yaml:"openid_client_id"` 30 | OpenIdClientSecret string `yaml:"openid_client_secret"` 31 | OpenIdRedirectURL string `yaml:"openid_redirect_url"` 32 | OpenIdEmailWhitelist []string `yaml:"openid_email_whitelist"` 33 | FrontendPath string `yaml:"frontend_path"` 34 | AutoArchive bool `yaml:"auto_archive"` 35 | Twitch struct { 36 | ClientId string `yaml:"client_id"` 37 | ClientSecret string `yaml:"client_secret"` 38 | CheckInterval time.Duration `yaml:"check_interval"` 39 | } `yaml:"twitch"` 40 | } 41 | 42 | var ( 43 | instance *Config 44 | instanceOnce sync.Once 45 | ) 46 | 47 | func Instance() *Config { 48 | if instance == nil { 49 | instanceOnce.Do(func() { 50 | instance = &Config{} 51 | instance.Twitch.CheckInterval = time.Minute * 5 52 | }) 53 | } 54 | return instance 55 | } 56 | 57 | // Initialises the Config struct given its config file 58 | func (c *Config) LoadFile(filename string) error { 59 | fd, err := os.Open(filename) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | c.path = filename 65 | 66 | if err := yaml.NewDecoder(fd).Decode(c); err != nil { 67 | return err 68 | } 69 | 70 | return nil 71 | } 72 | 73 | // Path of the directory containing the config file 74 | func (c *Config) Dir() string { return filepath.Dir(c.path) } 75 | 76 | // Absolute path of the config file 77 | func (c *Config) Path() string { return c.path } 78 | -------------------------------------------------------------------------------- /frontend/src/components/HomeSpeedDial.tsx: -------------------------------------------------------------------------------- 1 | import AddCircleIcon from '@mui/icons-material/AddCircle' 2 | import BuildCircleIcon from '@mui/icons-material/BuildCircle' 3 | import ClearAllIcon from '@mui/icons-material/ClearAll' 4 | import DeleteForeverIcon from '@mui/icons-material/DeleteForever' 5 | import FolderZipIcon from '@mui/icons-material/FolderZip' 6 | import FormatListBulleted from '@mui/icons-material/FormatListBulleted' 7 | import ViewAgendaIcon from '@mui/icons-material/ViewAgenda' 8 | import { 9 | SpeedDial, 10 | SpeedDialAction, 11 | SpeedDialIcon 12 | } from '@mui/material' 13 | import { useAtom, useAtomValue } from 'jotai' 14 | import { listViewState, serverURL } from '../atoms/settings' 15 | import { useI18n } from '../hooks/useI18n' 16 | import { useRPC } from '../hooks/useRPC' 17 | 18 | type Props = { 19 | onDownloadOpen: () => void 20 | onEditorOpen: () => void 21 | } 22 | 23 | const HomeSpeedDial: React.FC = ({ onDownloadOpen, onEditorOpen }) => { 24 | const serverAddr = useAtomValue(serverURL) 25 | const [listView, setListView] = useAtom(listViewState) 26 | 27 | const { i18n } = useI18n() 28 | const { client } = useRPC() 29 | 30 | return ( 31 | } 35 | > 36 | : } 38 | tooltipTitle={listView ? 'Card view' : i18n.t('tableView')} 39 | onClick={() => setListView(state => !state)} 40 | /> 41 | } 43 | tooltipTitle={i18n.t('bulkDownload')} 44 | onClick={() => window.open(`${serverAddr}/archive/bulk?token=${localStorage.getItem('token')}`)} 45 | /> 46 | } 48 | tooltipTitle={i18n.t('clearCompletedButton')} 49 | onClick={() => client.clearCompleted()} 50 | /> 51 | } 53 | tooltipTitle={i18n.t('abortAllButton')} 54 | onClick={() => client.killAll()} 55 | /> 56 | } 58 | tooltipTitle={i18n.t('templatesEditor')} 59 | onClick={onEditorOpen} 60 | /> 61 | } 63 | tooltipTitle={i18n.t('newDownloadButton')} 64 | onClick={onDownloadOpen} 65 | /> 66 | 67 | ) 68 | } 69 | 70 | export default HomeSpeedDial -------------------------------------------------------------------------------- /frontend/src/components/SocketSubscriber.tsx: -------------------------------------------------------------------------------- 1 | import * as O from 'fp-ts/Option' 2 | import { useEffect, useMemo } from 'react' 3 | import { useNavigate } from 'react-router-dom' 4 | import { take, timer } from 'rxjs' 5 | import { downloadsState } from '../atoms/downloads' 6 | import { rpcPollingTimeState } from '../atoms/rpc' 7 | import { serverAddressAndPortState } from '../atoms/settings' 8 | import { connectedState } from '../atoms/status' 9 | import { useSubscription } from '../hooks/observable' 10 | import { useToast } from '../hooks/toast' 11 | import { useI18n } from '../hooks/useI18n' 12 | import { useRPC } from '../hooks/useRPC' 13 | import { datetimeCompareFunc, isRPCResponse } from '../utils' 14 | import { useAtom, useAtomValue, useSetAtom } from 'jotai' 15 | 16 | interface Props extends React.HTMLAttributes { } 17 | 18 | const SocketSubscriber: React.FC = () => { 19 | const [connected, setIsConnected] = useAtom(connectedState) 20 | const setDownloads = useSetAtom(downloadsState) 21 | 22 | const serverAddressAndPort = useAtomValue(serverAddressAndPortState) 23 | const rpcPollingTime = useAtomValue(rpcPollingTimeState) 24 | 25 | const { i18n } = useI18n() 26 | const { client } = useRPC() 27 | const { pushMessage } = useToast() 28 | 29 | const navigate = useNavigate() 30 | 31 | const socketOnce$ = useMemo(() => client.socket$.pipe(take(1)), []) 32 | 33 | useEffect(() => { 34 | if (!connected) { 35 | socketOnce$.subscribe(() => { 36 | setIsConnected(true) 37 | pushMessage( 38 | `${i18n.t('toastConnected')} (${serverAddressAndPort})`, 39 | "success" 40 | ) 41 | }) 42 | } 43 | }, [connected]) 44 | 45 | useSubscription( 46 | client.socket$, 47 | event => { 48 | if (!isRPCResponse(event)) { return } 49 | if (!Array.isArray(event.result)) { return } 50 | 51 | if (event.result) { 52 | return setDownloads( 53 | O.of(event.result 54 | .filter(f => !!f.info.url).sort((a, b) => datetimeCompareFunc( 55 | b.info.created_at, 56 | a.info.created_at, 57 | )), 58 | ) 59 | ) 60 | } 61 | setDownloads(O.none) 62 | }, 63 | err => { 64 | console.error(err) 65 | pushMessage( 66 | `${i18n.t('rpcConnErr')} (${serverAddressAndPort})`, 67 | "error" 68 | ), 69 | navigate(`/error`) 70 | } 71 | ) 72 | 73 | useEffect(() => { 74 | if (connected) { 75 | const sub = timer(0, rpcPollingTime).subscribe(() => client.running()) 76 | return () => sub.unsubscribe() 77 | } 78 | }, [connected, client, rpcPollingTime]) 79 | 80 | return null 81 | } 82 | 83 | export default SocketSubscriber -------------------------------------------------------------------------------- /server/logging/handler.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "log/slog" 8 | "net/http" 9 | 10 | "github.com/go-chi/chi/v5" 11 | "github.com/gorilla/websocket" 12 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 13 | middlewares "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/middleware" 14 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/openid" 15 | ) 16 | 17 | var upgrader = websocket.Upgrader{ 18 | CheckOrigin: func(r *http.Request) bool { 19 | return true 20 | }, 21 | ReadBufferSize: 1000, 22 | WriteBufferSize: 1000, 23 | } 24 | 25 | func webSocket(logger *ObservableLogger) http.HandlerFunc { 26 | return func(w http.ResponseWriter, r *http.Request) { 27 | c, err := upgrader.Upgrade(w, r, nil) 28 | if err != nil { 29 | http.Error(w, err.Error(), http.StatusInternalServerError) 30 | return 31 | } 32 | 33 | logs := logger.Observe(r.Context()) 34 | 35 | for { 36 | select { 37 | case <-r.Context().Done(): 38 | return 39 | case msg := <-logs: 40 | c.WriteJSON(msg) 41 | } 42 | } 43 | } 44 | } 45 | 46 | func sse(logger *ObservableLogger) http.HandlerFunc { 47 | return func(w http.ResponseWriter, r *http.Request) { 48 | w.Header().Set("Content-Type", "text/event-stream") 49 | w.Header().Set("Cache-Control", "no-cache") 50 | w.Header().Set("Connection", "keep-alive") 51 | 52 | flusher, ok := w.(http.Flusher) 53 | if !ok { 54 | http.Error(w, "SSE not supported", http.StatusInternalServerError) 55 | return 56 | } 57 | 58 | logs := logger.Observe(r.Context()) 59 | 60 | for { 61 | select { 62 | case <-r.Context().Done(): 63 | slog.Info("detaching from logger") 64 | return 65 | case msg, ok := <-logs: 66 | if !ok { 67 | http.Error(w, "closed logs channel", http.StatusInternalServerError) 68 | return 69 | } 70 | 71 | var b bytes.Buffer 72 | 73 | b.WriteString("event: log\n") 74 | b.WriteString("data: ") 75 | 76 | if err := json.NewEncoder(&b).Encode(msg); err != nil { 77 | http.Error(w, err.Error(), http.StatusInternalServerError) 78 | return 79 | } 80 | 81 | b.WriteRune('\n') 82 | b.WriteRune('\n') 83 | 84 | io.Copy(w, &b) 85 | 86 | flusher.Flush() 87 | } 88 | } 89 | } 90 | } 91 | 92 | func ApplyRouter(logger *ObservableLogger) func(chi.Router) { 93 | return func(r chi.Router) { 94 | if config.Instance().RequireAuth { 95 | r.Use(middlewares.Authenticated) 96 | } 97 | if config.Instance().UseOpenId { 98 | r.Use(openid.Middleware) 99 | } 100 | r.Get("/ws", webSocket(logger)) 101 | r.Get("/sse", sse(logger)) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | on: 3 | release: 4 | types: [published] 5 | push: 6 | branches: [ master ] 7 | schedule: 8 | - cron : '0 1 * * 0' 9 | 10 | jobs: 11 | publish: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: read 15 | packages: write 16 | # This is used to complete the identity challenge 17 | # with sigstore/fulcio when running outside of PRs. 18 | id-token: write 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | # https://github.com/sigstore/cosign-installer 24 | - name: Install cosign 25 | # v3.1.2 26 | uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19 27 | with: 28 | cosign-release: 'v1.13.6' 29 | 30 | - name: Set up QEMU for ARM emulation 31 | # v2.2.0 32 | uses: docker/setup-qemu-action@2b82ce82d56a2a04d2637cd93a637ae1b359c0a7 33 | with: 34 | platforms: all 35 | - name: Set up Docker Buildx 36 | # 2.10.0 37 | uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55 38 | 39 | - name: Login to Docker Hub 40 | # 2.2.0 41 | uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc 42 | with: 43 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 44 | password: ${{ secrets.DOCKER_HUB_PASSWORD }} 45 | - name: Login to GHCR 46 | # 2.2.0 47 | uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc 48 | with: 49 | registry: ghcr.io 50 | username: ${{ github.actor }} 51 | password: ${{ secrets.GITHUB_TOKEN }} 52 | 53 | - name: Generate Docker metadata 54 | id: meta 55 | # v4.6.0 56 | uses: docker/metadata-action@818d4b7b91585d195f67373fd9cb0332e31a7175 57 | with: 58 | images: | 59 | ghcr.io/${{ github.repository }} 60 | docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/yt-dlp-webui 61 | tags: | 62 | type=raw,value=latest 63 | 64 | - name: Build and push Docker image 65 | id: build-and-push 66 | # v4.2.1 67 | uses: docker/build-push-action@0a97817b6ade9f46837855d676c4cca3a2471fc9 68 | with: 69 | context: . 70 | push: true 71 | platforms: linux/amd64,linux/arm64 72 | tags: ${{ steps.meta.outputs.tags }} 73 | labels: ${{ steps.meta.outputs.labels}} 74 | 75 | - name: Sign the published Docker image 76 | env: 77 | COSIGN_EXPERIMENTAL: "true" 78 | # This step uses the identity token to provision an ephemeral certificate 79 | # against the sigstore community Fulcio instance. 80 | run: | 81 | cosign sign ghcr.io/${{ github.repository }}@${{ steps.build-and-push.outputs.digest }} 82 | cosign sign docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/yt-dlp-webui@${{ steps.build-and-push.outputs.digest }} 83 | -------------------------------------------------------------------------------- /server/internal/playlist.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "log/slog" 7 | "os/exec" 8 | "slices" 9 | "strings" 10 | "time" 11 | 12 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/common" 13 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 14 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/playlist" 15 | ) 16 | 17 | func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error { 18 | params := append(req.Params, "--flat-playlist", "-J") 19 | urlWithParams := append([]string{req.URL}, params...) 20 | 21 | var ( 22 | downloader = config.Instance().DownloaderPath 23 | cmd = exec.Command(downloader, urlWithParams...) 24 | ) 25 | 26 | stdout, err := cmd.StdoutPipe() 27 | if err != nil { 28 | return err 29 | } 30 | 31 | var m playlist.Metadata 32 | 33 | if err := cmd.Start(); err != nil { 34 | return err 35 | } 36 | 37 | slog.Info("decoding playlist metadata", slog.String("url", req.URL)) 38 | 39 | if err := json.NewDecoder(stdout).Decode(&m); err != nil { 40 | return err 41 | } 42 | 43 | if err := cmd.Wait(); err != nil { 44 | return err 45 | } 46 | 47 | slog.Info("decoded playlist metadata", slog.String("url", req.URL)) 48 | 49 | if m.Type == "" { 50 | return errors.New("probably not a valid URL") 51 | } 52 | 53 | if m.IsPlaylist() { 54 | entries := slices.CompactFunc(slices.Compact(m.Entries), func(a common.DownloadInfo, b common.DownloadInfo) bool { 55 | return a.URL == b.URL 56 | }) 57 | 58 | entries = slices.DeleteFunc(entries, func(e common.DownloadInfo) bool { 59 | return strings.Contains(e.URL, "list=") 60 | }) 61 | 62 | slog.Info("playlist detected", slog.String("url", req.URL), slog.Int("count", len(entries))) 63 | 64 | if err := playlist.ApplyModifiers(&entries, req.Params); err != nil { 65 | return err 66 | } 67 | 68 | for i, meta := range entries { 69 | // detect playlist title from metadata since each playlist entry will be 70 | // treated as an individual download 71 | req.Rename = strings.Replace( 72 | req.Rename, 73 | "%(playlist_title)s", 74 | m.PlaylistTitle, 75 | 1, 76 | ) 77 | 78 | //XXX: it's idiotic but it works: virtually delay the creation time 79 | meta.CreatedAt = time.Now().Add(time.Millisecond * time.Duration(i*10)) 80 | 81 | proc := &Process{ 82 | Url: meta.URL, 83 | Progress: DownloadProgress{}, 84 | Output: DownloadOutput{Filename: req.Rename}, 85 | Info: meta, 86 | Params: req.Params, 87 | } 88 | 89 | proc.Info.URL = meta.URL 90 | 91 | db.Set(proc) 92 | mq.Publish(proc) 93 | 94 | proc.Info.CreatedAt = meta.CreatedAt 95 | } 96 | 97 | return nil 98 | } 99 | 100 | proc := &Process{ 101 | Url: req.URL, 102 | Params: req.Params, 103 | } 104 | 105 | db.Set(proc) 106 | mq.Publish(proc) 107 | slog.Info("sending new process to message queue", slog.String("url", proc.Url)) 108 | 109 | return cmd.Wait() 110 | } 111 | -------------------------------------------------------------------------------- /frontend/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { blue, red } from '@mui/material/colors' 2 | import { pipe } from 'fp-ts/lib/function' 3 | import { Accent, ThemeNarrowed } from './atoms/settings' 4 | import type { RPCResponse } from "./types" 5 | import { ProcessStatus } from './types' 6 | 7 | export function validateIP(ipAddr: string): boolean { 8 | let ipRegex = /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/gm 9 | return ipRegex.test(ipAddr) 10 | } 11 | 12 | export function validateDomain(url: string): boolean { 13 | const urlRegex = /(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/ 14 | const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ 15 | 16 | const [name, slug] = url.split('/') 17 | 18 | return urlRegex.test(url) || name === 'localhost' && slugRegex.test(slug) 19 | } 20 | 21 | export const ellipsis = (str: string, lim: number) => 22 | str.length > lim 23 | ? `${str.substring(0, lim)}...` 24 | : str 25 | 26 | export function toFormatArgs(codes: string[]): string { 27 | if (codes.length > 1) { 28 | return codes.reduce((v, a) => ` -f ${v}+${a}`) 29 | } 30 | if (codes.length === 1) { 31 | return ` -f ${codes[0]}` 32 | } 33 | return '' 34 | } 35 | 36 | export function formatSize(bytes: number): string { 37 | const threshold = 1024 38 | const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] 39 | 40 | let i = 0 41 | while (bytes >= threshold) { 42 | bytes /= threshold 43 | i = i + 1 44 | } 45 | 46 | return `${bytes.toFixed(i == 0 ? 0 : 2)} ${units.at(i)}` 47 | } 48 | 49 | export const formatSpeedMiB = (val: number) => 50 | `${(val / 1_048_576).toFixed(2)} MiB/s` 51 | 52 | export const datetimeCompareFunc = (a: string, b: string) => 53 | new Date(a).getTime() - new Date(b).getTime() 54 | 55 | export function isRPCResponse(object: any): object is RPCResponse { 56 | return 'result' in object && 'id' in object 57 | } 58 | 59 | export function mapProcessStatus(status: ProcessStatus) { 60 | switch (status) { 61 | case ProcessStatus.PENDING: 62 | return 'Pending' 63 | case ProcessStatus.DOWNLOADING: 64 | return 'Downloading' 65 | case ProcessStatus.COMPLETED: 66 | return 'Completed' 67 | case ProcessStatus.ERRORED: 68 | return 'Error' 69 | case ProcessStatus.LIVESTREAM: 70 | return 'Livestream' 71 | default: 72 | return 'Pending' 73 | } 74 | } 75 | 76 | export const prefersDarkMode = () => 77 | window.matchMedia('(prefers-color-scheme: dark)').matches 78 | 79 | export const base64URLEncode = (s: string) => pipe( 80 | s, 81 | s => String.fromCodePoint(...new TextEncoder().encode(s)), 82 | btoa, 83 | encodeURIComponent 84 | ) 85 | 86 | export const getAccentValue = (accent: Accent, mode: ThemeNarrowed) => { 87 | switch (accent) { 88 | case 'default': 89 | return mode === 'light' ? blue[700] : blue[300] 90 | case 'red': 91 | return mode === 'light' ? red[600] : red[400] 92 | default: 93 | return mode === 'light' ? blue[700] : blue[300] 94 | } 95 | } -------------------------------------------------------------------------------- /frontend/src/assets/i18n/zh_CN.yaml: -------------------------------------------------------------------------------- 1 | keys: 2 | urlInput: 视频 URL 3 | statusTitle: 状态 4 | statusReady: 就绪 5 | selectFormatButton: 选择格式 6 | startButton: 开始 7 | abortAllButton: 全部中止 8 | updateBinButton: 更新 yt-dlp 可执行文件 9 | darkThemeButton: 黑暗主题 10 | lightThemeButton: 明亮主题 11 | settingsAnchor: 设置 12 | serverAddressTitle: 服务器地址 13 | serverPortTitle: 端口 14 | extractAudioCheckbox: 提取音频 15 | noMTimeCheckbox: 不设置文件修改时间 16 | bgReminder: 关闭页面后,下载会继续在后台运行。 17 | toastConnected: '已连接到 ' 18 | toastUpdated: 已更新 yt-dlp 可执行文件! 19 | formatSelectionEnabler: 启用视频/音频格式选择 20 | themeSelect: '主题' 21 | languageSelect: '语言' 22 | overridesAnchor: 覆盖 23 | pathOverrideOption: 启用输出路径覆盖 24 | filenameOverrideOption: 启用输出文件名覆盖 25 | autoFileExtensionOption: 自动文件扩展名 26 | customFilename: 自定义文件名(留空使用默认值) 27 | customPath: 自定义路径 28 | customArgs: 启用自定义 yt-dlp 参数(能力越大 = 责任越大) 29 | customArgsInput: 自定义 yt-dlp 参数 30 | rpcConnErr: 连接 RPC 服务器发生错误 31 | splashText: 没有正在进行的下载 32 | archiveTitle: 归档 33 | clipboardAction: 复制 URL 到剪贴板 34 | playlistCheckbox: 下载播放列表(可能需要一段时间,提交后可以关闭页面等待) 35 | restartAppMessage: 需要刷新页面才能生效 36 | servedFromReverseProxyCheckbox: 处于反向代理的子目录后 37 | newDownloadButton: 新下载 38 | homeButtonLabel: 主页 39 | archiveButtonLabel: 归档 40 | settingsButtonLabel: 设置 41 | rpcAuthenticationLabel: RPC 身份验证 42 | themeTogglerLabel: 主题切换 43 | loadingLabel: 正在加载… 44 | appTitle: App 标题 45 | savedTemplates: 保存模板 46 | templatesEditor: 模板编辑器 47 | templatesEditorNameLabel: 模板名称 48 | templatesEditorContentLabel: 模板内容 49 | logsTitle: '日志' 50 | awaitingLogs: '正在等待日志…' 51 | bulkDownload: '下载 zip 压缩包中的文件' 52 | templatesReloadInfo: To register a new template it might need a page reload. 53 | livestreamURLInput: 直播 URL 54 | livestreamStatusWaiting: 等待直播开始 55 | livestreamStatusDownloading: 下载中 56 | livestreamStatusCompleted: 已完成 57 | livestreamStatusErrored: 发生错误 58 | livestreamStatusUnknown: 未知 59 | livestreamNoMonitoring: No livestreams monitored 60 | livestreamDownloadInfo: | 61 | 本功能将会监控即将开始的直播流,每个进程都会传入参数:--wait-for-video 10 (重试间隔10秒) 62 | 如果直播已经开始,那么依然可以下载,但是不会记录下载进度。 63 | 直播开始后,将会转移到下载页面 64 | livestreamExperimentalWarning: 实验性功能,可能存在未知Bug,请谨慎使用 65 | accentSelect: 'Accent' 66 | urlBase: URL base, for reverse proxy support (subdir), defaults to empty 67 | rpcPollingTimeTitle: RPC polling time 68 | rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side) 69 | generalDownloadSettings: 'General Download Settings' 70 | deleteCookies: Delete Cookies 71 | noFilesFound: 'No Files Found' 72 | tableView: 'Table View' 73 | deleteSelected: 'Delete selected' 74 | subscriptionsButtonLabel: 'Subscriptions' 75 | subscriptionsEmptyLabel: 'No subscriptions' 76 | subscriptionsURLInput: 'Channel URL' 77 | subscriptionsInfo: | 78 | Subscribes to a defined channel. Only the last video will be downloaded. 79 | The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank). 80 | cronExpressionLabel: 'Cron expression' 81 | editButtonLabel: 'Edit' 82 | newSubscriptionButton: New subscription -------------------------------------------------------------------------------- /frontend/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type RPCMethods = 2 | | "Service.Exec" 3 | | "Service.Kill" 4 | | "Service.Clear" 5 | | "Service.Running" 6 | | "Service.KillAll" 7 | | "Service.FreeSpace" 8 | | "Service.Formats" 9 | | "Service.ExecPlaylist" 10 | | "Service.DirectoryTree" 11 | | "Service.UpdateExecutable" 12 | | "Service.ExecLivestream" 13 | | "Service.ProgressLivestream" 14 | | "Service.KillLivestream" 15 | | "Service.KillAllLivestream" 16 | | "Service.ClearCompleted" 17 | 18 | export type RPCRequest = { 19 | method: RPCMethods 20 | params?: any[] 21 | id?: string 22 | } 23 | 24 | export type RPCResponse = Readonly<{ 25 | result: T 26 | error: number | null 27 | id?: string 28 | }> 29 | 30 | type DownloadInfo = { 31 | url: string 32 | filesize_approx?: number 33 | resolution?: string 34 | thumbnail: string 35 | title: string 36 | vcodec?: string 37 | acodec?: string 38 | ext?: string 39 | created_at: string 40 | } 41 | 42 | export enum ProcessStatus { 43 | PENDING = 0, 44 | DOWNLOADING, 45 | COMPLETED, 46 | ERRORED, 47 | LIVESTREAM, 48 | } 49 | 50 | type DownloadProgress = { 51 | speed: number 52 | eta: number 53 | percentage: string 54 | process_status: ProcessStatus 55 | } 56 | 57 | export type RPCResult = Readonly<{ 58 | id: string 59 | progress: DownloadProgress 60 | info: DownloadInfo 61 | output: { 62 | savedFilePath: string 63 | } 64 | }> 65 | 66 | export type RPCParams = { 67 | URL: string 68 | Params?: string 69 | } 70 | 71 | export type DLMetadata = { 72 | formats: Array 73 | _type: string 74 | best: DLFormat 75 | thumbnail: string 76 | title: string 77 | entries: Array 78 | } 79 | 80 | export type DLFormat = { 81 | format_id: string 82 | format_note: string 83 | fps: number 84 | resolution: string 85 | vcodec: string 86 | acodec: string 87 | filesize_approx: number 88 | language: string 89 | } 90 | 91 | export type DirectoryEntry = { 92 | name: string 93 | path: string 94 | size: number 95 | modTime: string 96 | isVideo: boolean 97 | isDirectory: boolean 98 | } 99 | 100 | export type DeleteRequest = Pick 101 | 102 | export type PlayRequest = DeleteRequest 103 | 104 | export type CustomTemplate = { 105 | id: string 106 | name: string 107 | content: string 108 | } 109 | 110 | export enum LiveStreamStatus { 111 | WAITING, 112 | IN_PROGRESS, 113 | COMPLETED, 114 | ERRORED 115 | } 116 | 117 | export type LiveStreamProgress = Record 122 | 123 | export type RPCVersion = { 124 | rpcVersion: string 125 | ytdlpVersion: string 126 | } 127 | 128 | export type ArchiveEntry = { 129 | id: string 130 | title: string 131 | path: string 132 | thumbnail: string 133 | source: string 134 | metadata: string 135 | created_at: string 136 | } 137 | 138 | export type PaginatedResponse = { 139 | first: number 140 | next: number 141 | data: T 142 | } -------------------------------------------------------------------------------- /server/internal/livestream/monitor.go: -------------------------------------------------------------------------------- 1 | package livestream 2 | 3 | import ( 4 | "encoding/gob" 5 | "log/slog" 6 | "maps" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 11 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/internal" 12 | ) 13 | 14 | type Monitor struct { 15 | db *internal.MemoryDB // where the just started livestream will be published 16 | mq *internal.MessageQueue // where the just started livestream will be published 17 | streams map[string]*LiveStream // keeps track of the livestreams 18 | done chan *LiveStream // to signal individual processes completition 19 | } 20 | 21 | func NewMonitor(mq *internal.MessageQueue, db *internal.MemoryDB) *Monitor { 22 | return &Monitor{ 23 | mq: mq, 24 | db: db, 25 | streams: make(map[string]*LiveStream), 26 | done: make(chan *LiveStream), 27 | } 28 | } 29 | 30 | // Detect each livestream completition, if done detach it from the monitor. 31 | func (m *Monitor) Schedule() { 32 | for l := range m.done { 33 | delete(m.streams, l.url) 34 | } 35 | } 36 | 37 | func (m *Monitor) Add(url string) { 38 | ls := New(url, m.done, m.mq, m.db) 39 | 40 | go ls.Start() 41 | m.streams[url] = ls 42 | } 43 | 44 | func (m *Monitor) Remove(url string) error { 45 | return m.streams[url].Kill() 46 | } 47 | 48 | func (m *Monitor) RemoveAll() error { 49 | for _, v := range m.streams { 50 | if err := v.Kill(); err != nil { 51 | return err 52 | } 53 | } 54 | return nil 55 | } 56 | 57 | func (m *Monitor) Status() LiveStreamStatus { 58 | status := make(LiveStreamStatus) 59 | 60 | for k, v := range m.streams { 61 | // wt, ok := <-v.WaitTime() 62 | // if !ok { 63 | // continue 64 | // } 65 | 66 | status[k] = Status{ 67 | Status: v.status, 68 | WaitTime: v.waitTime, 69 | LiveDate: v.liveDate, 70 | } 71 | } 72 | 73 | return status 74 | } 75 | 76 | // Persist the monitor current state to a file. 77 | // The file is located in the configured config directory 78 | func (m *Monitor) Persist() error { 79 | fd, err := os.Create(filepath.Join(config.Instance().SessionFilePath, "livestreams.dat")) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | defer fd.Close() 85 | 86 | slog.Debug("persisting livestream monitor state") 87 | 88 | var toPersist []string 89 | for url := range maps.Keys(m.streams) { 90 | toPersist = append(toPersist, url) 91 | } 92 | 93 | return gob.NewEncoder(fd).Encode(toPersist) 94 | } 95 | 96 | // Restore a saved state and resume the monitored livestreams 97 | func (m *Monitor) Restore() error { 98 | fd, err := os.Open(filepath.Join(config.Instance().SessionFilePath, "livestreams.dat")) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | defer fd.Close() 104 | 105 | var toRestore []string 106 | 107 | if err := gob.NewDecoder(fd).Decode(&toRestore); err != nil { 108 | return err 109 | } 110 | 111 | for _, url := range toRestore { 112 | m.Add(url) 113 | } 114 | 115 | slog.Debug("restored livestream monitor state") 116 | 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /frontend/src/components/ArchiveCard.tsx: -------------------------------------------------------------------------------- 1 | import DeleteIcon from '@mui/icons-material/Delete' 2 | import DeleteForeverIcon from '@mui/icons-material/DeleteForever' 3 | import OpenInBrowserIcon from '@mui/icons-material/OpenInBrowser' 4 | import SaveAltIcon from '@mui/icons-material/SaveAlt' 5 | import { 6 | Card, 7 | CardActionArea, 8 | CardActions, 9 | CardContent, 10 | CardMedia, 11 | IconButton, 12 | Skeleton, 13 | Tooltip, 14 | Typography 15 | } from '@mui/material' 16 | import { useAtomValue } from 'jotai' 17 | import { serverURL } from '../atoms/settings' 18 | import { ArchiveEntry } from '../types' 19 | import { base64URLEncode, ellipsis } from '../utils' 20 | 21 | type Props = { 22 | entry: ArchiveEntry 23 | onDelete: (id: string) => void 24 | onHardDelete: (id: string) => void 25 | } 26 | 27 | const ArchiveCard: React.FC = ({ entry, onDelete, onHardDelete }) => { 28 | const serverAddr = useAtomValue(serverURL) 29 | 30 | const viewFile = (path: string) => { 31 | const encoded = base64URLEncode(path) 32 | window.open(`${serverAddr}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`) 33 | } 34 | 35 | const downloadFile = (path: string) => { 36 | const encoded = base64URLEncode(path) 37 | window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`) 38 | } 39 | 40 | return ( 41 | 42 | navigator.clipboard.writeText(entry.source)}> 43 | {entry.thumbnail !== '' ? 44 | : 49 | 50 | } 51 | 52 | {entry.title !== '' ? 53 | 54 | {ellipsis(entry.title, 60)} 55 | : 56 | 57 | } 58 | {/* 59 | {JSON.stringify(JSON.parse(entry.metadata), null, 2)} 60 | */} 61 |

{new Date(entry.created_at).toLocaleString()}

62 |
63 |
64 | 65 | 66 | viewFile(entry.path)} 68 | > 69 | 70 | 71 | 72 | 73 | downloadFile(entry.path)} 75 | > 76 | 77 | 78 | 79 | 80 | onDelete(entry.id)} 82 | > 83 | 84 | 85 | 86 | 87 | onHardDelete(entry.id)} 89 | > 90 | 91 | 92 | 93 | 94 |
95 | ) 96 | } 97 | 98 | export default ArchiveCard -------------------------------------------------------------------------------- /frontend/src/assets/i18n/ja.yaml: -------------------------------------------------------------------------------- 1 | keys: 2 | urlInput: YouTubeまたはサポート済み動画のURL 3 | statusTitle: 状態 4 | statusReady: 準備 5 | selectFormatButton: フォーマット選択 6 | startButton: 開始 7 | abortAllButton: すべて中止 8 | updateBinButton: yt-dlp更新 9 | darkThemeButton: 黒テーマ 10 | lightThemeButton: 白テーマ 11 | settingsAnchor: 設定 12 | serverAddressTitle: サーバーアドレス 13 | serverPortTitle: ポート番号 14 | extractAudioCheckbox: 音質 15 | noMTimeCheckbox: ファイル時間の修正をしない 16 | bgReminder: このページを閉じてもバックグラウンドでダウンロードを続けます 17 | toastConnected: '接続中 ' 18 | toastUpdated: yt-dlpを更新しました! 19 | formatSelectionEnabler: 選択可能な動画/音源 20 | themeSelect: 'テーマ' 21 | languageSelect: '言語' 22 | overridesAnchor: 上書き 23 | pathOverrideOption: 保存するディレクトリ 24 | filenameOverrideOption: ファイル名の上書き 25 | autoFileExtensionOption: 自動ファイル拡張子 26 | customFilename: (空白の場合は元のファイル名) 27 | customPath: 保存先 28 | customArgs: yt-dlpのオプションの有効化 (最適設定にする場合) 29 | customArgsInput: yt-dlpのオプション 30 | rpcConnErr: RPCサーバーへの接続中にエラーが発生しました 31 | splashText: アクティブなダウンロードはありません 32 | archiveTitle: アーカイブ 33 | clipboardAction: URLをクリップボードにコピーしました 34 | playlistCheckbox: プレイリストをダウンロード (これには時間がかかりますが、処理中はウィンドウを閉じることができます) 35 | servedFromReverseProxyCheckbox: リバースプロキシのサブフォルダにあります 36 | newDownloadButton: 新しくダウンロード 37 | homeButtonLabel: ホーム 38 | archiveButtonLabel: アーカイブ 39 | settingsButtonLabel: 設定 40 | rpcAuthenticationLabel: RPC認証 41 | themeTogglerLabel: テーマ切り替え 42 | loadingLabel: 読み込み中... 43 | appTitle: アプリタイトル 44 | savedTemplates: 保存したテンプレート 45 | templatesEditor: テンプレートエディター 46 | templatesEditorNameLabel: テンプレート名 47 | templatesEditorContentLabel: テンプレート内容 48 | logsTitle: 'ログ' 49 | awaitingLogs: 'ログを待機中...' 50 | bulkDownload: 'ダウンロードしたファイルをZIPで保存' 51 | templatesReloadInfo: To register a new template it might need a page reload. 52 | livestreamURLInput: ライブストリームURL 53 | livestreamStatusWaiting: 開始を待っています 54 | livestreamStatusDownloading: ダウンロード中 55 | livestreamStatusCompleted: 完了 56 | livestreamStatusErrored: エラー 57 | livestreamStatusUnknown: 不明 58 | livestreamNoMonitoring: No livestreams monitored 59 | livestreamDownloadInfo: | 60 | まだ開始されていないライブストリームを監視します。各プロセスは、--wait-for-video 10 で実行されます。 61 | すでに開始されているライブストリームが提供された場合、ダウンロードは継続されますが進行状況は追跡されません。 62 | ライブストリームが開始されると、ダウンロードページに移動されます。 63 | livestreamExperimentalWarning: この機能は実験的なものです。何かが壊れるかもしれません! 64 | accentSelect: 'Accent' 65 | urlBase: URL base, for reverse proxy support (subdir), defaults to empty 66 | rpcPollingTimeTitle: RPC polling time 67 | rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side) 68 | generalDownloadSettings: 'General Download Settings' 69 | deleteCookies: Delete Cookies 70 | noFilesFound: 'No Files Found' 71 | tableView: 'Table View' 72 | deleteSelected: 'Delete selected' 73 | subscriptionsButtonLabel: 'Subscriptions' 74 | subscriptionsEmptyLabel: 'No subscriptions' 75 | subscriptionsURLInput: 'Channel URL' 76 | subscriptionsInfo: | 77 | Subscribes to a defined channel. Only the last video will be downloaded. 78 | The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank). 79 | cronExpressionLabel: 'Cron expression' 80 | editButtonLabel: 'Edit' 81 | newSubscriptionButton: New subscription -------------------------------------------------------------------------------- /server/subscription/repository/repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/google/uuid" 8 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/data" 9 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/domain" 10 | ) 11 | 12 | type Repository struct { 13 | db *sql.DB 14 | } 15 | 16 | // Delete implements domain.Repository. 17 | func (r *Repository) Delete(ctx context.Context, id string) error { 18 | conn, err := r.db.Conn(ctx) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | defer conn.Close() 24 | 25 | _, err = conn.ExecContext(ctx, "DELETE FROM subscriptions WHERE id = ?", id) 26 | 27 | return err 28 | } 29 | 30 | // GetCursor implements domain.Repository. 31 | func (r *Repository) GetCursor(ctx context.Context, id string) (int64, error) { 32 | conn, err := r.db.Conn(ctx) 33 | if err != nil { 34 | return -1, err 35 | } 36 | 37 | defer conn.Close() 38 | 39 | row := conn.QueryRowContext(ctx, "SELECT rowid FROM subscriptions WHERE id = ?", id) 40 | 41 | var rowId int64 42 | 43 | if err := row.Scan(&rowId); err != nil { 44 | return -1, err 45 | } 46 | 47 | return rowId, nil 48 | } 49 | 50 | // List implements domain.Repository. 51 | func (r *Repository) List(ctx context.Context, start int64, limit int) (*[]data.Subscription, error) { 52 | conn, err := r.db.Conn(ctx) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | defer conn.Close() 58 | 59 | var elements []data.Subscription 60 | 61 | rows, err := conn.QueryContext(ctx, "SELECT rowid, * FROM subscriptions WHERE rowid > ? LIMIT ?", start, limit) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | for rows.Next() { 67 | var rowId int64 68 | var element data.Subscription 69 | 70 | if err := rows.Scan( 71 | &rowId, 72 | &element.Id, 73 | &element.URL, 74 | &element.Params, 75 | &element.CronExpr, 76 | ); err != nil { 77 | return &elements, err 78 | } 79 | 80 | elements = append(elements, element) 81 | } 82 | 83 | return &elements, nil 84 | } 85 | 86 | // Submit implements domain.Repository. 87 | func (r *Repository) Submit(ctx context.Context, sub *data.Subscription) (*data.Subscription, error) { 88 | conn, err := r.db.Conn(ctx) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | defer conn.Close() 94 | 95 | _, err = conn.ExecContext( 96 | ctx, 97 | "INSERT INTO subscriptions (id, url, params, cron) VALUES (?, ?, ?, ?)", 98 | uuid.NewString(), 99 | sub.URL, 100 | sub.Params, 101 | sub.CronExpr, 102 | ) 103 | 104 | return sub, err 105 | } 106 | 107 | // UpdateByExample implements domain.Repository. 108 | func (r *Repository) UpdateByExample(ctx context.Context, example *data.Subscription) error { 109 | conn, err := r.db.Conn(ctx) 110 | if err != nil { 111 | return err 112 | } 113 | 114 | defer conn.Close() 115 | 116 | _, err = conn.ExecContext( 117 | ctx, 118 | "UPDATE subscriptions SET url = ?, params = ?, cron = ? WHERE id = ? OR url = ?", 119 | example.URL, 120 | example.Params, 121 | example.CronExpr, 122 | example.Id, 123 | example.URL, 124 | ) 125 | 126 | return err 127 | } 128 | 129 | func New(db *sql.DB) domain.Repository { 130 | return &Repository{ 131 | db: db, 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /server/archive/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/data" 7 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain" 8 | ) 9 | 10 | type Service struct { 11 | repository domain.Repository 12 | } 13 | 14 | func New(repository domain.Repository) domain.Service { 15 | return &Service{ 16 | repository: repository, 17 | } 18 | } 19 | 20 | // Archive implements domain.Service. 21 | func (s *Service) Archive(ctx context.Context, entity *domain.ArchiveEntry) error { 22 | return s.repository.Archive(ctx, &data.ArchiveEntry{ 23 | Id: entity.Id, 24 | Title: entity.Title, 25 | Path: entity.Path, 26 | Thumbnail: entity.Thumbnail, 27 | Source: entity.Source, 28 | Metadata: entity.Metadata, 29 | CreatedAt: entity.CreatedAt, 30 | }) 31 | } 32 | 33 | // HardDelete implements domain.Service. 34 | func (s *Service) HardDelete(ctx context.Context, id string) (*domain.ArchiveEntry, error) { 35 | res, err := s.repository.HardDelete(ctx, id) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | return &domain.ArchiveEntry{ 41 | Id: res.Id, 42 | Title: res.Title, 43 | Path: res.Path, 44 | Thumbnail: res.Thumbnail, 45 | Source: res.Source, 46 | Metadata: res.Metadata, 47 | CreatedAt: res.CreatedAt, 48 | }, nil 49 | } 50 | 51 | // SoftDelete implements domain.Service. 52 | func (s *Service) SoftDelete(ctx context.Context, id string) (*domain.ArchiveEntry, error) { 53 | res, err := s.repository.SoftDelete(ctx, id) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | return &domain.ArchiveEntry{ 59 | Id: res.Id, 60 | Title: res.Title, 61 | Path: res.Path, 62 | Thumbnail: res.Thumbnail, 63 | Source: res.Source, 64 | Metadata: res.Metadata, 65 | CreatedAt: res.CreatedAt, 66 | }, nil 67 | } 68 | 69 | // List implements domain.Service. 70 | func (s *Service) List( 71 | ctx context.Context, 72 | startRowId int, 73 | limit int, 74 | ) (*domain.PaginatedResponse[[]domain.ArchiveEntry], error) { 75 | res, err := s.repository.List(ctx, startRowId, limit) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | entities := make([]domain.ArchiveEntry, len(*res)) 81 | 82 | for i, model := range *res { 83 | entities[i] = domain.ArchiveEntry{ 84 | Id: model.Id, 85 | Title: model.Title, 86 | Path: model.Path, 87 | Thumbnail: model.Thumbnail, 88 | Source: model.Source, 89 | Metadata: model.Metadata, 90 | CreatedAt: model.CreatedAt, 91 | } 92 | } 93 | 94 | var ( 95 | first int64 96 | next int64 97 | ) 98 | 99 | if len(entities) > 0 { 100 | first, err = s.repository.GetCursor(ctx, entities[0].Id) 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | next, err = s.repository.GetCursor(ctx, entities[len(entities)-1].Id) 106 | if err != nil { 107 | return nil, err 108 | } 109 | } 110 | 111 | return &domain.PaginatedResponse[[]domain.ArchiveEntry]{ 112 | First: first, 113 | Next: next, 114 | Data: entities, 115 | }, nil 116 | } 117 | 118 | // GetCursor implements domain.Service. 119 | func (s *Service) GetCursor(ctx context.Context, id string) (int64, error) { 120 | return s.repository.GetCursor(ctx, id) 121 | } 122 | -------------------------------------------------------------------------------- /server/internal/message_queue.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log/slog" 7 | 8 | evbus "github.com/asaskevich/EventBus" 9 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 10 | "golang.org/x/sync/semaphore" 11 | ) 12 | 13 | const queueName = "process:pending" 14 | 15 | type MessageQueue struct { 16 | concurrency int 17 | eventBus evbus.Bus 18 | } 19 | 20 | // Creates a new message queue. 21 | // By default it will be created with a size equals to nthe number of logical 22 | // CPU cores -1. 23 | // The queue size can be set via the qs flag. 24 | func NewMessageQueue() (*MessageQueue, error) { 25 | qs := config.Instance().QueueSize 26 | 27 | if qs <= 0 { 28 | return nil, errors.New("invalid queue size") 29 | } 30 | 31 | return &MessageQueue{ 32 | concurrency: qs, 33 | eventBus: evbus.New(), 34 | }, nil 35 | } 36 | 37 | // Publish a message to the queue and set the task to a peding state. 38 | func (m *MessageQueue) Publish(p *Process) { 39 | // needs to have an id set before 40 | p.SetPending() 41 | 42 | m.eventBus.Publish(queueName, p) 43 | } 44 | 45 | func (m *MessageQueue) SetupConsumers() { 46 | go m.downloadConsumer() 47 | go m.metadataSubscriber() 48 | } 49 | 50 | // Setup the consumer listener which subscribes to the changes to the producer 51 | // channel and triggers the "download" action. 52 | func (m *MessageQueue) downloadConsumer() { 53 | sem := semaphore.NewWeighted(int64(m.concurrency)) 54 | 55 | m.eventBus.SubscribeAsync(queueName, func(p *Process) { 56 | sem.Acquire(context.Background(), 1) 57 | defer sem.Release(1) 58 | 59 | slog.Info("received process from event bus", 60 | slog.String("bus", queueName), 61 | slog.String("consumer", "downloadConsumer"), 62 | slog.String("id", p.getShortId()), 63 | ) 64 | 65 | if p.Progress.Status != StatusCompleted { 66 | slog.Info("started process", 67 | slog.String("bus", queueName), 68 | slog.String("id", p.getShortId()), 69 | ) 70 | if p.Livestream { 71 | // livestreams have higher priorty and they ignore the semaphore 72 | go p.Start() 73 | } else { 74 | p.Start() 75 | } 76 | } 77 | }, false) 78 | } 79 | 80 | // Setup the metadata consumer listener which subscribes to the changes to the 81 | // producer channel and adds metadata to each download. 82 | func (m *MessageQueue) metadataSubscriber() { 83 | // How many concurrent metadata fetcher jobs are spawned 84 | // Since there's ongoing downloads, 1 job at time seems a good compromise 85 | sem := semaphore.NewWeighted(1) 86 | 87 | m.eventBus.SubscribeAsync(queueName, func(p *Process) { 88 | sem.Acquire(context.Background(), 1) 89 | defer sem.Release(1) 90 | 91 | slog.Info("received process from event bus", 92 | slog.String("bus", queueName), 93 | slog.String("consumer", "metadataConsumer"), 94 | slog.String("id", p.getShortId()), 95 | ) 96 | 97 | if p.Progress.Status == StatusCompleted { 98 | slog.Warn("proccess has an illegal state", 99 | slog.String("id", p.getShortId()), 100 | slog.Int("status", p.Progress.Status), 101 | ) 102 | return 103 | } 104 | 105 | if err := p.SetMetadata(); err != nil { 106 | slog.Error("failed to retrieve metadata", 107 | slog.String("id", p.getShortId()), 108 | slog.String("err", err.Error()), 109 | ) 110 | } 111 | }, false) 112 | } 113 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "flag" 6 | "io/fs" 7 | "log" 8 | "os" 9 | "runtime" 10 | 11 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server" 12 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/cli" 13 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 14 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/openid" 15 | ) 16 | 17 | var ( 18 | host string 19 | port int 20 | queueSize int 21 | configFile string 22 | downloadPath string 23 | downloaderPath string 24 | sessionFilePath string 25 | localDatabasePath string 26 | frontendPath string 27 | 28 | requireAuth bool 29 | username string 30 | password string 31 | 32 | userFromEnv = os.Getenv("USERNAME") 33 | passFromEnv = os.Getenv("PASSWORD") 34 | 35 | logFile string 36 | enableFileLogging bool 37 | 38 | //go:embed frontend/dist/index.html 39 | //go:embed frontend/dist/assets/* 40 | frontend embed.FS 41 | 42 | //go:embed openapi/* 43 | swagger embed.FS 44 | ) 45 | 46 | func init() { 47 | flag.StringVar(&host, "host", "0.0.0.0", "Host where server will listen at") 48 | flag.IntVar(&port, "port", 3033, "Port where server will listen at") 49 | flag.IntVar(&queueSize, "qs", 2, "Queue size (concurrent downloads)") 50 | 51 | flag.StringVar(&configFile, "conf", "./config.yml", "Config file path") 52 | flag.StringVar(&downloadPath, "out", ".", "Where files will be saved") 53 | flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path") 54 | flag.StringVar(&sessionFilePath, "session", ".", "session file path") 55 | flag.StringVar(&localDatabasePath, "db", "local.db", "local database path") 56 | flag.StringVar(&frontendPath, "web", "", "frontend web resources path") 57 | 58 | flag.BoolVar(&enableFileLogging, "fl", false, "enable outputting logs to a file") 59 | flag.StringVar(&logFile, "lf", "yt-dlp-webui.log", "set log file location") 60 | 61 | flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication") 62 | flag.StringVar(&username, "user", userFromEnv, "Username required for auth") 63 | flag.StringVar(&password, "pass", passFromEnv, "Password required for auth") 64 | 65 | flag.Parse() 66 | } 67 | 68 | func main() { 69 | frontend, err := fs.Sub(frontend, "frontend/dist") 70 | if err != nil { 71 | log.Fatalln(err) 72 | } 73 | 74 | if frontendPath != "" { 75 | frontend = os.DirFS(frontendPath) 76 | } 77 | 78 | c := config.Instance() 79 | 80 | { 81 | // init the config struct with the values from flags 82 | // TODO: find an alternative way to populate the config struct from flags or config file 83 | c.Host = host 84 | c.Port = port 85 | 86 | c.QueueSize = queueSize 87 | 88 | c.DownloadPath = downloadPath 89 | c.DownloaderPath = downloaderPath 90 | c.SessionFilePath = sessionFilePath 91 | c.LocalDatabasePath = localDatabasePath 92 | 93 | c.LogPath = logFile 94 | c.EnableFileLogging = enableFileLogging 95 | 96 | c.RequireAuth = requireAuth 97 | c.Username = username 98 | c.Password = password 99 | } 100 | 101 | // limit concurrent downloads for systems with 2 or less logical cores 102 | if runtime.NumCPU() <= 2 { 103 | c.QueueSize = 1 104 | } 105 | 106 | // if config file is found it will be merged with the current config struct 107 | if err := c.LoadFile(configFile); err != nil { 108 | log.Println(cli.BgRed, "config", cli.Reset, err) 109 | } 110 | 111 | openid.Configure() 112 | 113 | server.RunBlocking(&server.RunConfig{ 114 | App: frontend, 115 | Swagger: swagger, 116 | }) 117 | } 118 | -------------------------------------------------------------------------------- /frontend/src/assets/i18n/ko.yaml: -------------------------------------------------------------------------------- 1 | keys: 2 | urlInput: YouTube나 다른 지원되는 사이트의 URL 3 | statusTitle: 상태 4 | startButton: 시작 5 | statusReady: 준비됨 6 | abortAllButton: 모두 중단 7 | updateBinButton: yt-dlp 바이너리 업데이트 8 | darkThemeButton: 다크 모드 9 | lightThemeButton: 라이트 모드 10 | settingsAnchor: 설정 11 | serverAddressTitle: 서버 주소 12 | serverPortTitle: Port 13 | extractAudioCheckbox: 오디오 추출 14 | noMTimeCheckbox: 파일 수정 시간을 설정하지 않음 15 | bgReminder: 이 페이지를 닫아도 백그라운드에서 다운로드가 계속됩니다 16 | toastConnected: '다음으로 연결됨 ' 17 | toastUpdated: yt-dlp 바이너리를 업데이트 했습니다 18 | formatSelectionEnabler: 비디오/오디오 포멧 옵션 표시 19 | themeSelect: 'Theme' 20 | languageSelect: 'Language' 21 | overridesAnchor: Overrides 22 | pathOverrideOption: Enable output path overriding 23 | filenameOverrideOption: Enable output file name overriding 24 | autoFileExtensionOption: 자동으로 파일 확장자 추가 25 | customFilename: Custom filename (leave blank to use default) 26 | customPath: Custom path 27 | customArgs: Enable custom yt-dlp args (great power = great responsabilities) 28 | customArgsInput: Custom yt-dlp arguments 29 | rpcConnErr: Error while conencting to RPC server 30 | splashText: No active downloads 31 | archiveTitle: Archive 32 | clipboardAction: Copied URL to clipboard 33 | playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) 34 | servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder 35 | newDownloadButton: New download 36 | homeButtonLabel: Home 37 | archiveButtonLabel: Archive 38 | settingsButtonLabel: Settings 39 | rpcAuthenticationLabel: RPC authentication 40 | themeTogglerLabel: Theme toggler 41 | loadingLabel: Loading... 42 | appTitle: App title 43 | savedTemplates: Saved templates 44 | templatesEditor: Templates editor 45 | templatesEditorNameLabel: Template name 46 | templatesEditorContentLabel: Template content 47 | logsTitle: 'Logs' 48 | awaitingLogs: 'Awaiting logs...' 49 | bulkDownload: 'Download files in a zip archive' 50 | templatesReloadInfo: To register a new template it might need a page reload. 51 | livestreamURLInput: Livestream URL 52 | livestreamStatusWaiting: Waiting/Wait start 53 | livestreamStatusDownloading: Downloading 54 | livestreamStatusCompleted: Completed 55 | livestreamStatusErrored: Errored 56 | livestreamStatusUnknown: Unknown 57 | livestreamNoMonitoring: No livestreams monitored 58 | livestreamDownloadInfo: | 59 | This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10. 60 | If an already started livestream is provided it will be still downloaded but its progress will not be tracked. 61 | Once started the livestream will be migrated to the downloads page. 62 | livestreamExperimentalWarning: This feature is still experimental. Something might break! 63 | accentSelect: 'Accent' 64 | urlBase: URL base, for reverse proxy support (subdir), defaults to empty 65 | rpcPollingTimeTitle: RPC polling time 66 | rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side) 67 | generalDownloadSettings: 'General Download Settings' 68 | deleteCookies: Delete Cookies 69 | noFilesFound: 'No Files Found' 70 | tableView: 'Table View' 71 | deleteSelected: 'Delete selected' 72 | subscriptionsButtonLabel: 'Subscriptions' 73 | subscriptionsEmptyLabel: 'No subscriptions' 74 | subscriptionsURLInput: 'Channel URL' 75 | subscriptionsInfo: | 76 | Subscribes to a defined channel. Only the last video will be downloaded. 77 | The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank). 78 | cronExpressionLabel: 'Cron expression' 79 | editButtonLabel: 'Edit' 80 | newSubscriptionButton: New subscription -------------------------------------------------------------------------------- /server/archive/repository/repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "os" 7 | 8 | "github.com/google/uuid" 9 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/data" 10 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/archive/domain" 11 | ) 12 | 13 | type Repository struct { 14 | db *sql.DB 15 | } 16 | 17 | func New(db *sql.DB) domain.Repository { 18 | return &Repository{ 19 | db: db, 20 | } 21 | } 22 | 23 | func (r *Repository) Archive(ctx context.Context, entry *data.ArchiveEntry) error { 24 | conn, err := r.db.Conn(ctx) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | defer conn.Close() 30 | 31 | _, err = conn.ExecContext( 32 | ctx, 33 | "INSERT INTO archive (id, title, path, thumbnail, source, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", 34 | uuid.NewString(), 35 | entry.Title, 36 | entry.Path, 37 | entry.Thumbnail, 38 | entry.Source, 39 | entry.Metadata, 40 | entry.CreatedAt, 41 | ) 42 | 43 | return err 44 | } 45 | 46 | func (r *Repository) SoftDelete(ctx context.Context, id string) (*data.ArchiveEntry, error) { 47 | conn, err := r.db.Conn(ctx) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | defer conn.Close() 53 | 54 | tx, err := conn.BeginTx(ctx, nil) 55 | if err != nil { 56 | return nil, err 57 | } 58 | defer tx.Rollback() 59 | 60 | var model data.ArchiveEntry 61 | 62 | row := tx.QueryRowContext(ctx, "SELECT * FROM archive WHERE id = ?", id) 63 | 64 | if err := row.Scan( 65 | &model.Id, 66 | &model.Title, 67 | &model.Path, 68 | &model.Thumbnail, 69 | &model.Source, 70 | &model.Metadata, 71 | &model.CreatedAt, 72 | ); err != nil { 73 | return nil, err 74 | } 75 | 76 | _, err = tx.ExecContext(ctx, "DELETE FROM archive WHERE id = ?", id) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | if err := tx.Commit(); err != nil { 82 | return nil, err 83 | } 84 | 85 | return &model, nil 86 | } 87 | 88 | func (r *Repository) HardDelete(ctx context.Context, id string) (*data.ArchiveEntry, error) { 89 | entry, err := r.SoftDelete(ctx, id) 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | if err := os.Remove(entry.Path); err != nil { 95 | return nil, err 96 | } 97 | 98 | return entry, nil 99 | } 100 | 101 | func (r *Repository) List(ctx context.Context, startRowId int, limit int) (*[]data.ArchiveEntry, error) { 102 | conn, err := r.db.Conn(ctx) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | defer conn.Close() 108 | 109 | var entries []data.ArchiveEntry 110 | 111 | // cursor based pagination 112 | rows, err := conn.QueryContext(ctx, "SELECT rowid, * FROM archive WHERE rowid > ? LIMIT ?", startRowId, limit) 113 | if err != nil { 114 | return nil, err 115 | } 116 | 117 | for rows.Next() { 118 | var rowId int64 119 | var entry data.ArchiveEntry 120 | 121 | if err := rows.Scan( 122 | &rowId, 123 | &entry.Id, 124 | &entry.Title, 125 | &entry.Path, 126 | &entry.Thumbnail, 127 | &entry.Source, 128 | &entry.Metadata, 129 | &entry.CreatedAt, 130 | ); err != nil { 131 | return &entries, err 132 | } 133 | 134 | entries = append(entries, entry) 135 | } 136 | 137 | return &entries, err 138 | } 139 | 140 | func (r *Repository) GetCursor(ctx context.Context, id string) (int64, error) { 141 | conn, err := r.db.Conn(ctx) 142 | if err != nil { 143 | return -1, err 144 | } 145 | defer conn.Close() 146 | 147 | row := conn.QueryRowContext(ctx, "SELECT rowid FROM archive WHERE id = ?", id) 148 | 149 | var rowId int64 150 | 151 | if err := row.Scan(&rowId); err != nil { 152 | return -1, err 153 | } 154 | 155 | return rowId, nil 156 | } 157 | -------------------------------------------------------------------------------- /server/subscription/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "math" 7 | 8 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/data" 9 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/domain" 10 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/subscription/task" 11 | "github.com/robfig/cron/v3" 12 | ) 13 | 14 | type Service struct { 15 | r domain.Repository 16 | runner task.TaskRunner 17 | } 18 | 19 | func New(r domain.Repository, runner task.TaskRunner) domain.Service { 20 | s := &Service{ 21 | r: r, 22 | runner: runner, 23 | } 24 | 25 | // very crude recoverer 26 | initial, _ := s.List(context.Background(), 0, math.MaxInt) 27 | if initial != nil { 28 | for _, v := range initial.Data { 29 | s.runner.Submit(&v) 30 | } 31 | } 32 | 33 | return s 34 | } 35 | 36 | func fromDB(model *data.Subscription) domain.Subscription { 37 | return domain.Subscription{ 38 | Id: model.Id, 39 | URL: model.URL, 40 | Params: model.Params, 41 | CronExpr: model.CronExpr, 42 | } 43 | } 44 | 45 | func toDB(dto *domain.Subscription) data.Subscription { 46 | return data.Subscription{ 47 | Id: dto.Id, 48 | URL: dto.URL, 49 | Params: dto.Params, 50 | CronExpr: dto.CronExpr, 51 | } 52 | } 53 | 54 | // Delete implements domain.Service. 55 | func (s *Service) Delete(ctx context.Context, id string) error { 56 | s.runner.StopTask(id) 57 | return s.r.Delete(ctx, id) 58 | } 59 | 60 | // GetCursor implements domain.Service. 61 | func (s *Service) GetCursor(ctx context.Context, id string) (int64, error) { 62 | return s.r.GetCursor(ctx, id) 63 | } 64 | 65 | // List implements domain.Service. 66 | func (s *Service) List(ctx context.Context, start int64, limit int) ( 67 | *domain.PaginatedResponse[[]domain.Subscription], 68 | error, 69 | ) { 70 | dbSubs, err := s.r.List(ctx, start, limit) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | subs := make([]domain.Subscription, len(*dbSubs)) 76 | 77 | for i, v := range *dbSubs { 78 | subs[i] = fromDB(&v) 79 | } 80 | 81 | var ( 82 | first int64 83 | next int64 84 | ) 85 | 86 | if len(subs) > 0 { 87 | first, err = s.r.GetCursor(ctx, subs[0].Id) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | next, err = s.r.GetCursor(ctx, subs[len(subs)-1].Id) 93 | if err != nil { 94 | return nil, err 95 | } 96 | } 97 | 98 | return &domain.PaginatedResponse[[]domain.Subscription]{ 99 | First: first, 100 | Next: next, 101 | Data: subs, 102 | }, nil 103 | } 104 | 105 | // Submit implements domain.Service. 106 | func (s *Service) Submit(ctx context.Context, sub *domain.Subscription) (*domain.Subscription, error) { 107 | if sub.CronExpr == "" { 108 | sub.CronExpr = "*/5 * * * *" 109 | } 110 | 111 | _, err := cron.ParseStandard(sub.CronExpr) 112 | if err != nil { 113 | return nil, errors.Join(errors.New("failed parsing cron expression"), err) 114 | } 115 | 116 | subDB, err := s.r.Submit(ctx, &data.Subscription{ 117 | URL: sub.URL, 118 | Params: sub.Params, 119 | CronExpr: sub.CronExpr, 120 | }) 121 | 122 | retval := fromDB(subDB) 123 | 124 | if err := s.runner.Submit(sub); err != nil { 125 | return nil, err 126 | } 127 | 128 | return &retval, err 129 | } 130 | 131 | // UpdateByExample implements domain.Service. 132 | func (s *Service) UpdateByExample(ctx context.Context, example *domain.Subscription) error { 133 | _, err := cron.ParseStandard(example.CronExpr) 134 | if err != nil { 135 | return errors.Join(errors.New("failed parsing cron expression"), err) 136 | } 137 | 138 | e := toDB(example) 139 | 140 | return s.r.UpdateByExample(ctx, &e) 141 | } 142 | -------------------------------------------------------------------------------- /frontend/src/router.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress } from '@mui/material' 2 | import { Suspense, lazy } from 'react' 3 | import { createHashRouter } from 'react-router-dom' 4 | import Layout from './Layout' 5 | import Terminal from './views/Terminal' 6 | 7 | const Home = lazy(() => import('./views/Home')) 8 | const Login = lazy(() => import('./views/Login')) 9 | const Twitch = lazy(() => import('./views/Twitch')) 10 | const Archive = lazy(() => import('./views/Archive')) 11 | const Settings = lazy(() => import('./views/Settings')) 12 | const LiveStream = lazy(() => import('./views/Livestream')) 13 | const Filebrowser = lazy(() => import('./views/Filebrowser')) 14 | const Subscriptions = lazy(() => import('./views/Subscriptions')) 15 | 16 | const ErrorBoundary = lazy(() => import('./components/ErrorBoundary')) 17 | 18 | export const router = createHashRouter([ 19 | { 20 | path: '/', 21 | Component: () => , 22 | children: [ 23 | { 24 | path: '/', 25 | element: ( 26 | }> 27 | 28 | 29 | ), 30 | errorElement: ( 31 | }> 32 | 33 | 34 | ) 35 | }, 36 | { 37 | path: '/settings', 38 | element: ( 39 | }> 40 | 41 | 42 | ) 43 | }, 44 | { 45 | path: '/log', 46 | element: ( 47 | }> 48 | 49 | 50 | ) 51 | }, 52 | { 53 | path: '/archive', 54 | element: ( 55 | }> 56 | 57 | 58 | ), 59 | errorElement: ( 60 | }> 61 | 62 | 63 | ) 64 | }, 65 | { 66 | path: '/filebrowser', 67 | element: ( 68 | }> 69 | 70 | 71 | ), 72 | errorElement: ( 73 | }> 74 | 75 | 76 | ) 77 | }, 78 | { 79 | path: '/subscriptions', 80 | element: ( 81 | }> 82 | 83 | 84 | ), 85 | errorElement: ( 86 | }> 87 | 88 | 89 | ) 90 | }, 91 | { 92 | path: '/login', 93 | element: ( 94 | }> 95 | 96 | 97 | ) 98 | }, 99 | { 100 | path: '/error', 101 | element: ( 102 | }> 103 | 104 | 105 | ) 106 | }, 107 | { 108 | path: '/monitor', 109 | element: ( 110 | }> 111 | 112 | 113 | ) 114 | }, 115 | { 116 | path: '/twitch', 117 | element: ( 118 | }> 119 | 120 | 121 | ) 122 | }, 123 | ] 124 | }, 125 | ]) -------------------------------------------------------------------------------- /server/internal/memory_db.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/gob" 5 | "errors" 6 | "log/slog" 7 | "os" 8 | "path/filepath" 9 | "sync" 10 | 11 | "github.com/google/uuid" 12 | "github.com/marcopiovanello/yt-dlp-web-ui/v3/server/config" 13 | ) 14 | 15 | var memDbEvents = make(chan *Process) 16 | 17 | // In-Memory Thread-Safe Key-Value Storage with optional persistence 18 | type MemoryDB struct { 19 | table map[string]*Process 20 | mu sync.RWMutex 21 | } 22 | 23 | func NewMemoryDB() *MemoryDB { 24 | return &MemoryDB{ 25 | table: make(map[string]*Process), 26 | } 27 | } 28 | 29 | // Get a process pointer given its id 30 | func (m *MemoryDB) Get(id string) (*Process, error) { 31 | m.mu.RLock() 32 | defer m.mu.RUnlock() 33 | 34 | entry, ok := m.table[id] 35 | if !ok { 36 | return nil, errors.New("no process found for the given key") 37 | } 38 | 39 | return entry, nil 40 | } 41 | 42 | // Store a pointer of a process and return its id 43 | func (m *MemoryDB) Set(process *Process) string { 44 | id := uuid.NewString() 45 | 46 | m.mu.Lock() 47 | process.Id = id 48 | m.table[id] = process 49 | m.mu.Unlock() 50 | 51 | return id 52 | } 53 | 54 | // Removes a process progress, given the process id 55 | func (m *MemoryDB) Delete(id string) { 56 | m.mu.Lock() 57 | delete(m.table, id) 58 | m.mu.Unlock() 59 | } 60 | 61 | func (m *MemoryDB) Keys() *[]string { 62 | var running []string 63 | 64 | m.mu.RLock() 65 | defer m.mu.RUnlock() 66 | 67 | for id := range m.table { 68 | running = append(running, id) 69 | } 70 | 71 | return &running 72 | } 73 | 74 | // Returns a slice of all currently stored processes progess 75 | func (m *MemoryDB) All() *[]ProcessResponse { 76 | running := []ProcessResponse{} 77 | 78 | m.mu.RLock() 79 | for k, v := range m.table { 80 | running = append(running, ProcessResponse{ 81 | Id: k, 82 | Info: v.Info, 83 | Progress: v.Progress, 84 | Output: v.Output, 85 | Params: v.Params, 86 | }) 87 | } 88 | m.mu.RUnlock() 89 | 90 | return &running 91 | } 92 | 93 | // Persist the database in a single file named "session.dat" 94 | func (m *MemoryDB) Persist() error { 95 | running := m.All() 96 | 97 | sf := filepath.Join(config.Instance().SessionFilePath, "session.dat") 98 | 99 | fd, err := os.Create(sf) 100 | if err != nil { 101 | return errors.Join(errors.New("failed to persist session"), err) 102 | } 103 | 104 | m.mu.RLock() 105 | defer m.mu.RUnlock() 106 | session := Session{Processes: *running} 107 | 108 | if err := gob.NewEncoder(fd).Encode(session); err != nil { 109 | return errors.Join(errors.New("failed to persist session"), err) 110 | } 111 | 112 | return nil 113 | } 114 | 115 | // Restore a persisted state 116 | func (m *MemoryDB) Restore(mq *MessageQueue) { 117 | sf := filepath.Join(config.Instance().SessionFilePath, "session.dat") 118 | 119 | fd, err := os.Open(sf) 120 | if err != nil { 121 | return 122 | } 123 | 124 | var session Session 125 | 126 | if err := gob.NewDecoder(fd).Decode(&session); err != nil { 127 | return 128 | } 129 | 130 | m.mu.Lock() 131 | defer m.mu.Unlock() 132 | 133 | for _, proc := range session.Processes { 134 | restored := &Process{ 135 | Id: proc.Id, 136 | Url: proc.Info.URL, 137 | Info: proc.Info, 138 | Progress: proc.Progress, 139 | Output: proc.Output, 140 | Params: proc.Params, 141 | } 142 | 143 | m.table[proc.Id] = restored 144 | 145 | if restored.Progress.Status != StatusCompleted { 146 | mq.Publish(restored) 147 | } 148 | } 149 | } 150 | 151 | func (m *MemoryDB) EventListener() { 152 | for p := range memDbEvents { 153 | if p.AutoRemove { 154 | slog.Info("compacting MemoryDB", slog.String("id", p.Id)) 155 | m.Delete(p.Id) 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /frontend/src/views/Login.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Login view component 3 | */ 4 | 5 | import styled from '@emotion/styled' 6 | import { 7 | Button, 8 | Container, 9 | Divider, 10 | Paper, 11 | Stack, 12 | TextField, 13 | Typography 14 | } from '@mui/material' 15 | import { matchW } from 'fp-ts/lib/TaskEither' 16 | import { pipe } from 'fp-ts/lib/function' 17 | import { useState } from 'react' 18 | import { useNavigate } from 'react-router-dom' 19 | import { serverURL } from '../atoms/settings' 20 | import { useToast } from '../hooks/toast' 21 | import { ffetch } from '../lib/httpClient' 22 | import { useAtomValue } from 'jotai' 23 | 24 | const LoginContainer = styled(Container)({ 25 | display: 'flex', 26 | minWidth: '100%', 27 | minHeight: '85vh', 28 | alignItems: 'center', 29 | justifyContent: 'center', 30 | }) 31 | 32 | const Title = styled(Typography)({ 33 | display: 'flex', 34 | width: '100%', 35 | alignItems: 'center', 36 | justifyContent: 'center', 37 | paddingBottom: '0.5rem' 38 | }) 39 | 40 | export default function Login() { 41 | const [username, setUsername] = useState('') 42 | const [password, setPassword] = useState('') 43 | 44 | const [formHasError, setFormHasError] = useState(false) 45 | 46 | const url = useAtomValue(serverURL) 47 | 48 | const navigate = useNavigate() 49 | 50 | const { pushMessage } = useToast() 51 | 52 | const navigateAndReload = () => { 53 | navigate('/') 54 | window.location.reload() 55 | } 56 | 57 | const login = async () => { 58 | const task = ffetch(`${url}/auth/login`, { 59 | method: 'POST', 60 | headers: { 61 | 'Content-Type': 'application/json' 62 | }, 63 | body: JSON.stringify({ 64 | username, 65 | password, 66 | }), 67 | }) 68 | 69 | pipe( 70 | task, 71 | matchW( 72 | (error) => { 73 | setFormHasError(true) 74 | pushMessage(error, 'error') 75 | }, 76 | (token) => { 77 | console.log(token) 78 | localStorage.setItem('token', token) 79 | navigateAndReload() 80 | } 81 | ) 82 | )() 83 | } 84 | 85 | const loginWithOpenId = () => window.open(`${url}/auth/openid/login`) 86 | 87 | return ( 88 | 89 | 90 | 91 | 92 | yt-dlp WebUI 93 | 94 | 95 | To configure authentication check the  96 | <a href='https://github.com/marcopiovanello/yt-dlp-web-ui/wiki/Authentication-methods'>wiki</a>. 97 | 98 | setUsername(e.currentTarget.value)} 104 | /> 105 | setPassword(e.currentTarget.value)} 111 | /> 112 | 115 | 116 | 117 | 118 | or use your authentication provider 119 | 120 | 121 | 122 | 125 | 126 | 127 | 128 | ) 129 | } -------------------------------------------------------------------------------- /frontend/src/assets/i18n/pl.yaml: -------------------------------------------------------------------------------- 1 | keys: 2 | urlInput: Adres URL YouTube lub innej obsługiwanej usługi 3 | statusTitle: Status 4 | startButton: Początek 5 | statusReady: Gotowy 6 | abortAllButton: Anuluj wszystko 7 | updateBinButton: Zaktualizuj plik binarny yt-dlp 8 | darkThemeButton: Ciemny motyw 9 | lightThemeButton: Światło motyw 10 | settingsAnchor: Ustawienia 11 | serverAddressTitle: Adres serwera 12 | serverPortTitle: Port 13 | extractAudioCheckbox: Wyodrębnij dźwięk 14 | noMTimeCheckbox: Nie ustawiaj czasu modyfikacji pliku 15 | bgReminder: Po zamknięciu tej strony pobieranie będzie kontynuowane w tle. 16 | toastConnected: 'Połączony z ' 17 | toastUpdated: Zaktualizowano plik binarny yt-dlp! 18 | formatSelectionEnabler: Aktywuj wybór formatów wideo/audio 19 | themeSelect: 'Motyw' 20 | languageSelect: 'Język' 21 | overridesAnchor: Przedefiniuj 22 | pathOverrideOption: Aktywuj zastąpienie ścieżki źródłowej 23 | filenameOverrideOption: Aktywuj zastępowanie nazwy pliku źródłowego 24 | autoFileExtensionOption: Automatyczne rozszerzenie pliku 25 | customFilename: Wprowadź nazwę pliku (pozostaw puste, aby użyć nazwy domyślnej) 26 | customPath: Ustaw ścieżkę 27 | customArgs: Uwzględnij konfigurowalne argumenty yt-dlp (wielka moc = wielka odpowiedzialność) 28 | customArgsInput: Niestandardowe argumenty yt-dlp 29 | rpcConnErr: Wystąpił błąd podczas łączenia z serwerem RPC 30 | splashText: Brak aktywnych pobrań 31 | archiveTitle: Archiwum 32 | clipboardAction: Adres URL zostanie skopiowany do schowka 33 | playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) 34 | servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder 35 | newDownloadButton: New download 36 | homeButtonLabel: Home 37 | archiveButtonLabel: Archive 38 | settingsButtonLabel: Settings 39 | rpcAuthenticationLabel: RPC authentication 40 | themeTogglerLabel: Theme toggler 41 | loadingLabel: Loading... 42 | appTitle: App title 43 | savedTemplates: Saved templates 44 | templatesEditor: Templates editor 45 | templatesEditorNameLabel: Template name 46 | templatesEditorContentLabel: Template content 47 | logsTitle: 'Logs' 48 | awaitingLogs: 'Awaiting logs...' 49 | bulkDownload: 'Download files in a zip archive' 50 | templatesReloadInfo: To register a new template it might need a page reload. 51 | livestreamURLInput: Livestream URL 52 | livestreamStatusWaiting: Waiting/Wait start 53 | livestreamStatusDownloading: Downloading 54 | livestreamStatusCompleted: Completed 55 | livestreamStatusErrored: Errored 56 | livestreamStatusUnknown: Unknown 57 | livestreamNoMonitoring: No livestreams monitored 58 | livestreamDownloadInfo: | 59 | This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10. 60 | If an already started livestream is provided it will be still downloaded but its progress will not be tracked. 61 | Once started the livestream will be migrated to the downloads page. 62 | livestreamExperimentalWarning: This feature is still experimental. Something might break! 63 | accentSelect: 'Accent' 64 | urlBase: URL base, for reverse proxy support (subdir), defaults to empty 65 | rpcPollingTimeTitle: RPC polling time 66 | rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side) 67 | generalDownloadSettings: 'General Download Settings' 68 | deleteCookies: Delete Cookies 69 | noFilesFound: 'No Files Found' 70 | tableView: 'Table View' 71 | deleteSelected: 'Delete selected' 72 | subscriptionsButtonLabel: 'Subscriptions' 73 | subscriptionsEmptyLabel: 'No subscriptions' 74 | subscriptionsURLInput: 'Channel URL' 75 | subscriptionsInfo: | 76 | Subscribes to a defined channel. Only the last video will be downloaded. 77 | The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank). 78 | cronExpressionLabel: 'Cron expression' 79 | editButtonLabel: 'Edit' 80 | newSubscriptionButton: New subscription -------------------------------------------------------------------------------- /frontend/src/assets/i18n/ca.yaml: -------------------------------------------------------------------------------- 1 | keys: 2 | urlInput: URL de YouTube o d'un altre servei compatible 3 | statusTitle: Estat 4 | startButton: Iniciar 5 | statusReady: Llest 6 | abortAllButton: Cancel·lar Tot 7 | updateBinButton: Actualitzar el binari yt-dlp 8 | darkThemeButton: Tema fosc 9 | lightThemeButton: Tema clar 10 | settingsAnchor: Configuració 11 | serverAddressTitle: Direcció del servidor 12 | serverPortTitle: Port 13 | extractAudioCheckbox: Extreure àudio 14 | noMTimeCheckbox: No guardar el temps de modificació de l'arxiu 15 | bgReminder: Si tanques aquesta pàgina, la descàrrega continuarà en segon pla. 16 | toastConnected: 'Connectat a' 17 | toastUpdated: El binari yt-dlp està actualitzat! 18 | formatSelectionEnabler: Habilitar la selecció de formats de vídeo/àudio 19 | themeSelect: 'Tema' 20 | languageSelect: 'Idiomes' 21 | overridesAnchor: Anul·lacions 22 | pathOverrideOption: Sobreescriure en la ruta de sortida 23 | filenameOverrideOption: Sobreescriure el nom del fitxer 24 | autoFileExtensionOption: Afegeix l'extensió de fitxer automàticament 25 | customFilename: Nom d'arxiu personalitzat (en blanc per utilitzar el predeterminat) 26 | customPath: Ruta personalitzada 27 | customArgs: Habilitar els arguments yt-dlp personalitzats (un gran poder comporta una gran responsabilitat) 28 | customArgsInput: Arguments yt-dlp personalitzats 29 | rpcConnErr: Error en connectar-se al servidor RPC 30 | splashText: No active downloads 31 | archiveTitle: Archive 32 | clipboardAction: Copied URL to clipboard 33 | playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) 34 | servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder 35 | newDownloadButton: New download 36 | homeButtonLabel: Home 37 | archiveButtonLabel: Archive 38 | settingsButtonLabel: Settings 39 | rpcAuthenticationLabel: RPC authentication 40 | themeTogglerLabel: Theme toggler 41 | loadingLabel: Loading... 42 | appTitle: App title 43 | savedTemplates: Saved templates 44 | templatesEditor: Templates editor 45 | templatesEditorNameLabel: Template name 46 | templatesEditorContentLabel: Template content 47 | logsTitle: 'Logs' 48 | awaitingLogs: 'Awaiting logs...' 49 | bulkDownload: 'Download files in a zip archive' 50 | templatesReloadInfo: To register a new template it might need a page reload. 51 | livestreamURLInput: Livestream URL 52 | livestreamStatusWaiting: Waiting/Wait start 53 | livestreamStatusDownloading: Downloading 54 | livestreamStatusCompleted: Completed 55 | livestreamStatusErrored: Errored 56 | livestreamStatusUnknown: Unknown 57 | livestreamNoMonitoring: No livestreams monitored 58 | livestreamDownloadInfo: | 59 | This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10. 60 | If an already started livestream is provided it will be still downloaded but its progress will not be tracked. 61 | Once started the livestream will be migrated to the downloads page. 62 | livestreamExperimentalWarning: This feature is still experimental. Something might break! 63 | accentSelect: 'Accent' 64 | urlBase: URL base, for reverse proxy support (subdir), defaults to empty 65 | rpcPollingTimeTitle: RPC polling time 66 | rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side) 67 | generalDownloadSettings: 'Ajustes generales de descarga' 68 | deleteCookies: Delete Cookies 69 | noFilesFound: 'No Files Found' 70 | tableView: 'Table View' 71 | deleteSelected: 'Delete selected' 72 | subscriptionsButtonLabel: 'Subscriptions' 73 | subscriptionsEmptyLabel: 'No subscriptions' 74 | subscriptionsURLInput: 'Channel URL' 75 | subscriptionsInfo: | 76 | Subscribes to a defined channel. Only the last video will be downloaded. 77 | The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank). 78 | cronExpressionLabel: 'Cron expression' 79 | editButtonLabel: 'Edit' 80 | newSubscriptionButton: New subscription -------------------------------------------------------------------------------- /frontend/src/assets/i18n/ru.yaml: -------------------------------------------------------------------------------- 1 | keys: 2 | urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса 3 | statusTitle: Статус 4 | startButton: Начать 5 | statusReady: Готово 6 | abortAllButton: Прервать все 7 | updateBinButton: Обновить бинарный файл yt-dlp 8 | darkThemeButton: Темная тема 9 | lightThemeButton: Светлая тема 10 | settingsAnchor: Настройки 11 | serverAddressTitle: Адрес сервера 12 | serverPortTitle: Порт 13 | extractAudioCheckbox: Извлечь аудио 14 | noMTimeCheckbox: Не устанавливать время модификации файла 15 | bgReminder: Как только вы закроете эту страницу, загрузка продолжится в фоновом режиме. 16 | toastConnected: 'Подключен к ' 17 | toastUpdated: Бинарный файл yt-dlp обновлен! 18 | formatSelectionEnabler: Активировать выбор видео/аудио форматов 19 | themeSelect: 'Тема' 20 | languageSelect: 'Язык' 21 | overridesAnchor: Переопределить 22 | pathOverrideOption: Активировать переопределение выходного пути 23 | filenameOverrideOption: Активировать переопределение имени выходного файла 24 | autoFileExtensionOption: Автоматическое расширение файла 25 | customFilename: Задать имя файла (оставьте пустым, чтобы использовать значение по умолчанию) 26 | customPath: Задать путь 27 | customArgs: Включить настраиваемые аргументы yt-dlp (большая сила = большая ответственность) 28 | customArgsInput: Пользовательские аргументы yt-dlp 29 | rpcConnErr: Ошибка при подключении к серверу RPC 30 | splashText: Нет активных загрузок 31 | archiveTitle: Архив 32 | clipboardAction: URL скопирован в буфер обмена 33 | playlistCheckbox: Скачать плейлист. Это займет время, после отправки вы сможете закрыть окно 34 | servedFromReverseProxyCheckbox: Находится за обратным прокси 35 | newDownloadButton: Новая загрузка 36 | homeButtonLabel: Home 37 | archiveButtonLabel: Архив 38 | settingsButtonLabel: Настройки 39 | rpcAuthenticationLabel: RPC-аутентификация 40 | themeTogglerLabel: Переключить тему 41 | loadingLabel: Загрузка... 42 | appTitle: Название приложения 43 | savedTemplates: Сохраненные шаблоны 44 | templatesEditor: Редактор шаблонов 45 | templatesEditorNameLabel: Имя шаблона 46 | templatesEditorContentLabel: Содержание шаблона 47 | logsTitle: 'Логи' 48 | awaitingLogs: 'Ожидание логов...' 49 | bulkDownload: 'Скачать файлы в zip архиве' 50 | templatesReloadInfo: To register a new template it might need a page reload. 51 | livestreamURLInput: Livestream URL 52 | livestreamStatusWaiting: Waiting/Wait start 53 | livestreamStatusDownloading: Downloading 54 | livestreamStatusCompleted: Completed 55 | livestreamStatusErrored: Errored 56 | livestreamStatusUnknown: Unknown 57 | livestreamNoMonitoring: No livestreams monitored 58 | livestreamDownloadInfo: | 59 | This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10. 60 | If an already started livestream is provided it will be still downloaded but its progress will not be tracked. 61 | Once started the livestream will be migrated to the downloads page. 62 | livestreamExperimentalWarning: This feature is still experimental. Something might break! 63 | accentSelect: 'Accent' 64 | urlBase: URL base, for reverse proxy support (subdir), defaults to empty 65 | rpcPollingTimeTitle: RPC polling time 66 | rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side) 67 | generalDownloadSettings: 'General Download Settings' 68 | deleteCookies: Delete Cookies 69 | noFilesFound: 'No Files Found' 70 | tableView: 'Table View' 71 | deleteSelected: 'Delete selected' 72 | subscriptionsButtonLabel: 'Subscriptions' 73 | subscriptionsEmptyLabel: 'No subscriptions' 74 | subscriptionsURLInput: 'Channel URL' 75 | subscriptionsInfo: | 76 | Subscribes to a defined channel. Only the last video will be downloaded. 77 | The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank). 78 | cronExpressionLabel: 'Cron expression' 79 | editButtonLabel: 'Edit' 80 | newSubscriptionButton: New subscription -------------------------------------------------------------------------------- /frontend/src/assets/i18n/es.yaml: -------------------------------------------------------------------------------- 1 | keys: 2 | urlInput: URL de YouTube u otro servicio compatible 3 | statusTitle: Estado 4 | startButton: Iniciar 5 | statusReady: Listo 6 | abortAllButton: Cancelar Todo 7 | updateBinButton: Actualizar el binario yt-dlp 8 | darkThemeButton: Tema oscuro 9 | lightThemeButton: Tema claro 10 | settingsAnchor: Ajustes 11 | serverAddressTitle: Dirección del servidor 12 | serverPortTitle: Puerto 13 | extractAudioCheckbox: Extraer audio 14 | noMTimeCheckbox: No guardar el tiempo de modificación del archivo 15 | bgReminder: Si cierras esta página, la descarga continuará en segundo plano. 16 | toastConnected: 'Conectado a' 17 | toastUpdated: ¡El binario yt-dlp está actualizado! 18 | formatSelectionEnabler: Habilitar la selección de formatos de video/audio 19 | themeSelect: 'Tema' 20 | languageSelect: 'Idiomas' 21 | overridesAnchor: Anulaciones 22 | pathOverrideOption: Sobreescribir en la ruta de salida 23 | filenameOverrideOption: Sobreescribir el nombre del fichero 24 | autoFileExtensionOption: Agregar extensión de archivo automáticamente 25 | customFilename: Nombre de archivo personalizado (en blanco para usar el predeterminado) 26 | customPath: Ruta personalizada 27 | customArgs: Habilitar los argumentos yt-dlp personalizados (un gran poder conlleva una gran responsabilidad) 28 | customArgsInput: Argumentos yt-dlp personalizados 29 | rpcConnErr: Error al conectarse al servidor RPC 30 | splashText: No active downloads 31 | archiveTitle: Archive 32 | clipboardAction: Copied URL to clipboard 33 | playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window) 34 | servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder 35 | newDownloadButton: New download 36 | homeButtonLabel: Home 37 | archiveButtonLabel: Archive 38 | settingsButtonLabel: Settings 39 | rpcAuthenticationLabel: RPC authentication 40 | themeTogglerLabel: Theme toggler 41 | loadingLabel: Loading... 42 | appTitle: App title 43 | savedTemplates: Saved templates 44 | templatesEditor: Templates editor 45 | templatesEditorNameLabel: Template name 46 | templatesEditorContentLabel: Template content 47 | logsTitle: 'Logs' 48 | awaitingLogs: 'Awaiting logs...' 49 | bulkDownload: 'Download files in a zip archive' 50 | templatesReloadInfo: To register a new template it might need a page reload. 51 | livestreamURLInput: Livestream URL 52 | livestreamStatusWaiting: Waiting/Wait start 53 | livestreamStatusDownloading: Downloading 54 | livestreamStatusCompleted: Completed 55 | livestreamStatusErrored: Errored 56 | livestreamStatusUnknown: Unknown 57 | livestreamNoMonitoring: No livestreams monitored 58 | livestreamDownloadInfo: | 59 | This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10. 60 | If an already started livestream is provided it will be still downloaded but its progress will not be tracked. 61 | Once started the livestream will be migrated to the downloads page. 62 | livestreamExperimentalWarning: This feature is still experimental. Something might break! 63 | accentSelect: 'Accent' 64 | urlBase: URL base, for reverse proxy support (subdir), defaults to empty 65 | rpcPollingTimeTitle: RPC polling time 66 | rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side) 67 | generalDownloadSettings: 'General Download Settings' 68 | deleteCookies: Delete Cookies 69 | noFilesFound: 'No Files Found' 70 | tableView: 'Table View' 71 | deleteSelected: 'Delete selected' 72 | subscriptionsButtonLabel: 'Subscriptions' 73 | subscriptionsEmptyLabel: 'No subscriptions' 74 | subscriptionsURLInput: 'Channel URL' 75 | subscriptionsInfo: | 76 | Subscribes to a defined channel. Only the last video will be downloaded. 77 | The monitor job will be scheduled/triggered by a defined cron expression (defaults to every 5 minutes if left blank). 78 | cronExpressionLabel: 'Cron expression' 79 | editButtonLabel: 'Edit' 80 | newSubscriptionButton: New subscription --------------------------------------------------------------------------------