reoganize quality and codec selections

This commit is contained in:
Alex Shnitman
2026-03-13 19:47:36 +02:00
parent 56826d33fd
commit 5c321bfaca
8 changed files with 766 additions and 384 deletions

View File

@@ -21,91 +21,90 @@ def _normalize_subtitle_language(language: str) -> str:
return language or "en" 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: Args:
format (str): format selected download_type (str): selected content type (video, audio, captions, thumbnail)
quality (str): quality selected codec (str): selected video codec (auto, h264, h265, av1, vp9)
video_codec (str): video codec filter (auto, h264, h265, av1, vp9) format (str): selected output format/profile for type
quality (str): selected quality
Raises: Raises:
Exception: unknown quality, unknown format Exception: unknown type/format
Returns: 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:"): if format.startswith("custom:"):
return format[7:] return format[7:]
if format == "thumbnail": if download_type == "thumbnail":
# Quality is irrelevant in this case since we skip the download
return "bestaudio/best" return "bestaudio/best"
if format == "captions": if download_type == "captions":
# Quality is irrelevant in this case since we skip the download
return "bestaudio/best" return "bestaudio/best"
if format in AUDIO_FORMATS: if download_type == "audio":
# Audio quality needs to be set post-download, set in opts if format not in AUDIO_FORMATS:
raise Exception(f"Unknown audio format {format}")
return f"bestaudio[ext={format}]/bestaudio/best" return f"bestaudio[ext={format}]/bestaudio/best"
if format in ("mp4", "any"): if download_type == "video":
if quality == "audio": if format not in ("any", "mp4", "ios"):
return "bestaudio/best" raise Exception(f"Unknown video format {format}")
# video {res} {vfmt} + audio {afmt} {res} {vfmt} vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format in ("mp4", "ios") else ("", "")
vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format == "mp4" else ("", "") vres = f"[height<={quality}]" if quality not in ("best", "worst") else ""
vres = f"[height<={quality}]" if quality not in ("best", "best_ios", "worst") else ""
vcombo = vres + vfmt vcombo = vres + vfmt
codec_filter = CODEC_FILTER_MAP.get(video_codec, "") codec_filter = CODEC_FILTER_MAP.get(codec, "")
if quality == "best_ios": if format == "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.
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}" 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: 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{codec_filter}{vcombo}+bestaudio{afmt}/bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}"
return f"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( def get_opts(
download_type: str,
codec: str,
format: str, format: str,
quality: str, quality: str,
ytdl_opts: dict, ytdl_opts: dict,
subtitle_format: str = "srt",
subtitle_language: str = "en", subtitle_language: str = "en",
subtitle_mode: str = "prefer_manual", subtitle_mode: str = "prefer_manual",
video_codec: str = "auto",
) -> dict: ) -> dict:
""" """
Returns extra download options Returns extra yt-dlp options/postprocessors.
Mostly postprocessing options
Args: Args:
format (str): format selected download_type (str): selected content type
quality (str): quality of format selected (needed for some formats) 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 ytdl_opts (dict): current options selected
Returns: 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) opts = copy.deepcopy(ytdl_opts)
postprocessors = [] postprocessors = []
if format in AUDIO_FORMATS: if download_type == "audio":
postprocessors.append( postprocessors.append(
{ {
"key": "FFmpegExtractAudio", "key": "FFmpegExtractAudio",
@@ -114,7 +113,6 @@ def get_opts(
} }
) )
# Audio formats without thumbnail
if format not in ("wav") and "writethumbnail" not in opts: if format not in ("wav") and "writethumbnail" not in opts:
opts["writethumbnail"] = True opts["writethumbnail"] = True
postprocessors.append( postprocessors.append(
@@ -127,19 +125,18 @@ def get_opts(
postprocessors.append({"key": "FFmpegMetadata"}) postprocessors.append({"key": "FFmpegMetadata"})
postprocessors.append({"key": "EmbedThumbnail"}) postprocessors.append({"key": "EmbedThumbnail"})
if format == "thumbnail": if download_type == "thumbnail":
opts["skip_download"] = True opts["skip_download"] = True
opts["writethumbnail"] = True opts["writethumbnail"] = True
postprocessors.append( postprocessors.append(
{"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"} {"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"}
) )
if format == "captions": if download_type == "captions":
mode = _normalize_caption_mode(subtitle_mode) mode = _normalize_caption_mode(subtitle_mode)
language = _normalize_subtitle_language(subtitle_language) language = _normalize_subtitle_language(subtitle_language)
opts["skip_download"] = True opts["skip_download"] = True
requested_subtitle_format = (subtitle_format or "srt").lower() requested_subtitle_format = (format or "srt").lower()
# txt is a derived, non-timed format produced from SRT after download.
if requested_subtitle_format == "txt": if requested_subtitle_format == "txt":
requested_subtitle_format = "srt" requested_subtitle_format = "srt"
opts["subtitlesformat"] = requested_subtitle_format opts["subtitlesformat"] = requested_subtitle_format

View File

@@ -194,6 +194,68 @@ routes = web.RouteTableDef()
VALID_SUBTITLE_FORMATS = {'srt', 'txt', 'vtt', 'ttml', 'sbv', 'scc', 'dfxp'} VALID_SUBTITLE_FORMATS = {'srt', 'txt', 'vtt', 'ttml', 'sbv', 'scc', 'dfxp'}
VALID_SUBTITLE_MODES = {'auto_only', 'manual_only', 'prefer_manual', 'prefer_auto'} 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}$') 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): class Notifier(DownloadQueueNotifier):
async def added(self, dl): async def added(self, dl):
@@ -272,23 +334,24 @@ if config.YTDL_OPTIONS_FILE:
async def add(request): async def add(request):
log.info("Received request to add download") log.info("Received request to add download")
post = await request.json() post = await request.json()
post = _migrate_legacy_request(post)
log.info(f"Request data: {post}") log.info(f"Request data: {post}")
url = post.get('url') url = post.get('url')
quality = post.get('quality') download_type = post.get('download_type')
if not url or not quality: codec = post.get('codec')
log.error("Bad request: missing 'url' or 'quality'")
raise web.HTTPBadRequest()
format = post.get('format') 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') folder = post.get('folder')
custom_name_prefix = post.get('custom_name_prefix') custom_name_prefix = post.get('custom_name_prefix')
playlist_item_limit = post.get('playlist_item_limit') playlist_item_limit = post.get('playlist_item_limit')
auto_start = post.get('auto_start') auto_start = post.get('auto_start')
split_by_chapters = post.get('split_by_chapters') split_by_chapters = post.get('split_by_chapters')
chapter_template = post.get('chapter_template') chapter_template = post.get('chapter_template')
subtitle_format = post.get('subtitle_format')
subtitle_language = post.get('subtitle_language') subtitle_language = post.get('subtitle_language')
subtitle_mode = post.get('subtitle_mode') subtitle_mode = post.get('subtitle_mode')
video_codec = post.get('video_codec')
if custom_name_prefix is None: if custom_name_prefix is None:
custom_name_prefix = '' custom_name_prefix = ''
@@ -302,46 +365,72 @@ async def add(request):
split_by_chapters = False split_by_chapters = False
if chapter_template is None: if chapter_template is None:
chapter_template = config.OUTPUT_TEMPLATE_CHAPTER chapter_template = config.OUTPUT_TEMPLATE_CHAPTER
if subtitle_format is None:
subtitle_format = 'srt'
if subtitle_language is None: if subtitle_language is None:
subtitle_language = 'en' subtitle_language = 'en'
if subtitle_mode is None: if subtitle_mode is None:
subtitle_mode = 'prefer_manual' 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_language = str(subtitle_language).strip()
subtitle_mode = str(subtitle_mode).strip() subtitle_mode = str(subtitle_mode).strip()
if chapter_template and ('..' in chapter_template or chapter_template.startswith('/') or chapter_template.startswith('\\')): 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') 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): 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') 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: if subtitle_mode not in VALID_SUBTITLE_MODES:
raise web.HTTPBadRequest(reason=f'subtitle_mode must be one of {sorted(VALID_SUBTITLE_MODES)}') raise web.HTTPBadRequest(reason=f'subtitle_mode must be one of {sorted(VALID_SUBTITLE_MODES)}')
if video_codec is None: if download_type not in VALID_DOWNLOAD_TYPES:
video_codec = 'auto' raise web.HTTPBadRequest(reason=f'download_type must be one of {sorted(VALID_DOWNLOAD_TYPES)}')
video_codec = str(video_codec).strip().lower() if codec not in VALID_VIDEO_CODECS:
if video_codec not in ('auto', 'h264', 'h265', 'av1', 'vp9'): raise web.HTTPBadRequest(reason=f'codec must be one of {sorted(VALID_VIDEO_CODECS)}')
raise web.HTTPBadRequest(reason="video_codec must be one of ['auto', 'h264', 'h265', 'av1', 'vp9']")
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) playlist_item_limit = int(playlist_item_limit)
status = await dqueue.add( status = await dqueue.add(
url, url,
quality, download_type,
codec,
format, format,
quality,
folder, folder,
custom_name_prefix, custom_name_prefix,
playlist_item_limit, playlist_item_limit,
auto_start, auto_start,
split_by_chapters, split_by_chapters,
chapter_template, chapter_template,
subtitle_format,
subtitle_language, subtitle_language,
subtitle_mode, subtitle_mode,
video_codec=video_codec,
) )
return web.Response(text=serializer.encode(status)) return web.Response(text=serializer.encode(status))

View File

@@ -151,6 +151,8 @@ class DownloadInfo:
title, title,
url, url,
quality, quality,
download_type,
codec,
format, format,
folder, folder,
custom_name_prefix, custom_name_prefix,
@@ -159,15 +161,15 @@ class DownloadInfo:
playlist_item_limit, playlist_item_limit,
split_by_chapters, split_by_chapters,
chapter_template, chapter_template,
subtitle_format="srt",
subtitle_language="en", subtitle_language="en",
subtitle_mode="prefer_manual", subtitle_mode="prefer_manual",
video_codec="auto",
): ):
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}' 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.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
self.url = url self.url = url
self.quality = quality self.quality = quality
self.download_type = download_type
self.codec = codec
self.format = format self.format = format
self.folder = folder self.folder = folder
self.custom_name_prefix = custom_name_prefix self.custom_name_prefix = custom_name_prefix
@@ -181,11 +183,48 @@ class DownloadInfo:
self.playlist_item_limit = playlist_item_limit self.playlist_item_limit = playlist_item_limit
self.split_by_chapters = split_by_chapters self.split_by_chapters = split_by_chapters
self.chapter_template = chapter_template self.chapter_template = chapter_template
self.subtitle_format = subtitle_format
self.subtitle_language = subtitle_language self.subtitle_language = subtitle_language
self.subtitle_mode = subtitle_mode self.subtitle_mode = subtitle_mode
self.subtitle_files = [] 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: class Download:
manager = None manager = None
@@ -196,15 +235,20 @@ class Download:
self.output_template = output_template self.output_template = output_template
self.output_template_chapter = output_template_chapter self.output_template_chapter = output_template_chapter
self.info = info 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( self.ytdl_opts = get_opts(
getattr(info, 'download_type', 'video'),
getattr(info, 'codec', 'auto'),
format, format,
quality, quality,
ytdl_opts, ytdl_opts,
subtitle_format=getattr(info, 'subtitle_format', 'srt'),
subtitle_language=getattr(info, 'subtitle_language', 'en'), subtitle_language=getattr(info, 'subtitle_language', 'en'),
subtitle_mode=getattr(info, 'subtitle_mode', 'prefer_manual'), subtitle_mode=getattr(info, 'subtitle_mode', 'prefer_manual'),
video_codec=getattr(info, 'video_codec', 'auto'),
) )
if "impersonate" in self.ytdl_opts: if "impersonate" in self.ytdl_opts:
self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"]) 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 # For captions-only downloads, yt-dlp may still report a media-like
# filepath in MoveFiles. Capture subtitle outputs explicitly so the # filepath in MoveFiles. Capture subtitle outputs explicitly so the
# UI can link to real caption files. # 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 {} requested_subtitles = d.get('info_dict', {}).get('requested_subtitles', {}) or {}
for subtitle in requested_subtitles.values(): for subtitle in requested_subtitles.values():
if isinstance(subtitle, dict) and subtitle.get('filepath'): if isinstance(subtitle, dict) and subtitle.get('filepath'):
@@ -352,14 +396,14 @@ class Download:
rel_name = os.path.relpath(fileName, self.download_dir) rel_name = os.path.relpath(fileName, self.download_dir)
# For captions mode, ignore media-like placeholders and let subtitle_file # For captions mode, ignore media-like placeholders and let subtitle_file
# statuses define the final file shown in the UI. # statuses define the final file shown in the UI.
if self.info.format == 'captions': if getattr(self.info, 'download_type', '') == 'captions':
requested_subtitle_format = str(getattr(self.info, 'subtitle_format', '')).lower() 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') 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): if not rel_name.lower().endswith(allowed_caption_exts):
continue continue
self.info.filename = rel_name self.info.filename = rel_name
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None 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) self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename)
# Handle chapter files # Handle chapter files
@@ -384,7 +428,7 @@ class Download:
subtitle_output_file = subtitle_file subtitle_output_file = subtitle_file
# txt mode is derived from SRT by stripping cue metadata. # 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) converted_txt = _convert_srt_to_txt_file(subtitle_file)
if converted_txt: if converted_txt:
subtitle_output_file = converted_txt subtitle_output_file = converted_txt
@@ -400,9 +444,9 @@ class Download:
if not existing: if not existing:
self.info.subtitle_files.append({'filename': rel_path, 'size': file_size}) self.info.subtitle_files.append({'filename': rel_path, 'size': file_size})
# Prefer first subtitle file as the primary result link in captions mode. # 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 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.filename = rel_path
self.info.size = file_size self.info.size = file_size
@@ -434,7 +478,7 @@ class PersistentQueue:
def load(self): def load(self):
for k, v in self.saved_items(): 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): def exists(self, key):
return key in self.dict 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 {}), **({'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) }).extract_info(url, download=False)
def __calc_download_path(self, quality, format, folder): def __calc_download_path(self, download_type, folder):
base_directory = self.config.DOWNLOAD_DIR if (quality != 'audio' and format not in AUDIO_FORMATS) else self.config.AUDIO_DOWNLOAD_DIR base_directory = self.config.AUDIO_DOWNLOAD_DIR if download_type == 'audio' else self.config.DOWNLOAD_DIR
if folder: if folder:
if not self.config.CUSTOM_DIRS: 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.'} 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 return dldirectory, None
async def __add_download(self, dl, auto_start): 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: if error_message is not None:
return error_message 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 = 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( async def __add_entry(
self, self,
entry, entry,
quality, download_type,
codec,
format, format,
quality,
folder, folder,
custom_name_prefix, custom_name_prefix,
playlist_item_limit, playlist_item_limit,
auto_start, auto_start,
split_by_chapters, split_by_chapters,
chapter_template, chapter_template,
subtitle_format,
subtitle_language, subtitle_language,
subtitle_mode, subtitle_mode,
already, already,
_add_gen=None, _add_gen=None,
video_codec="auto",
): ):
if not entry: if not entry:
return {'status': 'error', 'msg': "Invalid/empty data was given."} return {'status': 'error', 'msg': "Invalid/empty data was given."}
@@ -709,20 +753,20 @@ class DownloadQueue:
log.debug('Processing as a url') log.debug('Processing as a url')
return await self.add( return await self.add(
entry['url'], entry['url'],
quality, download_type,
codec,
format, format,
quality,
folder, folder,
custom_name_prefix, custom_name_prefix,
playlist_item_limit, playlist_item_limit,
auto_start, auto_start,
split_by_chapters, split_by_chapters,
chapter_template, chapter_template,
subtitle_format,
subtitle_language, subtitle_language,
subtitle_mode, subtitle_mode,
already, already,
_add_gen, _add_gen,
video_codec,
) )
elif etype == 'playlist' or etype == 'channel': elif etype == 'playlist' or etype == 'channel':
log.debug(f'Processing as a {etype}') log.debug(f'Processing as a {etype}')
@@ -749,20 +793,20 @@ class DownloadQueue:
results.append( results.append(
await self.__add_entry( await self.__add_entry(
etr, etr,
quality, download_type,
codec,
format, format,
quality,
folder, folder,
custom_name_prefix, custom_name_prefix,
playlist_item_limit, playlist_item_limit,
auto_start, auto_start,
split_by_chapters, split_by_chapters,
chapter_template, chapter_template,
subtitle_format,
subtitle_language, subtitle_language,
subtitle_mode, subtitle_mode,
already, already,
_add_gen, _add_gen,
video_codec,
) )
) )
if any(res['status'] == 'error' for res in results): if any(res['status'] == 'error' for res in results):
@@ -776,22 +820,22 @@ class DownloadQueue:
return {'status': 'ok'} return {'status': 'ok'}
if not self.queue.exists(key): if not self.queue.exists(key):
dl = DownloadInfo( dl = DownloadInfo(
entry['id'], id=entry['id'],
entry.get('title') or entry['id'], title=entry.get('title') or entry['id'],
key, url=key,
quality, quality=quality,
format, download_type=download_type,
folder, codec=codec,
custom_name_prefix, format=format,
error, folder=folder,
entry, custom_name_prefix=custom_name_prefix,
playlist_item_limit, error=error,
split_by_chapters, entry=entry,
chapter_template, playlist_item_limit=playlist_item_limit,
subtitle_format, split_by_chapters=split_by_chapters,
subtitle_language, chapter_template=chapter_template,
subtitle_mode, subtitle_language=subtitle_language,
video_codec, subtitle_mode=subtitle_mode,
) )
await self.__add_download(dl, auto_start) await self.__add_download(dl, auto_start)
return {'status': 'ok'} return {'status': 'ok'}
@@ -800,26 +844,25 @@ class DownloadQueue:
async def add( async def add(
self, self,
url, url,
quality, download_type,
codec,
format, format,
quality,
folder, folder,
custom_name_prefix, custom_name_prefix,
playlist_item_limit, playlist_item_limit,
auto_start=True, auto_start=True,
split_by_chapters=False, split_by_chapters=False,
chapter_template=None, chapter_template=None,
subtitle_format="srt",
subtitle_language="en", subtitle_language="en",
subtitle_mode="prefer_manual", subtitle_mode="prefer_manual",
already=None, already=None,
_add_gen=None, _add_gen=None,
video_codec="auto",
): ):
log.info( 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'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} '
f'{subtitle_format=} {subtitle_language=} {subtitle_mode=} ' f'{subtitle_language=} {subtitle_mode=}'
f'{video_codec=}'
) )
if already is None: if already is None:
_add_gen = self._add_generation _add_gen = self._add_generation
@@ -836,20 +879,20 @@ class DownloadQueue:
return {'status': 'error', 'msg': str(exc)} return {'status': 'error', 'msg': str(exc)}
return await self.__add_entry( return await self.__add_entry(
entry, entry,
quality, download_type,
codec,
format, format,
quality,
folder, folder,
custom_name_prefix, custom_name_prefix,
playlist_item_limit, playlist_item_limit,
auto_start, auto_start,
split_by_chapters, split_by_chapters,
chapter_template, chapter_template,
subtitle_format,
subtitle_language, subtitle_language,
subtitle_mode, subtitle_mode,
already, already,
_add_gen, _add_gen,
video_codec,
) )
async def start_pending(self, ids): async def start_pending(self, ids):
@@ -889,7 +932,7 @@ class DownloadQueue:
if self.config.DELETE_FILE_ON_TRASHCAN: if self.config.DELETE_FILE_ON_TRASHCAN:
dl = self.done.get(id) dl = self.done.get(id)
try: 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)) os.remove(os.path.join(dldirectory, dl.info.filename))
except Exception as e: except Exception as e:
log.warn(f'deleting file for download {id} failed with error message {e!r}') log.warn(f'deleting file for download {id} failed with error message {e!r}')

