diff --git a/app/dl_formats.py b/app/dl_formats.py index 283f53c..e229366 100644 --- a/app/dl_formats.py +++ b/app/dl_formats.py @@ -21,91 +21,90 @@ def _normalize_subtitle_language(language: str) -> str: return language or "en" -def get_format(format: str, quality: str, video_codec: str = "auto") -> str: +def get_format(download_type: str, codec: str, format: str, quality: str) -> str: """ - Returns format for download + Returns yt-dlp format selector. Args: - format (str): format selected - quality (str): quality selected - video_codec (str): video codec filter (auto, h264, h265, av1, vp9) + download_type (str): selected content type (video, audio, captions, thumbnail) + codec (str): selected video codec (auto, h264, h265, av1, vp9) + format (str): selected output format/profile for type + quality (str): selected quality Raises: - Exception: unknown quality, unknown format + Exception: unknown type/format Returns: - dl_format: Formatted download string + str: yt-dlp format selector """ - format = format or "any" + download_type = (download_type or "video").strip().lower() + format = (format or "any").strip().lower() + codec = (codec or "auto").strip().lower() + quality = (quality or "best").strip().lower() if format.startswith("custom:"): return format[7:] - if format == "thumbnail": - # Quality is irrelevant in this case since we skip the download + if download_type == "thumbnail": return "bestaudio/best" - if format == "captions": - # Quality is irrelevant in this case since we skip the download + if download_type == "captions": return "bestaudio/best" - if format in AUDIO_FORMATS: - # Audio quality needs to be set post-download, set in opts + if download_type == "audio": + if format not in AUDIO_FORMATS: + raise Exception(f"Unknown audio format {format}") return f"bestaudio[ext={format}]/bestaudio/best" - if format in ("mp4", "any"): - if quality == "audio": - return "bestaudio/best" - # video {res} {vfmt} + audio {afmt} {res} {vfmt} - vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format == "mp4" else ("", "") - vres = f"[height<={quality}]" if quality not in ("best", "best_ios", "worst") else "" + if download_type == "video": + if format not in ("any", "mp4", "ios"): + raise Exception(f"Unknown video format {format}") + vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format in ("mp4", "ios") else ("", "") + vres = f"[height<={quality}]" if quality not in ("best", "worst") else "" vcombo = vres + vfmt - codec_filter = CODEC_FILTER_MAP.get(video_codec, "") + codec_filter = CODEC_FILTER_MAP.get(codec, "") - if quality == "best_ios": - # iOS has strict requirements for video files, requiring h264 or h265 - # video codec and aac audio codec in MP4 container. This format string - # attempts to get the fully compatible formats first, then the h264/h265 - # video codec with any M4A audio codec (because audio is faster to - # convert if needed), and falls back to getting the best available MP4 - # file. + if format == "ios": return f"bestvideo[vcodec~='^((he|a)vc|h26[45])']{vres}+bestaudio[acodec=aac]/bestvideo[vcodec~='^((he|a)vc|h26[45])']{vres}+bestaudio{afmt}/bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}" if codec_filter: - # Try codec-filtered first, fall back to unfiltered return f"bestvideo{codec_filter}{vcombo}+bestaudio{afmt}/bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}" return f"bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}" - raise Exception(f"Unkown format {format}") + raise Exception(f"Unknown download_type {download_type}") def get_opts( + download_type: str, + codec: str, format: str, quality: str, ytdl_opts: dict, - subtitle_format: str = "srt", subtitle_language: str = "en", subtitle_mode: str = "prefer_manual", - video_codec: str = "auto", ) -> dict: """ - Returns extra download options - Mostly postprocessing options + Returns extra yt-dlp options/postprocessors. Args: - format (str): format selected - quality (str): quality of format selected (needed for some formats) + download_type (str): selected content type + codec (str): selected codec (unused currently, kept for API consistency) + format (str): selected format/profile + quality (str): selected quality ytdl_opts (dict): current options selected Returns: - ytdl_opts: Extra options + dict: extended options """ + del codec # kept for parity with get_format signature + download_type = (download_type or "video").strip().lower() + format = (format or "any").strip().lower() opts = copy.deepcopy(ytdl_opts) postprocessors = [] - if format in AUDIO_FORMATS: + if download_type == "audio": postprocessors.append( { "key": "FFmpegExtractAudio", @@ -114,7 +113,6 @@ def get_opts( } ) - # Audio formats without thumbnail if format not in ("wav") and "writethumbnail" not in opts: opts["writethumbnail"] = True postprocessors.append( @@ -127,19 +125,18 @@ def get_opts( postprocessors.append({"key": "FFmpegMetadata"}) postprocessors.append({"key": "EmbedThumbnail"}) - if format == "thumbnail": + if download_type == "thumbnail": opts["skip_download"] = True opts["writethumbnail"] = True postprocessors.append( {"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"} ) - if format == "captions": + if download_type == "captions": mode = _normalize_caption_mode(subtitle_mode) language = _normalize_subtitle_language(subtitle_language) opts["skip_download"] = True - requested_subtitle_format = (subtitle_format or "srt").lower() - # txt is a derived, non-timed format produced from SRT after download. + requested_subtitle_format = (format or "srt").lower() if requested_subtitle_format == "txt": requested_subtitle_format = "srt" opts["subtitlesformat"] = requested_subtitle_format diff --git a/app/main.py b/app/main.py index b2d9f14..6e3cbbf 100644 --- a/app/main.py +++ b/app/main.py @@ -194,6 +194,68 @@ routes = web.RouteTableDef() VALID_SUBTITLE_FORMATS = {'srt', 'txt', 'vtt', 'ttml', 'sbv', 'scc', 'dfxp'} VALID_SUBTITLE_MODES = {'auto_only', 'manual_only', 'prefer_manual', 'prefer_auto'} SUBTITLE_LANGUAGE_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9-]{0,34}$') +VALID_DOWNLOAD_TYPES = {'video', 'audio', 'captions', 'thumbnail'} +VALID_VIDEO_CODECS = {'auto', 'h264', 'h265', 'av1', 'vp9'} +VALID_VIDEO_FORMATS = {'any', 'mp4', 'ios'} +VALID_AUDIO_FORMATS = {'m4a', 'mp3', 'opus', 'wav', 'flac'} +VALID_THUMBNAIL_FORMATS = {'jpg'} + + +def _migrate_legacy_request(post: dict) -> dict: + """ + BACKWARD COMPATIBILITY: Translate old API request schema into the new one. + + Old API: + format (any/mp4/m4a/mp3/opus/wav/flac/thumbnail/captions) + quality + video_codec + subtitle_format (only when format=captions) + + New API: + download_type (video/audio/captions/thumbnail) + codec + format + quality + """ + if "download_type" in post: + return post + + old_format = str(post.get("format") or "any").strip().lower() + old_quality = str(post.get("quality") or "best").strip().lower() + old_video_codec = str(post.get("video_codec") or "auto").strip().lower() + + if old_format in VALID_AUDIO_FORMATS: + post["download_type"] = "audio" + post["codec"] = "auto" + post["format"] = old_format + elif old_format == "thumbnail": + post["download_type"] = "thumbnail" + post["codec"] = "auto" + post["format"] = "jpg" + post["quality"] = "best" + elif old_format == "captions": + post["download_type"] = "captions" + post["codec"] = "auto" + post["format"] = str(post.get("subtitle_format") or "srt").strip().lower() + post["quality"] = "best" + else: + # old_format is usually any/mp4 (legacy video path) + post["download_type"] = "video" + post["codec"] = old_video_codec + if old_quality == "best_ios": + post["format"] = "ios" + post["quality"] = "best" + elif old_quality == "audio": + # Legacy "audio only" under video format maps to m4a audio. + post["download_type"] = "audio" + post["codec"] = "auto" + post["format"] = "m4a" + post["quality"] = "best" + else: + post["format"] = old_format + post["quality"] = old_quality + + return post class Notifier(DownloadQueueNotifier): async def added(self, dl): @@ -272,23 +334,24 @@ if config.YTDL_OPTIONS_FILE: async def add(request): log.info("Received request to add download") post = await request.json() + post = _migrate_legacy_request(post) log.info(f"Request data: {post}") url = post.get('url') - quality = post.get('quality') - if not url or not quality: - log.error("Bad request: missing 'url' or 'quality'") - raise web.HTTPBadRequest() + download_type = post.get('download_type') + codec = post.get('codec') format = post.get('format') + quality = post.get('quality') + if not url or not quality or not download_type: + log.error("Bad request: missing 'url', 'download_type', or 'quality'") + raise web.HTTPBadRequest() folder = post.get('folder') custom_name_prefix = post.get('custom_name_prefix') playlist_item_limit = post.get('playlist_item_limit') auto_start = post.get('auto_start') split_by_chapters = post.get('split_by_chapters') chapter_template = post.get('chapter_template') - subtitle_format = post.get('subtitle_format') subtitle_language = post.get('subtitle_language') subtitle_mode = post.get('subtitle_mode') - video_codec = post.get('video_codec') if custom_name_prefix is None: custom_name_prefix = '' @@ -302,46 +365,72 @@ async def add(request): split_by_chapters = False if chapter_template is None: chapter_template = config.OUTPUT_TEMPLATE_CHAPTER - if subtitle_format is None: - subtitle_format = 'srt' if subtitle_language is None: subtitle_language = 'en' if subtitle_mode is None: subtitle_mode = 'prefer_manual' - subtitle_format = str(subtitle_format).strip().lower() + download_type = str(download_type).strip().lower() + codec = str(codec or 'auto').strip().lower() + format = str(format or '').strip().lower() + quality = str(quality).strip().lower() subtitle_language = str(subtitle_language).strip() subtitle_mode = str(subtitle_mode).strip() + if chapter_template and ('..' in chapter_template or chapter_template.startswith('/') or chapter_template.startswith('\\')): raise web.HTTPBadRequest(reason='chapter_template must not contain ".." or start with a path separator') - if subtitle_format not in VALID_SUBTITLE_FORMATS: - raise web.HTTPBadRequest(reason=f'subtitle_format must be one of {sorted(VALID_SUBTITLE_FORMATS)}') if not SUBTITLE_LANGUAGE_RE.fullmatch(subtitle_language): raise web.HTTPBadRequest(reason='subtitle_language must match pattern [A-Za-z0-9-] and be at most 35 characters') if subtitle_mode not in VALID_SUBTITLE_MODES: raise web.HTTPBadRequest(reason=f'subtitle_mode must be one of {sorted(VALID_SUBTITLE_MODES)}') - if video_codec is None: - video_codec = 'auto' - video_codec = str(video_codec).strip().lower() - if video_codec not in ('auto', 'h264', 'h265', 'av1', 'vp9'): - raise web.HTTPBadRequest(reason="video_codec must be one of ['auto', 'h264', 'h265', 'av1', 'vp9']") + if download_type not in VALID_DOWNLOAD_TYPES: + raise web.HTTPBadRequest(reason=f'download_type must be one of {sorted(VALID_DOWNLOAD_TYPES)}') + if codec not in VALID_VIDEO_CODECS: + raise web.HTTPBadRequest(reason=f'codec must be one of {sorted(VALID_VIDEO_CODECS)}') + + if download_type == 'video': + if format not in VALID_VIDEO_FORMATS: + raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_VIDEO_FORMATS)} for video') + if quality not in {'best', 'worst', '2160', '1440', '1080', '720', '480', '360', '240'}: + raise web.HTTPBadRequest(reason="quality must be one of ['best', '2160', '1440', '1080', '720', '480', '360', '240', 'worst'] for video") + elif download_type == 'audio': + if format not in VALID_AUDIO_FORMATS: + raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_AUDIO_FORMATS)} for audio') + allowed_audio_qualities = {'best'} + if format == 'mp3': + allowed_audio_qualities |= {'320', '192', '128'} + elif format == 'm4a': + allowed_audio_qualities |= {'192', '128'} + if quality not in allowed_audio_qualities: + raise web.HTTPBadRequest(reason=f'quality must be one of {sorted(allowed_audio_qualities)} for format {format}') + codec = 'auto' + elif download_type == 'captions': + if format not in VALID_SUBTITLE_FORMATS: + raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_SUBTITLE_FORMATS)} for captions') + quality = 'best' + codec = 'auto' + elif download_type == 'thumbnail': + if format not in VALID_THUMBNAIL_FORMATS: + raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_THUMBNAIL_FORMATS)} for thumbnail') + quality = 'best' + codec = 'auto' playlist_item_limit = int(playlist_item_limit) status = await dqueue.add( url, - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, - subtitle_format, subtitle_language, subtitle_mode, - video_codec=video_codec, ) return web.Response(text=serializer.encode(status)) diff --git a/app/ytdl.py b/app/ytdl.py index 370c458..e738fe9 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -151,6 +151,8 @@ class DownloadInfo: title, url, quality, + download_type, + codec, format, folder, custom_name_prefix, @@ -159,15 +161,15 @@ class DownloadInfo: playlist_item_limit, split_by_chapters, chapter_template, - subtitle_format="srt", subtitle_language="en", subtitle_mode="prefer_manual", - video_codec="auto", ): 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.download_type = download_type + self.codec = codec self.format = format self.folder = folder self.custom_name_prefix = custom_name_prefix @@ -181,11 +183,48 @@ class DownloadInfo: self.playlist_item_limit = playlist_item_limit self.split_by_chapters = split_by_chapters self.chapter_template = chapter_template - self.subtitle_format = subtitle_format self.subtitle_language = subtitle_language self.subtitle_mode = subtitle_mode self.subtitle_files = [] - self.video_codec = video_codec + + def __setstate__(self, state): + """BACKWARD COMPATIBILITY: migrate old DownloadInfo from persistent queue files.""" + self.__dict__.update(state) + if 'download_type' not in state: + old_format = state.get('format', 'any') + old_video_codec = state.get('video_codec', 'auto') + old_quality = state.get('quality', 'best') + old_subtitle_format = state.get('subtitle_format', 'srt') + + if old_format in AUDIO_FORMATS: + self.download_type = 'audio' + self.codec = 'auto' + elif old_format == 'thumbnail': + self.download_type = 'thumbnail' + self.codec = 'auto' + self.format = 'jpg' + elif old_format == 'captions': + self.download_type = 'captions' + self.codec = 'auto' + self.format = old_subtitle_format + else: + self.download_type = 'video' + self.codec = old_video_codec + if old_quality == 'best_ios': + self.format = 'ios' + self.quality = 'best' + elif old_quality == 'audio': + self.download_type = 'audio' + self.codec = 'auto' + self.format = 'm4a' + self.quality = 'best' + self.__dict__.pop('video_codec', None) + self.__dict__.pop('subtitle_format', None) + + if not getattr(self, "codec", None): + self.codec = "auto" + if not hasattr(self, "subtitle_files"): + self.subtitle_files = [] class Download: manager = None @@ -196,15 +235,20 @@ class Download: self.output_template = output_template self.output_template_chapter = output_template_chapter self.info = info - self.format = get_format(format, quality, video_codec=getattr(info, 'video_codec', 'auto')) + self.format = get_format( + getattr(info, 'download_type', 'video'), + getattr(info, 'codec', 'auto'), + format, + quality, + ) self.ytdl_opts = get_opts( + getattr(info, 'download_type', 'video'), + getattr(info, 'codec', 'auto'), format, quality, ytdl_opts, - subtitle_format=getattr(info, 'subtitle_format', 'srt'), subtitle_language=getattr(info, 'subtitle_language', 'en'), subtitle_mode=getattr(info, 'subtitle_mode', 'prefer_manual'), - video_codec=getattr(info, 'video_codec', 'auto'), ) if "impersonate" in self.ytdl_opts: self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"]) @@ -244,7 +288,7 @@ class Download: # For captions-only downloads, yt-dlp may still report a media-like # filepath in MoveFiles. Capture subtitle outputs explicitly so the # UI can link to real caption files. - if self.info.format == 'captions': + if getattr(self.info, 'download_type', '') == 'captions': requested_subtitles = d.get('info_dict', {}).get('requested_subtitles', {}) or {} for subtitle in requested_subtitles.values(): if isinstance(subtitle, dict) and subtitle.get('filepath'): @@ -352,14 +396,14 @@ class Download: rel_name = os.path.relpath(fileName, self.download_dir) # For captions mode, ignore media-like placeholders and let subtitle_file # statuses define the final file shown in the UI. - if self.info.format == 'captions': - requested_subtitle_format = str(getattr(self.info, 'subtitle_format', '')).lower() + if getattr(self.info, 'download_type', '') == 'captions': + requested_subtitle_format = str(getattr(self.info, 'format', '')).lower() allowed_caption_exts = ('.txt',) if requested_subtitle_format == 'txt' else ('.vtt', '.srt', '.sbv', '.scc', '.ttml', '.dfxp') if not rel_name.lower().endswith(allowed_caption_exts): continue self.info.filename = rel_name self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None - if self.info.format == 'thumbnail': + if getattr(self.info, 'download_type', '') == 'thumbnail': self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename) # Handle chapter files @@ -384,7 +428,7 @@ class Download: subtitle_output_file = subtitle_file # txt mode is derived from SRT by stripping cue metadata. - if self.info.format == 'captions' and str(getattr(self.info, 'subtitle_format', '')).lower() == 'txt': + if getattr(self.info, 'download_type', '') == 'captions' and str(getattr(self.info, 'format', '')).lower() == 'txt': converted_txt = _convert_srt_to_txt_file(subtitle_file) if converted_txt: subtitle_output_file = converted_txt @@ -400,9 +444,9 @@ class Download: if not existing: self.info.subtitle_files.append({'filename': rel_path, 'size': file_size}) # Prefer first subtitle file as the primary result link in captions mode. - if self.info.format == 'captions' and ( + if getattr(self.info, 'download_type', '') == 'captions' and ( not getattr(self.info, 'filename', None) or - str(getattr(self.info, 'subtitle_format', '')).lower() == 'txt' + str(getattr(self.info, 'format', '')).lower() == 'txt' ): self.info.filename = rel_path self.info.size = file_size @@ -434,7 +478,7 @@ class PersistentQueue: def load(self): for k, v in self.saved_items(): - self.dict[k] = Download(None, None, None, None, None, None, {}, v) + self.dict[k] = Download(None, None, None, None, getattr(v, 'quality', 'best'), getattr(v, 'format', 'any'), {}, v) def exists(self, key): return key in self.dict @@ -625,8 +669,8 @@ class DownloadQueue: **({'impersonate': yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.config.YTDL_OPTIONS['impersonate'])} if 'impersonate' in self.config.YTDL_OPTIONS else {}), }).extract_info(url, download=False) - def __calc_download_path(self, quality, format, folder): - base_directory = self.config.DOWNLOAD_DIR if (quality != 'audio' and format not in AUDIO_FORMATS) else self.config.AUDIO_DOWNLOAD_DIR + def __calc_download_path(self, download_type, folder): + base_directory = self.config.AUDIO_DOWNLOAD_DIR if download_type == 'audio' else self.config.DOWNLOAD_DIR if folder: if not self.config.CUSTOM_DIRS: return None, {'status': 'error', 'msg': 'A folder for the download was specified but CUSTOM_DIRS is not true in the configuration.'} @@ -643,7 +687,7 @@ class DownloadQueue: return dldirectory, None async def __add_download(self, dl, auto_start): - dldirectory, error_message = self.__calc_download_path(dl.quality, dl.format, dl.folder) + dldirectory, error_message = self.__calc_download_path(dl.download_type, 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}' @@ -677,20 +721,20 @@ class DownloadQueue: async def __add_entry( self, entry, - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, - subtitle_format, subtitle_language, subtitle_mode, already, _add_gen=None, - video_codec="auto", ): if not entry: return {'status': 'error', 'msg': "Invalid/empty data was given."} @@ -709,20 +753,20 @@ class DownloadQueue: log.debug('Processing as a url') return await self.add( entry['url'], - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, - subtitle_format, subtitle_language, subtitle_mode, already, _add_gen, - video_codec, ) elif etype == 'playlist' or etype == 'channel': log.debug(f'Processing as a {etype}') @@ -749,20 +793,20 @@ class DownloadQueue: results.append( await self.__add_entry( etr, - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, - subtitle_format, subtitle_language, subtitle_mode, already, _add_gen, - video_codec, ) ) if any(res['status'] == 'error' for res in results): @@ -776,22 +820,22 @@ class DownloadQueue: return {'status': 'ok'} 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, - split_by_chapters, - chapter_template, - subtitle_format, - subtitle_language, - subtitle_mode, - video_codec, + id=entry['id'], + title=entry.get('title') or entry['id'], + url=key, + quality=quality, + download_type=download_type, + codec=codec, + format=format, + folder=folder, + custom_name_prefix=custom_name_prefix, + error=error, + entry=entry, + playlist_item_limit=playlist_item_limit, + split_by_chapters=split_by_chapters, + chapter_template=chapter_template, + subtitle_language=subtitle_language, + subtitle_mode=subtitle_mode, ) await self.__add_download(dl, auto_start) return {'status': 'ok'} @@ -800,26 +844,25 @@ class DownloadQueue: async def add( self, url, - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start=True, split_by_chapters=False, chapter_template=None, - subtitle_format="srt", subtitle_language="en", subtitle_mode="prefer_manual", already=None, _add_gen=None, - video_codec="auto", ): log.info( - f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} ' + f'adding {url}: {download_type=} {codec=} {format=} {quality=} {already=} {folder=} {custom_name_prefix=} ' f'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} ' - f'{subtitle_format=} {subtitle_language=} {subtitle_mode=} ' - f'{video_codec=}' + f'{subtitle_language=} {subtitle_mode=}' ) if already is None: _add_gen = self._add_generation @@ -836,20 +879,20 @@ class DownloadQueue: return {'status': 'error', 'msg': str(exc)} return await self.__add_entry( entry, - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, - subtitle_format, subtitle_language, subtitle_mode, already, _add_gen, - video_codec, ) async def start_pending(self, ids): @@ -889,7 +932,7 @@ class DownloadQueue: if self.config.DELETE_FILE_ON_TRASHCAN: dl = self.done.get(id) try: - dldirectory, _ = self.__calc_download_path(dl.info.quality, dl.info.format, dl.info.folder) + dldirectory, _ = self.__calc_download_path(dl.info.download_type, dl.info.folder) os.remove(os.path.join(dldirectory, dl.info.filename)) except Exception as e: log.warn(f'deleting file for download {id} failed with error message {e!r}') diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 56a0b44..9c3b8d7 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -120,39 +120,205 @@
-
-
- Quality - + @if (downloadType === 'video') { +
+
+ Type + +
-
-
-
- Format - +
+
+ Codec + +
-
-
+
+
+ Format + +
+
+
+
+ Quality + +
+
+ } @else if (downloadType === 'audio') { +
+
+ Type + +
+
+
+
+ Format + +
+
+
+
+ Quality + +
+
+ } @else if (downloadType === 'captions') { +
+
+ Type + +
+
+
+
+ Format + +
+
+
+
+ Language + + + @for (lang of subtitleLanguages; track lang.id) { + + } + +
+
+
+
+ Subtitle Source + +
+
+ } @else { +
+
+ Type + +
+
+
+
+ Format + +
+
+ } +
+ +
+
@@ -161,7 +327,7 @@
-
+
@@ -225,77 +391,6 @@ ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
- @if (format === 'captions') { -
-
- Subtitles - -
- @if (subtitleFormat === 'txt') { -
TXT is generated from SRT by stripping timestamps and cue numbers.
- } -
-
-
- Language - - - @for (lang of subtitleLanguages; track lang.id) { - - } - -
-
-
-
- Subtitle Source - -
-
- } - @if (isVideoType()) { -
-
- Video Codec - -
-
- }
@@ -506,8 +601,9 @@ Video + Type Quality - Codec + Codec / Format File Size Downloaded @@ -536,15 +632,18 @@ @if (!!entry[1].filename) { {{ entry[1].title }} } @else { - - {{entry[1].title}} - @if (entry[1].status === 'error' && !isErrorExpanded(entry[0])) { - - Click for details - - } - + @if (entry[1].status === 'error') { + + } @else { + {{entry[1].title}} + } } @if (entry[1].status === 'error' && isErrorExpanded(entry[0])) {
@@ -571,6 +670,9 @@
} + + {{ downloadTypeLabel(entry[1]) }} + {{ formatQualityLabel(entry[1]) }} @@ -613,6 +715,7 @@ + @if (chapterFile.size) { {{ chapterFile.size | fileSize }} diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 0228f31..a890ffd 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -6,12 +6,27 @@ 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, faUpload } 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, faChevronDown, 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'; import { Themes } from './theme'; -import { Download, Status, Theme , Quality, Format, Formats, State } from './interfaces'; +import { + Download, + Status, + Theme, + Quality, + Option, + AudioFormatOption, + DOWNLOAD_TYPES, + VIDEO_CODECS, + VIDEO_FORMATS, + VIDEO_QUALITIES, + AUDIO_FORMATS, + CAPTION_FORMATS, + THUMBNAIL_FORMATS, + State, +} from './interfaces'; import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes'; import { MasterCheckboxComponent , SlaveCheckboxComponent} from './components/'; @@ -40,8 +55,15 @@ export class App implements AfterViewInit, OnInit { private http = inject(HttpClient); addUrl!: string; - formats: Format[] = Formats; + downloadTypes: Option[] = DOWNLOAD_TYPES; + videoCodecs: Option[] = VIDEO_CODECS; + videoFormats: Option[] = VIDEO_FORMATS; + audioFormats: AudioFormatOption[] = AUDIO_FORMATS; + captionFormats: Option[] = CAPTION_FORMATS; + thumbnailFormats: Option[] = THUMBNAIL_FORMATS; qualities!: Quality[]; + downloadType: string; + codec: string; quality: string; format: string; folder!: string; @@ -50,10 +72,8 @@ export class App implements AfterViewInit, OnInit { playlistItemLimit!: number; splitByChapters: boolean; chapterTemplate: string; - subtitleFormat: string; subtitleLanguage: string; subtitleMode: string; - videoCodec: string; addInProgress = false; cancelRequested = false; hasCookies = false; @@ -72,9 +92,18 @@ export class App implements AfterViewInit, OnInit { metubeVersion: string | null = null; isAdvancedOpen = false; sortAscending = false; - expandedErrors: Set = new Set(); + expandedErrors: Set = new Set(); cachedSortedDone: [string, Download][] = []; lastCopiedErrorId: string | null = null; + private previousDownloadType = 'video'; + private selectionsByType: Record = {}; + private readonly selectionCookiePrefix = 'metube_selection_'; // Download metrics activeDownloads = 0; @@ -112,13 +141,8 @@ export class App implements AfterViewInit, OnInit { faSortAmountDown = faSortAmountDown; faSortAmountUp = faSortAmountUp; faChevronRight = faChevronRight; + faChevronDown = faChevronDown; faUpload = faUpload; - subtitleFormats = [ - { id: 'srt', text: 'SRT' }, - { id: 'txt', text: 'TXT (Text only)' }, - { id: 'vtt', text: 'VTT' }, - { id: 'ttml', text: 'TTML' } - ]; subtitleLanguages = [ { id: 'en', text: 'English' }, { id: 'ar', text: 'Arabic' }, @@ -170,39 +194,35 @@ export class App implements AfterViewInit, OnInit { { id: 'manual_only', text: 'Manual Only' }, { id: 'auto_only', text: 'Auto Only' }, ]; - videoCodecs = [ - { id: 'auto', text: 'Auto' }, - { id: 'h264', text: 'H.264' }, - { id: 'h265', text: 'H.265 (HEVC)' }, - { id: 'av1', text: 'AV1' }, - { id: 'vp9', text: 'VP9' }, - ]; - constructor() { + this.downloadType = this.cookieService.get('metube_download_type') || 'video'; + this.codec = this.cookieService.get('metube_codec') || 'auto'; this.format = this.cookieService.get('metube_format') || 'any'; - // Needs to be set or qualities won't automatically be set - this.setQualities() this.quality = this.cookieService.get('metube_quality') || 'best'; this.autoStart = this.cookieService.get('metube_auto_start') !== 'false'; this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true'; // Will be set from backend configuration, use empty string as placeholder this.chapterTemplate = this.cookieService.get('metube_chapter_template') || ''; - this.subtitleFormat = this.cookieService.get('metube_subtitle_format') || 'srt'; this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en'; this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual'; - this.videoCodec = this.cookieService.get('metube_video_codec') || 'auto'; + const allowedDownloadTypes = new Set(this.downloadTypes.map(t => t.id)); const allowedVideoCodecs = new Set(this.videoCodecs.map(c => c.id)); - if (!allowedVideoCodecs.has(this.videoCodec)) { - this.videoCodec = 'auto'; + if (!allowedDownloadTypes.has(this.downloadType)) { + this.downloadType = 'video'; + } + if (!allowedVideoCodecs.has(this.codec)) { + this.codec = 'auto'; } - const allowedSubtitleFormats = new Set(this.subtitleFormats.map(fmt => fmt.id)); const allowedSubtitleModes = new Set(this.subtitleModes.map(mode => mode.id)); - if (!allowedSubtitleFormats.has(this.subtitleFormat)) { - this.subtitleFormat = 'srt'; - } if (!allowedSubtitleModes.has(this.subtitleMode)) { this.subtitleMode = 'prefer_manual'; } + this.loadSavedSelections(); + this.restoreSelection(this.downloadType); + this.normalizeSelectionsForType(); + this.setQualities(); + this.previousDownloadType = this.downloadType; + this.saveSelection(this.downloadType); this.sortAscending = this.cookieService.get('metube_sort_ascending') === 'true'; this.activeTheme = this.getPreferredTheme(this.cookieService); @@ -223,7 +243,7 @@ export class App implements AfterViewInit, OnInit { ngOnInit() { this.downloads.getCookieStatus().subscribe(data => { - this.hasCookies = data?.has_cookies || false; + this.hasCookies = !!(data && typeof data === 'object' && 'has_cookies' in data && data.has_cookies); }); this.getConfiguration(); this.getYtdlOptionsUpdateTime(); @@ -268,10 +288,27 @@ export class App implements AfterViewInit, OnInit { qualityChanged() { this.cookieService.set('metube_quality', this.quality, { expires: 3650 }); + this.saveSelection(this.downloadType); // Re-trigger custom directory change this.downloads.customDirsChanged.next(this.downloads.customDirs); } + downloadTypeChanged() { + this.saveSelection(this.previousDownloadType); + this.restoreSelection(this.downloadType); + this.cookieService.set('metube_download_type', this.downloadType, { expires: 3650 }); + this.normalizeSelectionsForType(false); + this.setQualities(); + this.saveSelection(this.downloadType); + this.previousDownloadType = this.downloadType; + this.downloads.customDirsChanged.next(this.downloads.customDirs); + } + + codecChanged() { + this.cookieService.set('metube_codec', this.codec, { expires: 3650 }); + this.saveSelection(this.downloadType); + } + showAdvanced() { return this.downloads.configuration['CUSTOM_DIRS']; } @@ -284,7 +321,7 @@ export class App implements AfterViewInit, OnInit { } isAudioType() { - return this.quality == 'audio' || this.format == 'mp3' || this.format == 'm4a' || this.format == 'opus' || this.format == 'wav' || this.format == 'flac'; + return this.downloadType === 'audio'; } getMatchingCustomDir() : Observable { @@ -358,8 +395,8 @@ export class App implements AfterViewInit, OnInit { formatChanged() { this.cookieService.set('metube_format', this.format, { expires: 3650 }); - // Updates to use qualities available - this.setQualities() + this.setQualities(); + this.saveSelection(this.downloadType); // Re-trigger custom directory change this.downloads.customDirsChanged.next(this.downloads.customDirs); } @@ -380,36 +417,42 @@ export class App implements AfterViewInit, OnInit { this.cookieService.set('metube_chapter_template', this.chapterTemplate, { expires: 3650 }); } - subtitleFormatChanged() { - this.cookieService.set('metube_subtitle_format', this.subtitleFormat, { expires: 3650 }); - } - subtitleLanguageChanged() { this.cookieService.set('metube_subtitle_language', this.subtitleLanguage, { expires: 3650 }); + this.saveSelection(this.downloadType); } subtitleModeChanged() { this.cookieService.set('metube_subtitle_mode', this.subtitleMode, { expires: 3650 }); - } - - videoCodecChanged() { - this.cookieService.set('metube_video_codec', this.videoCodec, { expires: 3650 }); + this.saveSelection(this.downloadType); } isVideoType() { - return (this.format === 'any' || this.format === 'mp4') && this.quality !== 'audio'; + return this.downloadType === 'video'; } formatQualityLabel(download: Download): string { + if (download.download_type === 'captions' || download.download_type === 'thumbnail') { + return '-'; + } const q = download.quality; if (!q) return ''; + if (/^\d+$/.test(q) && download.download_type === 'audio') return `${q} kbps`; if (/^\d+$/.test(q)) return `${q}p`; - if (q === 'best_ios') return 'Best (iOS)'; return q.charAt(0).toUpperCase() + q.slice(1); } + downloadTypeLabel(download: Download): string { + const type = download.download_type || 'video'; + return type.charAt(0).toUpperCase() + type.slice(1); + } + formatCodecLabel(download: Download): string { - const codec = download.video_codec; + if (download.download_type !== 'video') { + const format = (download.format || '').toUpperCase(); + return format || '-'; + } + const codec = download.codec; if (!codec || codec === 'auto') return 'Auto'; return this.videoCodecs.find(c => c.id === codec)?.text ?? codec; } @@ -425,17 +468,130 @@ export class App implements AfterViewInit, OnInit { } setQualities() { - // qualities for specific format - const format = this.formats.find(el => el.id == this.format) - if (format) { - this.qualities = format.qualities - const exists = this.qualities.find(el => el.id === this.quality) - this.quality = exists ? this.quality : 'best' + if (this.downloadType === 'video') { + this.qualities = this.format === 'ios' + ? [{ id: 'best', text: 'Best' }] + : VIDEO_QUALITIES; + } else if (this.downloadType === 'audio') { + const selectedFormat = this.audioFormats.find(el => el.id === this.format); + this.qualities = selectedFormat ? selectedFormat.qualities : [{ id: 'best', text: 'Best' }]; + } else { + this.qualities = [{ id: 'best', text: 'Best' }]; + } + const exists = this.qualities.find(el => el.id === this.quality); + this.quality = exists ? this.quality : 'best'; + } + + showCodecSelector() { + return this.downloadType === 'video'; + } + + showFormatSelector() { + return this.downloadType !== 'thumbnail'; + } + + showQualitySelector() { + if (this.downloadType === 'video') { + return this.format !== 'ios'; + } + return this.downloadType === 'audio'; + } + + getFormatOptions() { + if (this.downloadType === 'video') { + return this.videoFormats; + } + if (this.downloadType === 'audio') { + return this.audioFormats; + } + if (this.downloadType === 'captions') { + return this.captionFormats; + } + return this.thumbnailFormats; + } + + private normalizeSelectionsForType(resetForTypeChange = false) { + if (this.downloadType === 'video') { + const allowedFormats = new Set(this.videoFormats.map(f => f.id)); + if (resetForTypeChange || !allowedFormats.has(this.format)) { + this.format = 'any'; + } + const allowedCodecs = new Set(this.videoCodecs.map(c => c.id)); + if (resetForTypeChange || !allowedCodecs.has(this.codec)) { + this.codec = 'auto'; + } + } else if (this.downloadType === 'audio') { + const allowedFormats = new Set(this.audioFormats.map(f => f.id)); + if (resetForTypeChange || !allowedFormats.has(this.format)) { + this.format = this.audioFormats[0].id; + } + } else if (this.downloadType === 'captions') { + const allowedFormats = new Set(this.captionFormats.map(f => f.id)); + if (resetForTypeChange || !allowedFormats.has(this.format)) { + this.format = 'srt'; + } + this.quality = 'best'; + } else { + this.format = 'jpg'; + this.quality = 'best'; + } + this.cookieService.set('metube_format', this.format, { expires: 3650 }); + this.cookieService.set('metube_codec', this.codec, { expires: 3650 }); + } + + private saveSelection(type: string) { + if (!type) return; + const selection = { + codec: this.codec, + format: this.format, + quality: this.quality, + subtitleLanguage: this.subtitleLanguage, + subtitleMode: this.subtitleMode, + }; + this.selectionsByType[type] = selection; + this.cookieService.set( + this.selectionCookiePrefix + type, + JSON.stringify(selection), + { expires: 3650 } + ); + } + + private restoreSelection(type: string) { + const saved = this.selectionsByType[type]; + if (!saved) return; + this.codec = saved.codec; + this.format = saved.format; + this.quality = saved.quality; + this.subtitleLanguage = saved.subtitleLanguage; + this.subtitleMode = saved.subtitleMode; + } + + private loadSavedSelections() { + for (const type of this.downloadTypes.map(t => t.id)) { + const key = this.selectionCookiePrefix + type; + if (!this.cookieService.check(key)) continue; + try { + const raw = this.cookieService.get(key); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + this.selectionsByType[type] = { + codec: String(parsed.codec ?? 'auto'), + format: String(parsed.format ?? ''), + quality: String(parsed.quality ?? 'best'), + subtitleLanguage: String(parsed.subtitleLanguage ?? 'en'), + subtitleMode: String(parsed.subtitleMode ?? 'prefer_manual'), + }; + } + } catch { + // Ignore malformed cookie values. + } + } } -} addDownload( url?: string, + downloadType?: string, + codec?: string, quality?: string, format?: string, folder?: string, @@ -444,12 +600,12 @@ export class App implements AfterViewInit, OnInit { autoStart?: boolean, splitByChapters?: boolean, chapterTemplate?: string, - subtitleFormat?: string, subtitleLanguage?: string, subtitleMode?: string, - videoCodec?: string, ) { url = url ?? this.addUrl + downloadType = downloadType ?? this.downloadType + codec = codec ?? this.codec quality = quality ?? this.quality format = format ?? this.format folder = folder ?? this.folder @@ -458,10 +614,8 @@ export class App implements AfterViewInit, OnInit { autoStart = autoStart ?? this.autoStart splitByChapters = splitByChapters ?? this.splitByChapters chapterTemplate = chapterTemplate ?? this.chapterTemplate - subtitleFormat = subtitleFormat ?? this.subtitleFormat subtitleLanguage = subtitleLanguage ?? this.subtitleLanguage subtitleMode = subtitleMode ?? this.subtitleMode - videoCodec = videoCodec ?? this.videoCodec // Validate chapter template if chapter splitting is enabled if (splitByChapters && !chapterTemplate.includes('%(section_number)')) { @@ -469,10 +623,10 @@ export class App implements AfterViewInit, OnInit { return; } - console.debug('Downloading: url=' + url + ' quality=' + quality + ' format=' + format + ' folder=' + folder + ' customNamePrefix=' + customNamePrefix + ' playlistItemLimit=' + playlistItemLimit + ' autoStart=' + autoStart + ' splitByChapters=' + splitByChapters + ' chapterTemplate=' + chapterTemplate + ' subtitleFormat=' + subtitleFormat + ' subtitleLanguage=' + subtitleLanguage + ' subtitleMode=' + subtitleMode + ' videoCodec=' + videoCodec); + console.debug('Downloading: url=' + url + ' downloadType=' + downloadType + ' codec=' + codec + ' quality=' + quality + ' format=' + format + ' folder=' + folder + ' customNamePrefix=' + customNamePrefix + ' playlistItemLimit=' + playlistItemLimit + ' autoStart=' + autoStart + ' splitByChapters=' + splitByChapters + ' chapterTemplate=' + chapterTemplate + ' subtitleLanguage=' + subtitleLanguage + ' subtitleMode=' + subtitleMode); this.addInProgress = true; this.cancelRequested = false; - this.downloads.add(url, quality, format, folder, customNamePrefix, playlistItemLimit, autoStart, splitByChapters, chapterTemplate, subtitleFormat, subtitleLanguage, subtitleMode, videoCodec).subscribe((status: Status) => { + this.downloads.add(url, downloadType, codec, quality, format, folder, customNamePrefix, playlistItemLimit, autoStart, splitByChapters, chapterTemplate, subtitleLanguage, subtitleMode).subscribe((status: Status) => { if (status.status === 'error' && !this.cancelRequested) { alert(`Error adding URL: ${status.msg}`); } else if (status.status !== 'error') { @@ -499,6 +653,8 @@ export class App implements AfterViewInit, OnInit { retryDownload(key: string, download: Download) { this.addDownload( download.url, + download.download_type, + download.codec, download.quality, download.format, download.folder, @@ -507,10 +663,8 @@ export class App implements AfterViewInit, OnInit { true, download.split_by_chapters, download.chapter_template, - download.subtitle_format, download.subtitle_language, download.subtitle_mode, - download.video_codec, ); this.downloads.delById('done', [key]).subscribe(); } @@ -560,7 +714,7 @@ export class App implements AfterViewInit, OnInit { buildDownloadLink(download: Download) { let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"]; - if (download.quality == 'audio' || download.filename.endsWith('.mp3')) { + if (download.download_type === 'audio' || download.filename.endsWith('.mp3')) { baseDir = this.downloads.configuration["PUBLIC_HOST_AUDIO_URL"]; } @@ -584,7 +738,7 @@ export class App implements AfterViewInit, OnInit { buildChapterDownloadLink(download: Download, chapterFilename: string) { let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"]; - if (download.quality == 'audio' || chapterFilename.endsWith('.mp3')) { + if (download.download_type === 'audio' || chapterFilename.endsWith('.mp3')) { baseDir = this.downloads.configuration["PUBLIC_HOST_AUDIO_URL"]; } @@ -655,10 +809,10 @@ export class App implements AfterViewInit, OnInit { } const url = urls[index]; 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, + // Pass current selection options to backend + this.downloads.add(url, this.downloadType, this.codec, this.quality, this.format, this.folder, this.customNamePrefix, this.playlistItemLimit, this.autoStart, this.splitByChapters, this.chapterTemplate, - this.subtitleFormat, this.subtitleLanguage, this.subtitleMode, this.videoCodec) + this.subtitleLanguage, this.subtitleMode) .subscribe({ next: (status: Status) => { if (status.status === 'error') { @@ -891,7 +1045,7 @@ export class App implements AfterViewInit, OnInit { private refreshCookieStatus() { this.downloads.getCookieStatus().subscribe(data => { - this.hasCookies = data?.has_cookies || false; + this.hasCookies = !!(data && typeof data === 'object' && 'has_cookies' in data && data.has_cookies); }); } diff --git a/ui/src/app/interfaces/download.ts b/ui/src/app/interfaces/download.ts index 6d69785..c988f71 100644 --- a/ui/src/app/interfaces/download.ts +++ b/ui/src/app/interfaces/download.ts @@ -3,6 +3,8 @@ export interface Download { id: string; title: string; url: string; + download_type: string; + codec?: string; quality: string; format: string; folder: string; @@ -10,10 +12,8 @@ export interface Download { playlist_item_limit: number; split_by_chapters?: boolean; chapter_template?: string; - subtitle_format?: string; subtitle_language?: string; subtitle_mode?: string; - video_codec?: string; status: string; msg: string; percent: number; @@ -25,5 +25,5 @@ export interface Download { size?: number; error?: string; deleting?: boolean; - chapter_files?: Array<{ filename: string, size: number }>; + chapter_files?: { filename: string, size: number }[]; } diff --git a/ui/src/app/interfaces/formats.ts b/ui/src/app/interfaces/formats.ts index 0ea73af..9b1b100 100644 --- a/ui/src/app/interfaces/formats.ts +++ b/ui/src/app/interfaces/formats.ts @@ -1,81 +1,77 @@ -import { Format } from "./format"; +import { Quality } from "./quality"; +export interface Option { + id: string; + text: string; +} -export const Formats: Format[] = [ - { - id: 'any', - text: 'Any', - qualities: [ - { id: 'best', text: 'Best' }, - { id: '2160', text: '2160p' }, - { id: '1440', text: '1440p' }, - { id: '1080', text: '1080p' }, - { id: '720', text: '720p' }, - { id: '480', text: '480p' }, - { id: '360', text: '360p' }, - { id: '240', text: '240p' }, - { id: 'worst', text: 'Worst' }, - { id: 'audio', text: 'Audio Only' }, - ], - }, - { - id: 'mp4', - text: 'MP4', - qualities: [ - { id: 'best', text: 'Best' }, - { id: 'best_ios', text: 'Best (iOS)' }, - { id: '2160', text: '2160p' }, - { id: '1440', text: '1440p' }, - { id: '1080', text: '1080p' }, - { id: '720', text: '720p' }, - { id: '480', text: '480p' }, - { id: '360', text: '360p' }, - { id: '240', text: '240p' }, - { id: 'worst', text: 'Worst' }, - ], - }, - { - id: 'm4a', - text: 'M4A', - qualities: [ - { id: 'best', text: 'Best' }, - { id: '192', text: '192 kbps' }, - { id: '128', text: '128 kbps' }, - ], - }, - { - id: 'mp3', - text: 'MP3', - qualities: [ - { id: 'best', text: 'Best' }, - { id: '320', text: '320 kbps' }, - { id: '192', text: '192 kbps' }, - { id: '128', text: '128 kbps' }, - ], - }, - { - id: 'opus', - text: 'OPUS', - qualities: [{ id: 'best', text: 'Best' }], - }, - { - id: 'wav', - text: 'WAV', - qualities: [{ id: 'best', text: 'Best' }], - }, - { - id: 'flac', - text: 'FLAC', - qualities: [{ id: 'best', text: 'Best' }], - }, - { - id: 'thumbnail', - text: 'Thumbnail', - qualities: [{ id: 'best', text: 'Best' }], - }, - { - id: 'captions', - text: 'Captions', - qualities: [{ id: 'best', text: 'Best' }], - }, +export interface AudioFormatOption extends Option { + qualities: Quality[]; +} + +export const DOWNLOAD_TYPES: Option[] = [ + { id: "video", text: "Video" }, + { id: "audio", text: "Audio" }, + { id: "captions", text: "Captions" }, + { id: "thumbnail", text: "Thumbnail" }, ]; + +export const VIDEO_CODECS: Option[] = [ + { id: "auto", text: "Auto" }, + { id: "h264", text: "H.264" }, + { id: "h265", text: "H.265 (HEVC)" }, + { id: "av1", text: "AV1" }, + { id: "vp9", text: "VP9" }, +]; + +export const VIDEO_FORMATS: Option[] = [ + { id: "any", text: "Auto" }, + { id: "mp4", text: "MP4" }, + { id: "ios", text: "iOS Compatible" }, +]; + +export const VIDEO_QUALITIES: Quality[] = [ + { id: "best", text: "Best" }, + { id: "2160", text: "2160p" }, + { id: "1440", text: "1440p" }, + { id: "1080", text: "1080p" }, + { id: "720", text: "720p" }, + { id: "480", text: "480p" }, + { id: "360", text: "360p" }, + { id: "240", text: "240p" }, + { id: "worst", text: "Worst" }, +]; + +export const AUDIO_FORMATS: AudioFormatOption[] = [ + { + id: "m4a", + text: "M4A", + qualities: [ + { id: "best", text: "Best" }, + { id: "192", text: "192 kbps" }, + { id: "128", text: "128 kbps" }, + ], + }, + { + id: "mp3", + text: "MP3", + qualities: [ + { id: "best", text: "Best" }, + { id: "320", text: "320 kbps" }, + { id: "192", text: "192 kbps" }, + { id: "128", text: "128 kbps" }, + ], + }, + { id: "opus", text: "OPUS", qualities: [{ id: "best", text: "Best" }] }, + { id: "wav", text: "WAV", qualities: [{ id: "best", text: "Best" }] }, + { id: "flac", text: "FLAC", qualities: [{ id: "best", text: "Best" }] }, +]; + +export const CAPTION_FORMATS: Option[] = [ + { id: "srt", text: "SRT" }, + { id: "txt", text: "TXT (Text only)" }, + { id: "vtt", text: "VTT" }, + { id: "ttml", text: "TTML" }, +]; + +export const THUMBNAIL_FORMATS: Option[] = [{ id: "jpg", text: "JPG" }]; diff --git a/ui/src/app/services/downloads.service.ts b/ui/src/app/services/downloads.service.ts index 94d629d..6bbd0be 100644 --- a/ui/src/app/services/downloads.service.ts +++ b/ui/src/app/services/downloads.service.ts @@ -109,6 +109,8 @@ export class DownloadsService { public add( url: string, + downloadType: string, + codec: string, quality: string, format: string, folder: string, @@ -117,13 +119,13 @@ export class DownloadsService { autoStart: boolean, splitByChapters: boolean, chapterTemplate: string, - subtitleFormat: string, subtitleLanguage: string, subtitleMode: string, - videoCodec: string, ) { return this.http.post('add', { url: url, + download_type: downloadType, + codec: codec, quality: quality, format: format, folder: folder, @@ -132,10 +134,8 @@ export class DownloadsService { auto_start: autoStart, split_by_chapters: splitByChapters, chapter_template: chapterTemplate, - subtitle_format: subtitleFormat, subtitle_language: subtitleLanguage, subtitle_mode: subtitleMode, - video_codec: videoCodec, }).pipe( catchError(this.handleHTTPError) ); @@ -174,6 +174,8 @@ export class DownloadsService { status: string; msg?: string; }> { + const defaultDownloadType = 'video'; + const defaultCodec = 'auto'; const defaultQuality = 'best'; const defaultFormat = 'mp4'; const defaultFolder = ''; @@ -182,14 +184,14 @@ export class DownloadsService { const defaultAutoStart = true; const defaultSplitByChapters = false; const defaultChapterTemplate = this.configuration['OUTPUT_TEMPLATE_CHAPTER']; - const defaultSubtitleFormat = 'srt'; const defaultSubtitleLanguage = 'en'; const defaultSubtitleMode = 'prefer_manual'; - const defaultVideoCodec = 'auto'; return new Promise((resolve, reject) => { this.add( url, + defaultDownloadType, + defaultCodec, defaultQuality, defaultFormat, defaultFolder, @@ -198,10 +200,8 @@ export class DownloadsService { defaultAutoStart, defaultSplitByChapters, defaultChapterTemplate, - defaultSubtitleFormat, defaultSubtitleLanguage, defaultSubtitleMode, - defaultVideoCodec, ) .subscribe({ next: (response) => resolve(response), @@ -221,19 +221,19 @@ export class DownloadsService { uploadCookies(file: File) { const formData = new FormData(); formData.append('cookies', file); - return this.http.post('upload-cookies', formData).pipe( + return this.http.post<{ status: string; msg?: string }>('upload-cookies', formData).pipe( catchError(this.handleHTTPError) ); } deleteCookies() { - return this.http.post('delete-cookies', {}).pipe( + return this.http.post<{ status: string; msg?: string }>('delete-cookies', {}).pipe( catchError(this.handleHTTPError) ); } getCookieStatus() { - return this.http.get('cookie-status').pipe( + return this.http.get<{ status: string; has_cookies: boolean }>('cookie-status').pipe( catchError(this.handleHTTPError) ); }