diff --git a/app/main.py b/app/main.py index a2d87f6..d6dd0c6 100644 --- a/app/main.py +++ b/app/main.py @@ -223,23 +223,41 @@ async def add(request): raise web.HTTPBadRequest() format = post.get('format') folder = post.get('folder') - custom_name_prefix = post.get('custom_name_prefix') - playlist_strict_mode = post.get('playlist_strict_mode') - playlist_item_limit = post.get('playlist_item_limit') - auto_start = post.get('auto_start') - - if custom_name_prefix is None: - custom_name_prefix = '' - if auto_start is None: - auto_start = True - if playlist_strict_mode is None: - playlist_strict_mode = config.DEFAULT_OPTION_PLAYLIST_STRICT_MODE - if playlist_item_limit is None: + custom_name_prefix = post.get('custom_name_prefix') + custom_name = post.get('custom_name') + playlist_strict_mode = post.get('playlist_strict_mode') + playlist_item_limit = post.get('playlist_item_limit') + auto_start = post.get('auto_start') + + if custom_name_prefix is None: + custom_name_prefix = '' + if custom_name is not None and not isinstance(custom_name, str): + log.error("Bad request: custom_name must be a string") + return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'invalid custom name'})) + sanitized_custom_name = '' + if isinstance(custom_name, str): + sanitized_custom_name = custom_name.strip() + if sanitized_custom_name: + if any(sep in sanitized_custom_name for sep in ('/', '\\')) or '..' in sanitized_custom_name: + log.error("Bad request: custom_name contains invalid characters") + return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'invalid custom name'})) + dot_index = sanitized_custom_name.rfind('.') + if dot_index > 0: + sanitized_custom_name = sanitized_custom_name[:dot_index] + sanitized_custom_name = sanitized_custom_name.strip() + if not sanitized_custom_name: + log.error("Bad request: custom_name missing after sanitization") + return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'invalid custom name'})) + if auto_start is None: + auto_start = True + if playlist_strict_mode is None: + playlist_strict_mode = config.DEFAULT_OPTION_PLAYLIST_STRICT_MODE + if playlist_item_limit is None: playlist_item_limit = config.DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT playlist_item_limit = int(playlist_item_limit) - status = await dqueue.add(url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start) + status = await dqueue.add(url, quality, format, folder, custom_name_prefix, sanitized_custom_name, playlist_strict_mode, playlist_item_limit, auto_start) return web.Response(text=serializer.encode(status)) @routes.post(config.URL_PREFIX + 'delete') diff --git a/app/ytdl.py b/app/ytdl.py index 4baa5d6..66369b9 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -1,9 +1,10 @@ -import os -import yt_dlp -from collections import OrderedDict -import shelve -import time -import asyncio +import os +import glob +import yt_dlp +from collections import OrderedDict +import shelve +import time +import asyncio import multiprocessing import logging import re @@ -33,20 +34,21 @@ class DownloadQueueNotifier: async def renamed(self, dl): raise NotImplementedError -class DownloadInfo: - def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit): - self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}' - self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}' - self.url = url - self.quality = quality - self.format = format - self.folder = folder - self.custom_name_prefix = custom_name_prefix - self.msg = self.percent = self.speed = self.eta = None - self.status = "pending" - self.size = None - self.timestamp = time.time_ns() - self.error = error +class DownloadInfo: + def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, custom_name, error, entry, playlist_item_limit): + self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}' + self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}' + self.url = url + self.quality = quality + self.format = format + self.folder = folder + self.custom_name_prefix = custom_name_prefix + self.custom_name = custom_name + self.msg = self.percent = self.speed = self.eta = None + self.status = "pending" + self.size = None + self.timestamp = time.time_ns() + self.error = error self.entry = entry self.playlist_item_limit = playlist_item_limit @@ -332,16 +334,24 @@ class DownloadQueue: dldirectory = base_directory return dldirectory, None - async def __add_download(self, dl, auto_start): - dldirectory, error_message = self.__calc_download_path(dl.quality, dl.format, dl.folder) - if error_message is not None: - return error_message - output = self.config.OUTPUT_TEMPLATE if len(dl.custom_name_prefix) == 0 else f'{dl.custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}' - output_chapter = self.config.OUTPUT_TEMPLATE_CHAPTER - entry = getattr(dl, 'entry', None) - if entry is not None and 'playlist' in entry and entry['playlist'] is not None: - if len(self.config.OUTPUT_TEMPLATE_PLAYLIST): - output = self.config.OUTPUT_TEMPLATE_PLAYLIST + async def __add_download(self, dl, auto_start): + dldirectory, error_message = self.__calc_download_path(dl.quality, dl.format, dl.folder) + if error_message is not None: + return error_message + custom_name = getattr(dl, 'custom_name', '') or '' + if custom_name: + conflict = self.__check_custom_name_conflict(dldirectory, custom_name) + if conflict is not None: + log.info(f'Custom name conflict for download {dl.url} at {dldirectory} name "{custom_name}"') + return conflict + output = f'{custom_name}.%(ext)s' + else: + output = self.config.OUTPUT_TEMPLATE if len(dl.custom_name_prefix) == 0 else f'{dl.custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}' + output_chapter = self.config.OUTPUT_TEMPLATE_CHAPTER + entry = getattr(dl, 'entry', None) + if entry is not None and 'playlist' in entry and entry['playlist'] is not None: + if len(self.config.OUTPUT_TEMPLATE_PLAYLIST): + output = self.config.OUTPUT_TEMPLATE_PLAYLIST for property, value in entry.items(): if property.startswith("playlist"): output = output.replace(f"%({property})s", str(value)) @@ -356,14 +366,24 @@ class DownloadQueue: asyncio.create_task(self.__start_download(download)) else: self.pending.put(download) - await self.notifier.added(dl) - - async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already): - if not entry: - return {'status': 'error', 'msg': "Invalid/empty data was given."} - - error = None - if "live_status" in entry and "release_timestamp" in entry and entry.get("live_status") == "is_upcoming": + await self.notifier.added(dl) + + def __check_custom_name_conflict(self, directory, custom_name): + base_path = os.path.join(directory, custom_name) + if os.path.exists(base_path): + return {'status': 'error', 'msg': 'custom filename already exists'} + pattern = os.path.join(directory, f'{custom_name}.*') + for candidate in glob.glob(pattern): + if os.path.isfile(candidate): + return {'status': 'error', 'msg': 'custom filename already exists'} + return None + + async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, custom_name, playlist_strict_mode, playlist_item_limit, auto_start, already): + if not entry: + return {'status': 'error', 'msg': "Invalid/empty data was given."} + + error = None + if "live_status" in entry and "release_timestamp" in entry and entry.get("live_status") == "is_upcoming": dt_ts = datetime.fromtimestamp(entry.get("release_timestamp")).strftime('%Y-%m-%d %H:%M:%S %z') error = f"Live stream is scheduled to start at {dt_ts}" else: @@ -374,13 +394,15 @@ class DownloadQueue: if etype.startswith('url'): log.debug('Processing as an url') - return await self.add(entry['url'], quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already) - elif etype == 'playlist': - log.debug('Processing as a playlist') - entries = entry['entries'] - log.info(f'playlist detected with {len(entries)} entries') - playlist_index_digits = len(str(len(entries))) - results = [] + return await self.add(entry['url'], quality, format, folder, custom_name_prefix, custom_name, playlist_strict_mode, playlist_item_limit, auto_start, already) + elif etype == 'playlist': + if custom_name: + return {'status': 'error', 'msg': 'custom name is only supported for single downloads'} + log.debug('Processing as a playlist') + entries = entry['entries'] + log.info(f'playlist detected with {len(entries)} entries') + playlist_index_digits = len(str(len(entries))) + results = [] if playlist_item_limit > 0: log.info(f'Playlist item limit is set. Processing only first {playlist_item_limit} entries') entries = entries[:playlist_item_limit] @@ -395,28 +417,30 @@ class DownloadQueue: if any(res['status'] == 'error' for res in results): return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)} return {'status': 'ok'} - elif etype == 'video' or (etype.startswith('url') and 'id' in entry and 'title' in entry): - log.debug('Processing as a video') - key = entry.get('webpage_url') or entry['url'] - if not self.queue.exists(key): - dl = DownloadInfo(entry['id'], entry.get('title') or entry['id'], key, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit) - await self.__add_download(dl, auto_start) - return {'status': 'ok'} - return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'} - - async def add(self, url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start=True, already=None): - log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {playlist_strict_mode=} {playlist_item_limit=} {auto_start=}') - already = set() if already is None else already - if url in already: - log.info('recursion detected, skipping') - return {'status': 'ok'} + elif etype == 'video' or (etype.startswith('url') and 'id' in entry and 'title' in entry): + log.debug('Processing as a video') + key = entry.get('webpage_url') or entry['url'] + if not self.queue.exists(key): + dl = DownloadInfo(entry['id'], entry.get('title') or entry['id'], key, quality, format, folder, custom_name_prefix, custom_name, error, entry, playlist_item_limit) + result = await self.__add_download(dl, auto_start) + if isinstance(result, dict) and result.get('status') == 'error': + return result + return {'status': 'ok'} + return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'} + + async def add(self, url, quality, format, folder, custom_name_prefix, custom_name, playlist_strict_mode, playlist_item_limit, auto_start=True, already=None): + log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {custom_name=} {playlist_strict_mode=} {playlist_item_limit=} {auto_start=}') + already = set() if already is None else already + if url in already: + log.info('recursion detected, skipping') + return {'status': 'ok'} else: already.add(url) try: entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url, playlist_strict_mode) except yt_dlp.utils.YoutubeDLError as exc: return {'status': 'error', 'msg': str(exc)} - return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already) + return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, custom_name, playlist_strict_mode, playlist_item_limit, auto_start, already) async def start_pending(self, ids): for id in ids: diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html index 426d077..e50d76f 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -91,6 +91,22 @@ +
+
+ + + The extension is added automatically; avoid dots or slashes. +
+
diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index 7419ce6..0671020 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -33,6 +33,7 @@ export class AppComponent implements AfterViewInit { format: string; folder: string; customNamePrefix: string; + customName: string = ''; autoStart: boolean; playlistStrictMode: boolean; playlistItemLimit: number; @@ -270,14 +271,31 @@ export class AppComponent implements AfterViewInit { playlistStrictMode = playlistStrictMode ?? this.playlistStrictMode playlistItemLimit = playlistItemLimit ?? this.playlistItemLimit autoStart = autoStart ?? this.autoStart + let sanitizedCustomName = ''; + if (this.customName && this.customName.trim()) { + const raw = this.customName.trim(); + if (/[\\/]/.test(raw) || raw.includes('..')) { + alert('Name cannot include path separators or "..".'); + return; + } + sanitizedCustomName = this.getFilenameStem(raw); + if (!sanitizedCustomName) { + alert('Invalid name provided.'); + return; + } + this.customName = sanitizedCustomName; + } else { + this.customName = ''; + } - console.debug('Downloading: url='+url+' quality='+quality+' format='+format+' folder='+folder+' customNamePrefix='+customNamePrefix+' playlistStrictMode='+playlistStrictMode+' playlistItemLimit='+playlistItemLimit+' autoStart='+autoStart); + console.debug('Downloading: url='+url+' quality='+quality+' format='+format+' folder='+folder+' customNamePrefix='+customNamePrefix+' customName='+sanitizedCustomName+' playlistStrictMode='+playlistStrictMode+' playlistItemLimit='+playlistItemLimit+' autoStart='+autoStart); this.addInProgress = true; - this.downloads.add(url, quality, format, folder, customNamePrefix, playlistStrictMode, playlistItemLimit, autoStart).subscribe((status: Status) => { + this.downloads.add(url, quality, format, folder, customNamePrefix, playlistStrictMode, playlistItemLimit, autoStart, sanitizedCustomName).subscribe((status: Status) => { if (status.status === 'error') { alert(`Error adding URL: ${status.msg}`); } else { this.addUrl = ''; + this.customName = ''; } this.addInProgress = false; }); @@ -479,7 +497,7 @@ export class AppComponent implements AfterViewInit { this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`; // Now pass the selected quality, format, folder, etc. to the add() method this.downloads.add(url, this.quality, this.format, this.folder, this.customNamePrefix, - this.playlistStrictMode, this.playlistItemLimit, this.autoStart) + this.playlistStrictMode, this.playlistItemLimit, this.autoStart, undefined) .subscribe({ next: (status: Status) => { if (status.status === 'error') { diff --git a/ui/src/app/downloads.service.ts b/ui/src/app/downloads.service.ts index d64e1e0..7f225ed 100644 --- a/ui/src/app/downloads.service.ts +++ b/ui/src/app/downloads.service.ts @@ -26,6 +26,7 @@ export interface Download { speed: number; eta: number; filename: string; + custom_name?: string; checked?: boolean; deleting?: boolean; } @@ -125,8 +126,12 @@ export class DownloadsService { return of({status: 'error', msg: msg}) } - public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, playlistStrictMode: boolean, playlistItemLimit: number, autoStart: boolean) { - return this.http.post('add', {url: url, quality: quality, format: format, folder: folder, custom_name_prefix: customNamePrefix, playlist_strict_mode: playlistStrictMode, playlist_item_limit: playlistItemLimit, auto_start: autoStart}).pipe( + public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, playlistStrictMode: boolean, playlistItemLimit: number, autoStart: boolean, customName?: string) { + const payload: Record = {url: url, quality: quality, format: format, folder: folder, custom_name_prefix: customNamePrefix, playlist_strict_mode: playlistStrictMode, playlist_item_limit: playlistItemLimit, auto_start: autoStart}; + if (customName) { + payload.custom_name = customName; + } + return this.http.post('add', payload).pipe( catchError(this.handleHTTPError) ); }