View File

@@ -120,39 +120,205 @@
<!-- Options Row --> <!-- Options Row -->
<div class="row mb-3 g-3"> <div class="row mb-3 g-3">
<div class="col-md-4"> @if (downloadType === 'video') {
<div class="input-group"> <div class="col-md-3">
<span class="input-group-text">Quality</span> <div class="input-group">
<select class="form-select" <span class="input-group-text">Type</span>
name="quality" <select class="form-select"
[(ngModel)]="quality" name="downloadType"
(change)="qualityChanged()" [(ngModel)]="downloadType"
[disabled]="addInProgress || downloads.loading"> (change)="downloadTypeChanged()"
@for (q of qualities; track q) { [disabled]="addInProgress || downloads.loading">
<option [ngValue]="q.id">{{ q.text }}</option> @for (type of downloadTypes; track type.id) {
} <option [ngValue]="type.id">{{ type.text }}</option>
</select> }
</select>
</div>
</div> </div>
</div> <div class="col-md-3">
<div class="col-md-4"> <div class="input-group">
<div class="input-group"> <span class="input-group-text">Codec</span>
<span class="input-group-text">Format</span> <select class="form-select"
<select class="form-select" name="codec"
name="format" [(ngModel)]="codec"
[(ngModel)]="format" (change)="codecChanged()"
(change)="formatChanged()" [disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || downloads.loading"> @for (vc of videoCodecs; track vc.id) {
@for (f of formats; track f) { <option [ngValue]="vc.id">{{ vc.text }}</option>
<option [ngValue]="f.id">{{ f.text }}</option> }
} </select>
</select> </div>
</div> </div>
</div> <div class="col-md-3">
<div class="col-md-4"> <div class="input-group">
<span class="input-group-text">Format</span>
<select class="form-select"
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
[disabled]="addInProgress || downloads.loading">
@for (f of getFormatOptions(); track f.id) {
<option [ngValue]="f.id">{{ f.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-3">
<div class="input-group">
<span class="input-group-text">Quality</span>
<select class="form-select"
name="quality"
[(ngModel)]="quality"
(change)="qualityChanged()"
[disabled]="addInProgress || downloads.loading || !showQualitySelector()">
@for (q of qualities; track q.id) {
<option [ngValue]="q.id">{{ q.text }}</option>
}
</select>
</div>
</div>
} @else if (downloadType === 'audio') {
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Type</span>
<select class="form-select"
name="downloadType"
[(ngModel)]="downloadType"
(change)="downloadTypeChanged()"
[disabled]="addInProgress || downloads.loading">
@for (type of downloadTypes; track type.id) {
<option [ngValue]="type.id">{{ type.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Format</span>
<select class="form-select"
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
[disabled]="addInProgress || downloads.loading">
@for (f of getFormatOptions(); track f.id) {
<option [ngValue]="f.id">{{ f.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Quality</span>
<select class="form-select"
name="quality"
[(ngModel)]="quality"
(change)="qualityChanged()"
[disabled]="addInProgress || downloads.loading">
@for (q of qualities; track q.id) {
<option [ngValue]="q.id">{{ q.text }}</option>
}
</select>
</div>
</div>
} @else if (downloadType === 'captions') {
<div class="col-md-3">
<div class="input-group">
<span class="input-group-text">Type</span>
<select class="form-select"
name="downloadType"
[(ngModel)]="downloadType"
(change)="downloadTypeChanged()"
[disabled]="addInProgress || downloads.loading">
@for (type of downloadTypes; track type.id) {
<option [ngValue]="type.id">{{ type.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-3">
<div class="input-group">
<span class="input-group-text">Format</span>
<select class="form-select"
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Subtitle output format for captions mode">
@for (f of getFormatOptions(); track f.id) {
<option [ngValue]="f.id">{{ f.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-3">
<div class="input-group">
<span class="input-group-text">Language</span>
<input class="form-control"
type="text"
list="subtitleLanguageOptions"
name="subtitleLanguage"
[(ngModel)]="subtitleLanguage"
(change)="subtitleLanguageChanged()"
[disabled]="addInProgress || downloads.loading"
placeholder="e.g. en, es, zh-Hans"
ngbTooltip="Subtitle language (you can type any language code)">
<datalist id="subtitleLanguageOptions">
@for (lang of subtitleLanguages; track lang.id) {
<option [value]="lang.id">{{ lang.text }}</option>
}
</datalist>
</div>
</div>
<div class="col-md-3">
<div class="input-group">
<span class="input-group-text">Subtitle Source</span>
<select class="form-select"
name="subtitleMode"
[(ngModel)]="subtitleMode"
(change)="subtitleModeChanged()"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Choose manual, auto, or fallback preference for captions mode">
@for (mode of subtitleModes; track mode.id) {
<option [ngValue]="mode.id">{{ mode.text }}</option>
}
</select>
</div>
</div>
} @else {
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Type</span>
<select class="form-select"
name="downloadType"
[(ngModel)]="downloadType"
(change)="downloadTypeChanged()"
[disabled]="addInProgress || downloads.loading">
@for (type of downloadTypes; track type.id) {
<option [ngValue]="type.id">{{ type.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Format</span>
<input class="form-control" value="JPG" disabled>
</div>
</div>
}
</div>
<div class="row mb-3 g-3">
<div class="col-12 text-start">
<button type="button" <button type="button"
class="btn btn-outline-secondary w-100 h-100" class="btn btn-link p-0 text-decoration-none"
(click)="toggleAdvanced()"> (click)="toggleAdvanced()"
[attr.aria-expanded]="isAdvancedOpen"
aria-controls="advancedOptions">
Advanced Options Advanced Options
<fa-icon
[icon]="isAdvancedOpen ? faChevronDown : faChevronRight"
class="ms-1" />
</button> </button>
</div> </div>
</div> </div>
@@ -161,7 +327,7 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="collapse show" id="advancedOptions" [ngbCollapse]="!isAdvancedOpen"> <div class="collapse show" id="advancedOptions" [ngbCollapse]="!isAdvancedOpen">
<div class="card card-body"> <div class="py-2">
<!-- Advanced Settings --> <!-- Advanced Settings -->
<div class="row g-3 mb-2"> <div class="row g-3 mb-2">
<div class="col-md-6"> <div class="col-md-6">
@@ -225,77 +391,6 @@
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)"> ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
</div> </div>
</div> </div>
@if (format === 'captions') {
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Subtitles</span>
<select class="form-select"
name="subtitleFormat"
[(ngModel)]="subtitleFormat"
(change)="subtitleFormatChanged()"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Subtitle output format for captions mode">
@for (fmt of subtitleFormats; track fmt.id) {
<option [ngValue]="fmt.id">{{ fmt.text }}</option>
}
</select>
</div>
@if (subtitleFormat === 'txt') {
<div class="form-text">TXT is generated from SRT by stripping timestamps and cue numbers.</div>
}
</div>
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Language</span>
<input class="form-control"
type="text"
list="subtitleLanguageOptions"
name="subtitleLanguage"
[(ngModel)]="subtitleLanguage"
(change)="subtitleLanguageChanged()"
[disabled]="addInProgress || downloads.loading"
placeholder="e.g. en, es, zh-Hans"
ngbTooltip="Subtitle language (you can type any language code)">
<datalist id="subtitleLanguageOptions">
@for (lang of subtitleLanguages; track lang.id) {
<option [value]="lang.id">{{ lang.text }}</option>
}
</datalist>
</div>
</div>
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Subtitle Source</span>
<select class="form-select"
name="subtitleMode"
[(ngModel)]="subtitleMode"
(change)="subtitleModeChanged()"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Choose manual, auto, or fallback preference for captions mode">
@for (mode of subtitleModes; track mode.id) {
<option [ngValue]="mode.id">{{ mode.text }}</option>
}
</select>
</div>
</div>
}
@if (isVideoType()) {
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Video Codec</span>
<select class="form-select"
name="videoCodec"
[(ngModel)]="videoCodec"
(change)="videoCodecChanged()"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Prefer a specific video codec. Falls back to best available if not found.">
@for (vc of videoCodecs; track vc.id) {
<option [ngValue]="vc.id">{{ vc.text }}</option>
}
</select>
</div>
</div>
}
<div class="col-12"> <div class="col-12">
<div class="row g-2 align-items-center"> <div class="row g-2 align-items-center">
<div class="col-auto"> <div class="col-auto">
@@ -506,8 +601,9 @@
<app-master-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)" /> <app-master-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)" />
</th> </th>
<th scope="col">Video</th> <th scope="col">Video</th>
<th scope="col">Type</th>
<th scope="col">Quality</th> <th scope="col">Quality</th>
<th scope="col">Codec</th> <th scope="col">Codec / Format</th>
<th scope="col">File Size</th> <th scope="col">File Size</th>
<th scope="col">Downloaded</th> <th scope="col">Downloaded</th>
<th scope="col" style="width: 8rem;"></th> <th scope="col" style="width: 8rem;"></th>
@@ -536,15 +632,18 @@
<span ngbTooltip="{{buildResultItemTooltip(entry[1])}}">@if (!!entry[1].filename) { <span ngbTooltip="{{buildResultItemTooltip(entry[1])}}">@if (!!entry[1].filename) {
<a href="{{buildDownloadLink(entry[1])}}" target="_blank">{{ entry[1].title }}</a> <a href="{{buildDownloadLink(entry[1])}}" target="_blank">{{ entry[1].title }}</a>
} @else { } @else {
<span [style.cursor]="entry[1].status === 'error' ? 'pointer' : 'default'" @if (entry[1].status === 'error') {
(click)="entry[1].status === 'error' ? toggleErrorDetail(entry[0]) : null"> <button type="button" class="btn btn-link p-0 text-start align-baseline" (click)="toggleErrorDetail(entry[0])">
{{entry[1].title}} {{entry[1].title}}
@if (entry[1].status === 'error' && !isErrorExpanded(entry[0])) { @if (!isErrorExpanded(entry[0])) {
<small class="text-danger ms-2"> <small class="text-danger ms-2">
<fa-icon [icon]="faChevronRight" size="xs" class="me-1" />Click for details <fa-icon [icon]="faChevronRight" size="xs" class="me-1" />Click for details
</small> </small>
} }
</span> </button>
} @else {
<span>{{entry[1].title}}</span>
}
}</span> }</span>
@if (entry[1].status === 'error' && isErrorExpanded(entry[0])) { @if (entry[1].status === 'error' && isErrorExpanded(entry[0])) {
<div class="alert alert-danger py-2 px-3 mt-2 mb-0 small" style="border-left: 4px solid var(--bs-danger);"> <div class="alert alert-danger py-2 px-3 mt-2 mb-0 small" style="border-left: 4px solid var(--bs-danger);">
@@ -571,6 +670,9 @@
</div> </div>
} }
</td> </td>
<td class="text-nowrap">
{{ downloadTypeLabel(entry[1]) }}
</td>
<td class="text-nowrap"> <td class="text-nowrap">
{{ formatQualityLabel(entry[1]) }} {{ formatQualityLabel(entry[1]) }}
</td> </td>
@@ -613,6 +715,7 @@
</td> </td>
<td></td> <td></td>
<td></td> <td></td>
<td></td>
<td> <td>
@if (chapterFile.size) { @if (chapterFile.size) {
<span>{{ chapterFile.size | fileSize }}</span> <span>{{ chapterFile.size | fileSize }}</span>

View File

@@ -6,12 +6,27 @@ import { FormsModule } from '@angular/forms';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { NgSelectModule } from '@ng-select/ng-select'; 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 { faGithub } from '@fortawesome/free-brands-svg-icons';
import { CookieService } from 'ngx-cookie-service'; import { CookieService } from 'ngx-cookie-service';
import { DownloadsService } from './services/downloads.service'; import { DownloadsService } from './services/downloads.service';
import { Themes } from './theme'; 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 { EtaPipe, SpeedPipe, FileSizePipe } from './pipes';
import { MasterCheckboxComponent , SlaveCheckboxComponent} from './components/'; import { MasterCheckboxComponent , SlaveCheckboxComponent} from './components/';
@@ -40,8 +55,15 @@ export class App implements AfterViewInit, OnInit {
private http = inject(HttpClient); private http = inject(HttpClient);
addUrl!: string; 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[]; qualities!: Quality[];
downloadType: string;
codec: string;
quality: string; quality: string;
format: string; format: string;
folder!: string; folder!: string;
@@ -50,10 +72,8 @@ export class App implements AfterViewInit, OnInit {
playlistItemLimit!: number; playlistItemLimit!: number;
splitByChapters: boolean; splitByChapters: boolean;
chapterTemplate: string; chapterTemplate: string;
subtitleFormat: string;
subtitleLanguage: string; subtitleLanguage: string;
subtitleMode: string; subtitleMode: string;
videoCodec: string;
addInProgress = false; addInProgress = false;
cancelRequested = false; cancelRequested = false;
hasCookies = false; hasCookies = false;
@@ -72,9 +92,18 @@ export class App implements AfterViewInit, OnInit {
metubeVersion: string | null = null; metubeVersion: string | null = null;
isAdvancedOpen = false; isAdvancedOpen = false;
sortAscending = false; sortAscending = false;
expandedErrors: Set<string> = new Set(); expandedErrors: Set<string> = new Set<string>();
cachedSortedDone: [string, Download][] = []; cachedSortedDone: [string, Download][] = [];
lastCopiedErrorId: string | null = null; lastCopiedErrorId: string | null = null;
private previousDownloadType = 'video';
private selectionsByType: Record<string, {
codec: string;
format: string;
quality: string;
subtitleLanguage: string;
subtitleMode: string;
}> = {};
private readonly selectionCookiePrefix = 'metube_selection_';
// Download metrics // Download metrics
activeDownloads = 0; activeDownloads = 0;
@@ -112,13 +141,8 @@ export class App implements AfterViewInit, OnInit {
faSortAmountDown = faSortAmountDown; faSortAmountDown = faSortAmountDown;
faSortAmountUp = faSortAmountUp; faSortAmountUp = faSortAmountUp;
faChevronRight = faChevronRight; faChevronRight = faChevronRight;
faChevronDown = faChevronDown;
faUpload = faUpload; faUpload = faUpload;
subtitleFormats = [
{ id: 'srt', text: 'SRT' },
{ id: 'txt', text: 'TXT (Text only)' },
{ id: 'vtt', text: 'VTT' },
{ id: 'ttml', text: 'TTML' }
];
subtitleLanguages = [ subtitleLanguages = [
{ id: 'en', text: 'English' }, { id: 'en', text: 'English' },
{ id: 'ar', text: 'Arabic' }, { id: 'ar', text: 'Arabic' },
@@ -170,39 +194,35 @@ export class App implements AfterViewInit, OnInit {
{ id: 'manual_only', text: 'Manual Only' }, { id: 'manual_only', text: 'Manual Only' },
{ id: 'auto_only', text: 'Auto 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() { 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'; 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.quality = this.cookieService.get('metube_quality') || 'best';
this.autoStart = this.cookieService.get('metube_auto_start') !== 'false'; this.autoStart = this.cookieService.get('metube_auto_start') !== 'false';
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true'; this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
// Will be set from backend configuration, use empty string as placeholder // Will be set from backend configuration, use empty string as placeholder
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || ''; 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.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en';
this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual'; 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)); const allowedVideoCodecs = new Set(this.videoCodecs.map(c => c.id));
if (!allowedVideoCodecs.has(this.videoCodec)) { if (!allowedDownloadTypes.has(this.downloadType)) {
this.videoCodec = 'auto'; 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)); const allowedSubtitleModes = new Set(this.subtitleModes.map(mode => mode.id));
if (!allowedSubtitleFormats.has(this.subtitleFormat)) {
this.subtitleFormat = 'srt';
}
if (!allowedSubtitleModes.has(this.subtitleMode)) { if (!allowedSubtitleModes.has(this.subtitleMode)) {
this.subtitleMode = 'prefer_manual'; 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.sortAscending = this.cookieService.get('metube_sort_ascending') === 'true';
this.activeTheme = this.getPreferredTheme(this.cookieService); this.activeTheme = this.getPreferredTheme(this.cookieService);
@@ -223,7 +243,7 @@ export class App implements AfterViewInit, OnInit {
ngOnInit() { ngOnInit() {
this.downloads.getCookieStatus().subscribe(data => { 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.getConfiguration();
this.getYtdlOptionsUpdateTime(); this.getYtdlOptionsUpdateTime();
@@ -268,10 +288,27 @@ export class App implements AfterViewInit, OnInit {
qualityChanged() { qualityChanged() {
this.cookieService.set('metube_quality', this.quality, { expires: 3650 }); this.cookieService.set('metube_quality', this.quality, { expires: 3650 });
this.saveSelection(this.downloadType);
// Re-trigger custom directory change // Re-trigger custom directory change
this.downloads.customDirsChanged.next(this.downloads.customDirs); 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() { showAdvanced() {
return this.downloads.configuration['CUSTOM_DIRS']; return this.downloads.configuration['CUSTOM_DIRS'];
} }
@@ -284,7 +321,7 @@ export class App implements AfterViewInit, OnInit {
} }
isAudioType() { 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<string[]> { getMatchingCustomDir() : Observable<string[]> {
@@ -358,8 +395,8 @@ export class App implements AfterViewInit, OnInit {
formatChanged() { formatChanged() {
this.cookieService.set('metube_format', this.format, { expires: 3650 }); 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 // Re-trigger custom directory change
this.downloads.customDirsChanged.next(this.downloads.customDirs); 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 }); this.cookieService.set('metube_chapter_template', this.chapterTemplate, { expires: 3650 });
} }
subtitleFormatChanged() {
this.cookieService.set('metube_subtitle_format', this.subtitleFormat, { expires: 3650 });
}
subtitleLanguageChanged() { subtitleLanguageChanged() {
this.cookieService.set('metube_subtitle_language', this.subtitleLanguage, { expires: 3650 }); this.cookieService.set('metube_subtitle_language', this.subtitleLanguage, { expires: 3650 });
this.saveSelection(this.downloadType);
} }
subtitleModeChanged() { subtitleModeChanged() {
this.cookieService.set('metube_subtitle_mode', this.subtitleMode, { expires: 3650 }); this.cookieService.set('metube_subtitle_mode', this.subtitleMode, { expires: 3650 });
} this.saveSelection(this.downloadType);
videoCodecChanged() {
this.cookieService.set('metube_video_codec', this.videoCodec, { expires: 3650 });
} }
isVideoType() { isVideoType() {
return (this.format === 'any' || this.format === 'mp4') && this.quality !== 'audio'; return this.downloadType === 'video';
} }
formatQualityLabel(download: Download): string { formatQualityLabel(download: Download): string {
if (download.download_type === 'captions' || download.download_type === 'thumbnail') {
return '-';
}
const q = download.quality; const q = download.quality;
if (!q) return ''; if (!q) return '';
if (/^\d+$/.test(q) && download.download_type === 'audio') return `${q} kbps`;
if (/^\d+$/.test(q)) return `${q}p`; if (/^\d+$/.test(q)) return `${q}p`;
if (q === 'best_ios') return 'Best (iOS)';
return q.charAt(0).toUpperCase() + q.slice(1); 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 { 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'; if (!codec || codec === 'auto') return 'Auto';
return this.videoCodecs.find(c => c.id === codec)?.text ?? codec; return this.videoCodecs.find(c => c.id === codec)?.text ?? codec;
} }
@@ -425,17 +468,130 @@ export class App implements AfterViewInit, OnInit {
} }
setQualities() { setQualities() {
// qualities for specific format if (this.downloadType === 'video') {
const format = this.formats.find(el => el.id == this.format) this.qualities = this.format === 'ios'
if (format) { ? [{ id: 'best', text: 'Best' }]
this.qualities = format.qualities : VIDEO_QUALITIES;
const exists = this.qualities.find(el => el.id === this.quality) } else if (this.downloadType === 'audio') {
this.quality = exists ? this.quality : 'best' 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( addDownload(
url?: string, url?: string,
downloadType?: string,
codec?: string,
quality?: string, quality?: string,
format?: string, format?: string,
folder?: string, folder?: string,
@@ -444,12 +600,12 @@ export class App implements AfterViewInit, OnInit {
autoStart?: boolean, autoStart?: boolean,
splitByChapters?: boolean, splitByChapters?: boolean,
chapterTemplate?: string, chapterTemplate?: string,
subtitleFormat?: string,
subtitleLanguage?: string, subtitleLanguage?: string,
subtitleMode?: string, subtitleMode?: string,
videoCodec?: string,
) { ) {
url = url ?? this.addUrl url = url ?? this.addUrl
downloadType = downloadType ?? this.downloadType
codec = codec ?? this.codec
quality = quality ?? this.quality quality = quality ?? this.quality
format = format ?? this.format format = format ?? this.format
folder = folder ?? this.folder folder = folder ?? this.folder
@@ -458,10 +614,8 @@ export class App implements AfterViewInit, OnInit {
autoStart = autoStart ?? this.autoStart autoStart = autoStart ?? this.autoStart
splitByChapters = splitByChapters ?? this.splitByChapters splitByChapters = splitByChapters ?? this.splitByChapters
chapterTemplate = chapterTemplate ?? this.chapterTemplate chapterTemplate = chapterTemplate ?? this.chapterTemplate
subtitleFormat = subtitleFormat ?? this.subtitleFormat
subtitleLanguage = subtitleLanguage ?? this.subtitleLanguage subtitleLanguage = subtitleLanguage ?? this.subtitleLanguage
subtitleMode = subtitleMode ?? this.subtitleMode subtitleMode = subtitleMode ?? this.subtitleMode
videoCodec = videoCodec ?? this.videoCodec
// Validate chapter template if chapter splitting is enabled // Validate chapter template if chapter splitting is enabled
if (splitByChapters && !chapterTemplate.includes('%(section_number)')) { if (splitByChapters && !chapterTemplate.includes('%(section_number)')) {
@@ -469,10 +623,10 @@ export class App implements AfterViewInit, OnInit {
return; 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.addInProgress = true;
this.cancelRequested = false; 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) { if (status.status === 'error' && !this.cancelRequested) {
alert(`Error adding URL: ${status.msg}`); alert(`Error adding URL: ${status.msg}`);
} else if (status.status !== 'error') { } else if (status.status !== 'error') {
@@ -499,6 +653,8 @@ export class App implements AfterViewInit, OnInit {
retryDownload(key: string, download: Download) { retryDownload(key: string, download: Download) {
this.addDownload( this.addDownload(
download.url, download.url,
download.download_type,
download.codec,
download.quality, download.quality,
download.format, download.format,
download.folder, download.folder,
@@ -507,10 +663,8 @@ export class App implements AfterViewInit, OnInit {
true, true,
download.split_by_chapters, download.split_by_chapters,
download.chapter_template, download.chapter_template,
download.subtitle_format,
download.subtitle_language, download.subtitle_language,
download.subtitle_mode, download.subtitle_mode,
download.video_codec,
); );
this.downloads.delById('done', [key]).subscribe(); this.downloads.delById('done', [key]).subscribe();
} }
@@ -560,7 +714,7 @@ export class App implements AfterViewInit, OnInit {
buildDownloadLink(download: Download) { buildDownloadLink(download: Download) {
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"]; 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"]; baseDir = this.downloads.configuration["PUBLIC_HOST_AUDIO_URL"];
} }
@@ -584,7 +738,7 @@ export class App implements AfterViewInit, OnInit {
buildChapterDownloadLink(download: Download, chapterFilename: string) { buildChapterDownloadLink(download: Download, chapterFilename: string) {
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"]; 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"]; baseDir = this.downloads.configuration["PUBLIC_HOST_AUDIO_URL"];
} }
@@ -655,10 +809,10 @@ export class App implements AfterViewInit, OnInit {
} }
const url = urls[index]; const url = urls[index];
this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`; this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`;
// Now pass the selected quality, format, folder, etc. to the add() method // Pass current selection options to backend
this.downloads.add(url, this.quality, this.format, this.folder, this.customNamePrefix, 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.playlistItemLimit, this.autoStart, this.splitByChapters, this.chapterTemplate,
this.subtitleFormat, this.subtitleLanguage, this.subtitleMode, this.videoCodec) this.subtitleLanguage, this.subtitleMode)
.subscribe({ .subscribe({
next: (status: Status) => { next: (status: Status) => {
if (status.status === 'error') { if (status.status === 'error') {
@@ -891,7 +1045,7 @@ export class App implements AfterViewInit, OnInit {
private refreshCookieStatus() { private refreshCookieStatus() {
this.downloads.getCookieStatus().subscribe(data => { this.downloads.getCookieStatus().subscribe(data => {
this.hasCookies = data?.has_cookies || false; this.hasCookies = !!(data && typeof data === 'object' && 'has_cookies' in data && data.has_cookies);
}); });
} }

View File

@@ -3,6 +3,8 @@ export interface Download {
id: string; id: string;
title: string; title: string;
url: string; url: string;
download_type: string;
codec?: string;
quality: string; quality: string;
format: string; format: string;
folder: string; folder: string;
@@ -10,10 +12,8 @@ export interface Download {
playlist_item_limit: number; playlist_item_limit: number;
split_by_chapters?: boolean; split_by_chapters?: boolean;
chapter_template?: string; chapter_template?: string;
subtitle_format?: string;
subtitle_language?: string; subtitle_language?: string;
subtitle_mode?: string; subtitle_mode?: string;
video_codec?: string;
status: string; status: string;
msg: string; msg: string;
percent: number; percent: number;
@@ -25,5 +25,5 @@ export interface Download {
size?: number; size?: number;
error?: string; error?: string;
deleting?: boolean; deleting?: boolean;
chapter_files?: Array<{ filename: string, size: number }>; chapter_files?: { filename: string, size: number }[];
} }

View File

@@ -1,81 +1,77 @@
import { Format } from "./format"; import { Quality } from "./quality";
export interface Option {
id: string;
text: string;
}
export const Formats: Format[] = [ export interface AudioFormatOption extends Option {
{ qualities: Quality[];
id: 'any', }
text: 'Any',
qualities: [ export const DOWNLOAD_TYPES: Option[] = [
{ id: 'best', text: 'Best' }, { id: "video", text: "Video" },
{ id: '2160', text: '2160p' }, { id: "audio", text: "Audio" },
{ id: '1440', text: '1440p' }, { id: "captions", text: "Captions" },
{ id: '1080', text: '1080p' }, { id: "thumbnail", text: "Thumbnail" },
{ 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 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" }];

View File

@@ -109,6 +109,8 @@ export class DownloadsService {
public add( public add(
url: string, url: string,
downloadType: string,
codec: string,
quality: string, quality: string,
format: string, format: string,
folder: string, folder: string,
@@ -117,13 +119,13 @@ export class DownloadsService {
autoStart: boolean, autoStart: boolean,
splitByChapters: boolean, splitByChapters: boolean,
chapterTemplate: string, chapterTemplate: string,
subtitleFormat: string,
subtitleLanguage: string, subtitleLanguage: string,
subtitleMode: string, subtitleMode: string,
videoCodec: string,
) { ) {
return this.http.post<Status>('add', { return this.http.post<Status>('add', {
url: url, url: url,
download_type: downloadType,
codec: codec,
quality: quality, quality: quality,
format: format, format: format,
folder: folder, folder: folder,
@@ -132,10 +134,8 @@ export class DownloadsService {
auto_start: autoStart, auto_start: autoStart,
split_by_chapters: splitByChapters, split_by_chapters: splitByChapters,
chapter_template: chapterTemplate, chapter_template: chapterTemplate,
subtitle_format: subtitleFormat,
subtitle_language: subtitleLanguage, subtitle_language: subtitleLanguage,
subtitle_mode: subtitleMode, subtitle_mode: subtitleMode,
video_codec: videoCodec,
}).pipe( }).pipe(
catchError(this.handleHTTPError) catchError(this.handleHTTPError)
); );
@@ -174,6 +174,8 @@ export class DownloadsService {
status: string; status: string;
msg?: string; msg?: string;
}> { }> {
const defaultDownloadType = 'video';
const defaultCodec = 'auto';
const defaultQuality = 'best'; const defaultQuality = 'best';
const defaultFormat = 'mp4'; const defaultFormat = 'mp4';
const defaultFolder = ''; const defaultFolder = '';
@@ -182,14 +184,14 @@ export class DownloadsService {
const defaultAutoStart = true; const defaultAutoStart = true;
const defaultSplitByChapters = false; const defaultSplitByChapters = false;
const defaultChapterTemplate = this.configuration['OUTPUT_TEMPLATE_CHAPTER']; const defaultChapterTemplate = this.configuration['OUTPUT_TEMPLATE_CHAPTER'];
const defaultSubtitleFormat = 'srt';
const defaultSubtitleLanguage = 'en'; const defaultSubtitleLanguage = 'en';
const defaultSubtitleMode = 'prefer_manual'; const defaultSubtitleMode = 'prefer_manual';
const defaultVideoCodec = 'auto';
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.add( this.add(
url, url,
defaultDownloadType,
defaultCodec,
defaultQuality, defaultQuality,
defaultFormat, defaultFormat,
defaultFolder, defaultFolder,
@@ -198,10 +200,8 @@ export class DownloadsService {
defaultAutoStart, defaultAutoStart,
defaultSplitByChapters, defaultSplitByChapters,
defaultChapterTemplate, defaultChapterTemplate,
defaultSubtitleFormat,
defaultSubtitleLanguage, defaultSubtitleLanguage,
defaultSubtitleMode, defaultSubtitleMode,
defaultVideoCodec,
) )
.subscribe({ .subscribe({
next: (response) => resolve(response), next: (response) => resolve(response),
@@ -221,19 +221,19 @@ export class DownloadsService {
uploadCookies(file: File) { uploadCookies(file: File) {
const formData = new FormData(); const formData = new FormData();
formData.append('cookies', file); formData.append('cookies', file);
return this.http.post<any>('upload-cookies', formData).pipe( return this.http.post<{ status: string; msg?: string }>('upload-cookies', formData).pipe(
catchError(this.handleHTTPError) catchError(this.handleHTTPError)
); );
} }
deleteCookies() { deleteCookies() {
return this.http.post<any>('delete-cookies', {}).pipe( return this.http.post<{ status: string; msg?: string }>('delete-cookies', {}).pipe(
catchError(this.handleHTTPError) catchError(this.handleHTTPError)
); );
} }
getCookieStatus() { getCookieStatus() {
return this.http.get<any>('cookie-status').pipe( return this.http.get<{ status: string; has_cookies: boolean }>('cookie-status').pipe(
catchError(this.handleHTTPError) catchError(this.handleHTTPError)
); );
} }