From 54a7bf20dfb8a6ed7705abe73c1b9f20da8ec74e Mon Sep 17 00:00:00 2001 From: ddmoney420 Date: Tue, 3 Mar 2026 15:07:42 -0700 Subject: [PATCH] Add cookie file upload from the web UI (closes #536) Upload a cookies.txt directly from Advanced Options for authenticated downloads (e.g. private YouTube videos, age-restricted content). File is stored in the state directory and passed to yt-dlp automatically. Includes status indicator and delete button. Max 1MB file size. Co-Authored-By: Claude Opus 4.6 --- app/main.py | 40 ++++++++++++++++++++++++ ui/src/app/app.html | 18 +++++++++++ ui/src/app/app.ts | 32 ++++++++++++++++++- ui/src/app/services/downloads.service.ts | 20 ++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 5fe4bab..ee23c5a 100644 --- a/app/main.py +++ b/app/main.py @@ -331,6 +331,44 @@ async def start(request): status = await dqueue.start_pending(ids) return web.Response(text=serializer.encode(status)) +@routes.post(config.URL_PREFIX + 'upload-cookies') +async def upload_cookies(request): + reader = await request.multipart() + field = await reader.next() + if field is None or field.name != 'cookies': + return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'No cookies file provided'})) + cookies_path = os.path.join(config.STATE_DIR, 'cookies.txt') + size = 0 + with open(cookies_path, 'wb') as f: + while True: + chunk = await field.read_chunk() + if not chunk: + break + size += len(chunk) + if size > 1_000_000: # 1MB limit + os.remove(cookies_path) + return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'Cookie file too large (max 1MB)'})) + f.write(chunk) + # Tell yt-dlp to use the cookies file + config.YTDL_OPTIONS['cookiefile'] = cookies_path + log.info(f'Cookies file uploaded ({size} bytes)') + return web.Response(text=serializer.encode({'status': 'ok', 'msg': f'Cookies uploaded ({size} bytes)'})) + +@routes.post(config.URL_PREFIX + 'delete-cookies') +async def delete_cookies(request): + cookies_path = os.path.join(config.STATE_DIR, 'cookies.txt') + if os.path.exists(cookies_path): + os.remove(cookies_path) + config.YTDL_OPTIONS.pop('cookiefile', None) + log.info('Cookies file deleted') + return web.Response(text=serializer.encode({'status': 'ok'})) + +@routes.get(config.URL_PREFIX + 'cookie-status') +async def cookie_status(request): + cookies_path = os.path.join(config.STATE_DIR, 'cookies.txt') + exists = os.path.exists(cookies_path) + return web.Response(text=serializer.encode({'status': 'ok', 'has_cookies': exists})) + @routes.get(config.URL_PREFIX + 'history') async def history(request): history = { 'done': [], 'queue': [], 'pending': []} @@ -451,6 +489,8 @@ async def add_cors(request): app.router.add_route('OPTIONS', config.URL_PREFIX + 'add', add_cors) app.router.add_route('OPTIONS', config.URL_PREFIX + 'cancel-add', add_cors) +app.router.add_route('OPTIONS', config.URL_PREFIX + 'upload-cookies', add_cors) +app.router.add_route('OPTIONS', config.URL_PREFIX + 'delete-cookies', add_cors) async def on_prepare(request, response): if 'Origin' in request.headers: diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 348c0c7..9c33b5a 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -228,6 +228,24 @@ +
+
+ + + @if (hasCookies) { + Active + + } +
+
Items Limit diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 18e62a0..6e7e1bc 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -6,7 +6,7 @@ import { FormsModule } from '@angular/forms'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgSelectModule } from '@ng-select/ng-select'; -import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight } from '@fortawesome/free-solid-svg-icons'; +import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faUpload } from '@fortawesome/free-solid-svg-icons'; import { faGithub } from '@fortawesome/free-brands-svg-icons'; import { CookieService } from 'ngx-cookie-service'; import { DownloadsService } from './services/downloads.service'; @@ -47,6 +47,8 @@ export class App implements AfterViewInit, OnInit { customNamePrefix!: string; customFilename = ''; trackNumbering = false; + hasCookies = false; + cookieUploadInProgress = false; autoStart: boolean; playlistItemLimit!: number; splitByChapters: boolean; @@ -110,6 +112,7 @@ export class App implements AfterViewInit, OnInit { faSortAmountDown = faSortAmountDown; faSortAmountUp = faSortAmountUp; faChevronRight = faChevronRight; + faUpload = faUpload; subtitleFormats = [ { id: 'srt', text: 'SRT' }, { id: 'txt', text: 'TXT (Text only)' }, @@ -208,6 +211,9 @@ export class App implements AfterViewInit, OnInit { } ngOnInit() { + this.downloads.getCookieStatus().subscribe(data => { + this.hasCookies = data?.has_cookies || false; + }); this.getConfiguration(); this.getYtdlOptionsUpdateTime(); this.customDirs$ = this.getMatchingCustomDir(); @@ -793,6 +799,30 @@ export class App implements AfterViewInit, OnInit { return this.expandedErrors.has(id); } + onCookieFileSelect(event: Event) { + const input = event.target as HTMLInputElement; + if (\!input.files?.length) return; + this.cookieUploadInProgress = true; + this.downloads.uploadCookies(input.files[0]).subscribe({ + next: () => { + this.hasCookies = true; + this.cookieUploadInProgress = false; + input.value = ''; + }, + error: () => { + this.cookieUploadInProgress = false; + input.value = ''; + } + }); + } + + deleteCookies() { + this.downloads.deleteCookies().subscribe({ + next: () => { this.hasCookies = false; }, + error: () => {} + }); + } + private updateMetrics() { this.activeDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'downloading' || d.status === 'preparing').length; this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length; diff --git a/ui/src/app/services/downloads.service.ts b/ui/src/app/services/downloads.service.ts index 2c7689c..4bb4b4a 100644 --- a/ui/src/app/services/downloads.service.ts +++ b/ui/src/app/services/downloads.service.ts @@ -221,4 +221,24 @@ export class DownloadsService { catchError(this.handleHTTPError) ); } + + uploadCookies(file: File) { + const formData = new FormData(); + formData.append('cookies', file); + return this.http.post('upload-cookies', formData).pipe( + catchError(this.handleHTTPError) + ); + } + + deleteCookies() { + return this.http.post('delete-cookies', {}).pipe( + catchError(this.handleHTTPError) + ); + } + + getCookieStatus() { + return this.http.get('cookie-status').pipe( + catchError(this.handleHTTPError) + ); + } }