diff --git a/README.md b/README.md index 663de904..6fa71930 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ Certain values can be set via environment variables, using the `-e` parameter on * __PUBLIC_HOST_AUDIO_URL__: same as PUBLIC_HOST_URL but for audio downloads. * __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. +* __DEFAULT_OPTION_PLAYLIST_STRICT_MODE__: if `true`, the "Strict Playlist mode" switch will be enabled by default. In this mode the playlists will be downloaded only if the url strictly points to a playlist. Urls to videos inside a playlist will be treated same as direct video url. Defaults to `false` . +* __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum numer of playlist items that can be downloaded. Defaults to `0` (no limit). * __YTDL_OPTIONS__: Additional options to pass to youtube-dl, in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L183). 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. * __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. diff --git a/app/main.py b/app/main.py index 3daed83d..48826df8 100644 --- a/app/main.py +++ b/app/main.py @@ -28,6 +28,9 @@ class Config: 'PUBLIC_HOST_AUDIO_URL': 'audio_download/', 'OUTPUT_TEMPLATE': '%(title)s.%(ext)s', 'OUTPUT_TEMPLATE_CHAPTER': '%(title)s - %(section_number)s %(section_title)s.%(ext)s', + 'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s', + 'DEFAULT_OPTION_PLAYLIST_STRICT_MODE' : 'false', + 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0', 'YTDL_OPTIONS': '{}', 'YTDL_OPTIONS_FILE': '', 'HOST': '0.0.0.0', @@ -36,7 +39,7 @@ class Config: 'DEFAULT_THEME': 'auto' } - _BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN') + _BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'DEFAULT_OPTION_PLAYLIST_STRICT_MODE') def __init__(self): for k, v in self._DEFAULTS.items(): @@ -119,12 +122,22 @@ async def add(request): 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 - status = await dqueue.add(url, quality, format, folder, custom_name_prefix, auto_start) + 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) return web.Response(text=serializer.encode(status)) @routes.post(config.URL_PREFIX + 'delete') diff --git a/app/ytdl.py b/app/ytdl.py index 086d7c9a..11400613 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -212,19 +212,20 @@ def __init__(self, config, notifier): async def __import_queue(self): for k, v in self.queue.saved_items(): - await self.add(v.url, v.quality, v.format, v.folder, v.custom_name_prefix) + await self.add(v.url, v.quality, v.format, v.folder, v.custom_name_prefix, v.playlist_strict_mode, v.playlist_item_limit) async def initialize(self): self.event = asyncio.Event() asyncio.create_task(self.__download()) asyncio.create_task(self.__import_queue()) - def __extract_info(self, url): + def __extract_info(self, url, playlist_strict_mode): return yt_dlp.YoutubeDL(params={ 'quiet': True, 'no_color': True, 'extract_flat': True, 'ignore_no_formats_error': True, + 'noplaylist': playlist_strict_mode, 'paths': {"home": self.config.DOWNLOAD_DIR, "temp": self.config.TEMP_DIR}, **self.config.YTDL_OPTIONS, }).extract_info(url, download=False) @@ -252,7 +253,7 @@ def __calc_download_path(self, quality, format, folder): dldirectory = base_directory return dldirectory, None - async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, auto_start, already): + 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."} @@ -265,22 +266,32 @@ async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, error = entry["msg"] etype = entry.get('_type') or 'video' - if etype == 'playlist': + + 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 = [] + 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] for index, etr in enumerate(entries, start=1): + etr["_type"] = "video" # Prevents video to be treated as url and lose below properties during processing etr["playlist"] = entry["id"] etr["playlist_index"] = '{{0:0{0:d}d}}'.format(playlist_index_digits).format(index) 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, auto_start, already)) + results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, 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'} elif etype == 'video' or etype.startswith('url') and 'id' in entry and 'title' in entry: + log.debug('Processing as a video') if not self.queue.exists(entry['id']): dl = DownloadInfo(entry['id'], entry['title'], entry.get('webpage_url') or entry['url'], quality, format, folder, custom_name_prefix, error) dldirectory, error_message = self.__calc_download_path(quality, format, folder) @@ -288,22 +299,31 @@ async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, return error_message output = self.config.OUTPUT_TEMPLATE if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}' output_chapter = self.config.OUTPUT_TEMPLATE_CHAPTER - for property, value in entry.items(): - if property.startswith("playlist"): - output = output.replace(f"%({property})s", str(value)) + if 'playlist' in entry: + 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)) + + ytdl_options = dict(self.config.YTDL_OPTIONS) + + 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 + if auto_start is True: - self.queue.put(Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, quality, format, self.config.YTDL_OPTIONS, dl)) + self.queue.put(Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, quality, format, ytdl_options, dl)) self.event.set() else: - self.pending.put(Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, quality, format, self.config.YTDL_OPTIONS, dl)) + self.pending.put(Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, quality, format, ytdl_options, dl)) await self.notifier.added(dl) return {'status': 'ok'} - elif etype.startswith('url'): - return await self.add(entry['url'], quality, format, folder, custom_name_prefix, auto_start, already) return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'} - async def add(self, url, quality, format, folder, custom_name_prefix, auto_start=True, already=None): - log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=}') + 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=}') already = set() if already is None else already if url in already: log.info('recursion detected, skipping') @@ -311,10 +331,10 @@ async def add(self, url, quality, format, folder, custom_name_prefix, auto_start else: already.add(url) try: - entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url) + 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, auto_start, already) + return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, 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 e5b71455..16d1443a 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -94,6 +94,24 @@ Custom Name Prefix +
+
+
+
+
+ + +
+
+
+
+
+ Items limit + +
+
+
+
diff --git a/ui/src/app/app.component.sass b/ui/src/app/app.component.sass index 16913d49..4b20d8e5 100644 --- a/ui/src/app/app.component.sass +++ b/ui/src/app/app.component.sass @@ -55,3 +55,7 @@ td .disabled opacity: 0.5 pointer-events: none + +.form-switch + input + margin-top: 5px diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index 29af8ee3..55ece6e8 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -24,6 +24,8 @@ export class AppComponent implements AfterViewInit { folder: string; customNamePrefix: string; autoStart: boolean; + playlistStrictMode: boolean; + playlistItemLimit: number; addInProgress = false; themes: Theme[] = Themes; activeTheme: Theme; @@ -37,7 +39,6 @@ export class AppComponent implements AfterViewInit { @ViewChild('doneClearFailed') doneClearFailed: ElementRef; @ViewChild('doneRetryFailed') doneRetryFailed: ElementRef; - faTrashAlt = faTrashAlt; faCheckCircle = faCheckCircle; faTimesCircle = faTimesCircle; @@ -60,6 +61,7 @@ export class AppComponent implements AfterViewInit { } ngOnInit() { + this.getConfiguration(); this.customDirs$ = this.getMatchingCustomDir(); this.setTheme(this.activeTheme); @@ -129,6 +131,18 @@ export class AppComponent implements AfterViewInit { })); } + getConfiguration() { + this.downloads.configurationChanged.subscribe({ + next: (config) => { + this.playlistStrictMode = config['DEFAULT_OPTION_PLAYLIST_STRICT_MODE']; + const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT']; + if (playlistItemLimit !== '0') { + this.playlistItemLimit = playlistItemLimit; + } + } + }); + } + getPreferredTheme(cookieService: CookieService) { let theme = 'auto'; if (cookieService.check('metube_theme')) { @@ -179,17 +193,19 @@ export class AppComponent implements AfterViewInit { this.quality = exists ? this.quality : 'best' } - addDownload(url?: string, quality?: string, format?: string, folder?: string, customNamePrefix?: string, autoStart?: boolean) { + addDownload(url?: string, quality?: string, format?: string, folder?: string, customNamePrefix?: string, playlistStrictMode?: boolean, playlistItemLimit?: number, autoStart?: boolean) { url = url ?? this.addUrl quality = quality ?? this.quality format = format ?? this.format folder = folder ?? this.folder customNamePrefix = customNamePrefix ?? this.customNamePrefix + playlistStrictMode = playlistStrictMode ?? this.playlistStrictMode + playlistItemLimit = playlistItemLimit ?? this.playlistItemLimit autoStart = autoStart ?? this.autoStart - console.debug('Downloading: url='+url+' quality='+quality+' format='+format+' folder='+folder+' customNamePrefix='+customNamePrefix+' autoStart='+autoStart); + console.debug('Downloading: url='+url+' quality='+quality+' format='+format+' folder='+folder+' customNamePrefix='+customNamePrefix+' playlistStrictMode='+playlistStrictMode+' playlistItemLimit='+playlistItemLimit+' autoStart='+autoStart); this.addInProgress = true; - this.downloads.add(url, quality, format, folder, customNamePrefix, autoStart).subscribe((status: Status) => { + this.downloads.add(url, quality, format, folder, customNamePrefix, playlistStrictMode, playlistItemLimit, autoStart).subscribe((status: Status) => { if (status.status === 'error') { alert(`Error adding URL: ${status.msg}`); } else { @@ -204,7 +220,7 @@ export class AppComponent implements AfterViewInit { } retryDownload(key: string, download: Download) { - this.addDownload(download.url, download.quality, download.format, download.folder, download.custom_name_prefix, true); + this.addDownload(download.url, download.quality, download.format, download.folder, download.custom_name_prefix, download.playlist_strict_mode, download.playlist_item_limit, true); this.downloads.delById('done', [key]).subscribe(); } @@ -248,4 +264,11 @@ export class AppComponent implements AfterViewInit { identifyDownloadRow(index: number, row: KeyValue) { return row.key; } + + isNumber(event) { + const charCode = (event.which) ? event.which : event.keyCode; + if (charCode > 31 && (charCode < 48 || charCode > 57)) { + event.preventDefault(); + } + } } diff --git a/ui/src/app/downloads.service.ts b/ui/src/app/downloads.service.ts index 425ecb44..75db7479 100644 --- a/ui/src/app/downloads.service.ts +++ b/ui/src/app/downloads.service.ts @@ -17,6 +17,8 @@ export interface Download { format: string; folder: string; custom_name_prefix: string; + playlist_strict_mode: boolean; + playlist_item_limit: number; status: string; msg: string; percent: number; @@ -37,6 +39,7 @@ export class DownloadsService { queueChanged = new Subject(); doneChanged = new Subject(); customDirsChanged = new Subject(); + configurationChanged = new Subject(); configuration = {}; customDirs = {}; @@ -85,6 +88,7 @@ export class DownloadsService { let data = JSON.parse(strdata); console.debug("got configuration:", data); this.configuration = data; + this.configurationChanged.next(data); }); socket.fromEvent('custom_dirs').subscribe((strdata: string) => { let data = JSON.parse(strdata); @@ -99,8 +103,8 @@ export class DownloadsService { return of({status: 'error', msg: msg}) } - public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, autoStart: boolean) { - return this.http.post('add', {url: url, quality: quality, format: format, folder: folder, custom_name_prefix: customNamePrefix, auto_start: autoStart}).pipe( + 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( catchError(this.handleHTTPError) ); }