From 393add34b12356e470ec193804fcca7385d2f37c Mon Sep 17 00:00:00 2001 From: Adam Fendley Date: Sat, 31 Jan 2026 14:46:26 -0500 Subject: [PATCH] Add support for downloading an entire channel --- README.md | 1 + app/main.py | 3 ++- app/ytdl.py | 29 +++++++++++++++++++++++++++++ ui/src/app/app.html | 2 +- 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6019378..3812b8d 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Certain values can be set via environment variables, using the `-e` parameter on * __OUTPUT_TEMPLATE__: The template for the filenames of the downloaded videos, formatted according to [this spec](https://github.com/yt-dlp/yt-dlp/blob/master/README.md#output-template). Defaults to `%(title)s.%(ext)s`. * __OUTPUT_TEMPLATE_CHAPTER__: The template for the filenames of the downloaded videos when split into chapters via postprocessors. Defaults to `%(title)s - %(section_number)s %(section_title)s.%(ext)s`. * __OUTPUT_TEMPLATE_PLAYLIST__: The template for the filenames of the downloaded videos when downloaded as a playlist. Defaults to `%(playlist_title)s/%(title)s.%(ext)s`. When empty, then `OUTPUT_TEMPLATE` is used. +* __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to `%(channel)s/%(title)s.%(ext)s`. When empty, then `OUTPUT_TEMPLATE` is used. * __YTDL_OPTIONS__: Additional options to pass to yt-dlp in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L222). They roughly correspond to command-line options, though some do not have exact equivalents here. For example, `--recode-video` has to be specified via `postprocessors`. Also note that dashes are replaced with underscores. You may find [this script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) helpful for converting from command-line options to `YTDL_OPTIONS`. * __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence. The file will be monitored for changes and reloaded automatically when changes are detected. diff --git a/app/main.py b/app/main.py index d53f2b9..65a4ea3 100644 --- a/app/main.py +++ b/app/main.py @@ -58,6 +58,7 @@ class Config: 'OUTPUT_TEMPLATE': '%(title)s.%(ext)s', 'OUTPUT_TEMPLATE_CHAPTER': '%(title)s - %(section_number)02d - %(section_title)s.%(ext)s', 'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s', + 'OUTPUT_TEMPLATE_CHANNEL': '%(channel)s/%(title)s.%(ext)s', 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0', 'YTDL_OPTIONS': '{}', 'YTDL_OPTIONS_FILE': '', @@ -401,7 +402,7 @@ async def on_prepare(request, response): response.headers['Access-Control-Allow-Headers'] = 'Content-Type' app.on_response_prepare.append(on_prepare) - + def supports_reuse_port(): try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) diff --git a/app/ytdl.py b/app/ytdl.py index 06ee564..5958ccd 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -452,6 +452,12 @@ class DownloadQueue: for property, value in entry.items(): if property.startswith("playlist"): output = output.replace(f"%({property})s", str(value)) + if entry is not None and 'channel' in entry and entry['channel'] is not None: + if len(self.config.OUTPUT_TEMPLATE_CHANNEL): + output = self.config.OUTPUT_TEMPLATE_CHANNEL + for property, value in entry.items(): + if property.startswith("channel"): + output = output.replace(f"%({property})s", str(value)) ytdl_options = dict(self.config.YTDL_OPTIONS) playlist_item_limit = getattr(dl, 'playlist_item_limit', 0) if playlist_item_limit > 0: @@ -505,6 +511,29 @@ 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 == 'channel': + log.debug('Processing as a channel') + entries = entry['entries'] + # Convert generator to list if needed (for len() and slicing operations) + if isinstance(entries, types.GeneratorType): + entries = list(entries) + log.info(f'channel detected with {len(entries)} entries') + channel_index_digits = len(str(len(entries))) + results = [] + if playlist_item_limit > 0: + log.info(f'Channel item limit is set. Processing only first {playlist_item_limit} entries') + entries = entries[:playlist_item_limit] + for index, etr in enumerate(entries, start=1): + etr["_type"] = "video" + etr["channel"] = entry.get("id") or entry.get("channel_id") or entry.get("channel") + etr["channel_index"] = '{{0:0{0:d}d}}'.format(channel_index_digits).format(index) + for property in ("id", "title", "uploader", "uploader_id", "channel", "channel_id"): + if property in entry: + etr[f"channel_{property}"] = entry[property] + results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, split_by_chapters, chapter_template, 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'} 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'] diff --git a/ui/src/app/app.html b/ui/src/app/app.html index cfbeda2..a09faeb 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -94,7 +94,7 @@ autocomplete="off" spellcheck="false" class="form-control form-control-lg" - placeholder="Enter video or playlist URL" + placeholder="Enter video, channel, or playlist URL" name="addUrl" [(ngModel)]="addUrl" [disabled]="addInProgress || downloads.loading">