diff --git a/app/main.py b/app/main.py index 73132a7..bc09469 100644 --- a/app/main.py +++ b/app/main.py @@ -220,12 +220,15 @@ async def add(request): format = post.get('format') folder = post.get('folder') 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 None: + custom_name = '' if auto_start is None: auto_start = True if playlist_strict_mode is None: @@ -235,7 +238,7 @@ async def add(request): 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, 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 2c241cb..57873d5 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -14,6 +14,24 @@ from datetime import datetime log = logging.getLogger('ytdl') +def sanitize_custom_input(input): + # Remove or replace dangerous characters + # Path separators and traversal patterns + input = input.replace('/', '_') + input = input.replace('\\', '_') + input = input.replace('..', '_') + + # Command injection patterns + input = re.sub(r'[;&|`$(){}[\]<>]', '_', input) + + # Control characters and other problematic characters + input = re.sub(r'[\x00-\x1f\x7f-\x9f]', '_', input) + + # Replace multiple underscores with single underscore + input = re.sub(r'_+', '_', input) + + return input + class DownloadQueueNotifier: async def added(self, dl): raise NotImplementedError @@ -31,14 +49,30 @@ class DownloadQueueNotifier: 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}' + def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, custom_name, error, entry, playlist_item_limit): + # Sanitize custom inputs to prevent path traversal and command injection attacks + if custom_name: + custom_name = sanitize_custom_input(custom_name) + if custom_name_prefix: + custom_name_prefix = sanitize_custom_input(custom_name_prefix) + + # Handle custom name logic - if custom_name is provided, use it; otherwise use prefix logic + if custom_name and len(custom_name.strip()) > 0: + self.id = id + self.title = custom_name + elif len(custom_name_prefix) > 0: + self.id = f'{custom_name_prefix}.{id}' + self.title = f'{custom_name_prefix}.{title}' + else: + self.id = id + self.title = 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 @@ -333,12 +367,37 @@ class DownloadQueue: 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}' + # Handle custom naming in output template + if dl.custom_name and len(dl.custom_name.strip()) > 0: + # Create a custom output template using the custom name + # Replace %(title)s with the custom name in the template + output = self.config.OUTPUT_TEMPLATE.replace('%(title)s', dl.custom_name) + elif len(dl.custom_name_prefix) > 0: + output = f'{dl.custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}' + else: + output = 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 + # Start with the playlist template + playlist_output = self.config.OUTPUT_TEMPLATE_PLAYLIST + + # Apply custom naming logic to playlist template + if dl.custom_name and len(dl.custom_name.strip()) > 0: + # If playlist has multiple entries, append entry id to custom name to avoid overwriting + playlist_entries = entry.get('entries') + if playlist_entries and isinstance(playlist_entries, list) and len(playlist_entries) > 1: + entry_id = entry.get('id') or '' + custom_name_with_id = f"{dl.custom_name}_{entry_id}" if entry_id else dl.custom_name + output = playlist_output.replace('%(title)s', custom_name_with_id) + else: + output = playlist_output.replace('%(title)s', dl.custom_name) + elif len(dl.custom_name_prefix) > 0: + # Add prefix to the playlist template + output = f'{dl.custom_name_prefix}.{playlist_output}' + else: + output = playlist_output for property, value in entry.items(): if property.startswith("playlist"): output = output.replace(f"%({property})s", str(value)) @@ -347,6 +406,7 @@ class DownloadQueue: if playlist_item_limit > 0: log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries') ytdl_options['playlistend'] = playlist_item_limit + download = Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, dl.quality, dl.format, ytdl_options, dl) if auto_start is True: self.queue.put(download) @@ -355,7 +415,7 @@ class DownloadQueue: 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): + 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."} @@ -371,7 +431,7 @@ 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) + 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': log.debug('Processing as a playlist') entries = entry['entries'] @@ -388,7 +448,7 @@ class DownloadQueue: for property in ("id", "title", "uploader", "uploader_id"): if property in entry: etr[f"playlist_{property}"] = entry[property] - results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already)) + results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, custom_name, playlist_strict_mode, playlist_item_limit, auto_start, already)) 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'} @@ -396,13 +456,13 @@ class DownloadQueue: 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) + dl = DownloadInfo(entry['id'], entry.get('title') or entry['id'], key, quality, format, folder, custom_name_prefix, custom_name, 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=}') + 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') @@ -413,7 +473,7 @@ class DownloadQueue: 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 a536154..a6e160e 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -179,6 +179,18 @@ ngbTooltip="Add a prefix to downloaded filenames"> +
+
+ Custom Name + +
+
Items Limit @@ -193,7 +205,7 @@ ngbTooltip="Maximum number of items to download from a playlist (0 = no limit)">
-
+
{ diff --git a/ui/src/app/downloads.service.ts b/ui/src/app/downloads.service.ts index cf63a5e..9632034 100644 --- a/ui/src/app/downloads.service.ts +++ b/ui/src/app/downloads.service.ts @@ -17,6 +17,7 @@ export interface Download { format: string; folder: string; custom_name_prefix: string; + custom_name: string; playlist_strict_mode: boolean; playlist_item_limit: number; status: string; @@ -110,8 +111,8 @@ 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, customName: string, playlistStrictMode: boolean, playlistItemLimit: number, autoStart: boolean) { + return this.http.post('add', {url: url, quality: quality, format: format, folder: folder, custom_name_prefix: customNamePrefix, custom_name: customName, playlist_strict_mode: playlistStrictMode, playlist_item_limit: playlistItemLimit, auto_start: autoStart}).pipe( catchError(this.handleHTTPError) ); } @@ -141,12 +142,13 @@ export class DownloadsService { const defaultFormat = 'mp4'; const defaultFolder = ''; const defaultCustomNamePrefix = ''; + const defaultCustomName = ''; const defaultPlaylistStrictMode = false; const defaultPlaylistItemLimit = 0; const defaultAutoStart = true; return new Promise((resolve, reject) => { - this.add(url, defaultQuality, defaultFormat, defaultFolder, defaultCustomNamePrefix, defaultPlaylistStrictMode, defaultPlaylistItemLimit, defaultAutoStart) + this.add(url, defaultQuality, defaultFormat, defaultFolder, defaultCustomNamePrefix, defaultCustomName, defaultPlaylistStrictMode, defaultPlaylistItemLimit, defaultAutoStart) .subscribe( response => resolve(response), error => reject(error)