mirror of
https://github.com/alexta69/metube.git
synced 2026-03-18 22:43:51 +00:00
reoganize quality and codec selections
This commit is contained in:
@@ -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
|
||||||
|
|||||||
127
app/main.py
127
app/main.py
@@ -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))
|
||||||
|
|
||||||
|
|||||||
149
app/ytdl.py
149
app/ytdl.py
@@ -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}')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }];
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user