Compare commits

...

6 Commits

Author SHA1 Message Date
Alex Shnitman 475aeb91bf add status indicator when adding a URL 2026-03-13 19:49:18 +02:00
Alex Shnitman 5c321bfaca reoganize quality and codec selections 2026-03-13 19:47:36 +02:00
CyCl0ne 56826d33fd Add video codec selector and codec/quality columns in done list
Allow users to prefer a specific video codec (H.264, H.265, AV1, VP9)
when adding downloads. The selector filters available formats via
yt-dlp format strings, falling back to best available if the preferred
codec is not found. The completed downloads table now shows Quality
and Codec columns.
2026-03-09 08:59:01 +01:00
Alex Shnitman 3b0eaad67e Merge branch 'dependabot/github_actions/github-actions-292e5e2d7a' of https://github.com/alexta69/metube into feature/download-timestamp 2026-03-08 22:19:01 +02:00
dependabot[bot] 2a166ccf1f Bump the github-actions group with 4 updates
Bumps the github-actions group with 4 updates: [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action), [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action), [docker/login-action](https://github.com/docker/login-action) and [docker/build-push-action](https://github.com/docker/build-push-action).


Updates `docker/setup-qemu-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-08 16:12:41 +00:00
CyCl0ne 3bbe1e8424 Add "Downloaded" timestamp column to completed downloads list
Display the completion time for each download in the done list.
The backend already stores a nanosecond timestamp on DownloadInfo;                                     this wires it up to the frontend using Angular's DatePipe.
2026-03-08 14:56:16 +01:00
11 changed files with 837 additions and 328 deletions
+5 -5
View File
@@ -18,26 +18,26 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- -
name: Login to DockerHub name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- -
name: Login to GitHub Container Registry name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- -
name: Build and push name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
+50 -39
View File
@@ -3,6 +3,13 @@ import copy
AUDIO_FORMATS = ("m4a", "mp3", "opus", "wav", "flac") AUDIO_FORMATS = ("m4a", "mp3", "opus", "wav", "flac")
CAPTION_MODES = ("auto_only", "manual_only", "prefer_manual", "prefer_auto") CAPTION_MODES = ("auto_only", "manual_only", "prefer_manual", "prefer_auto")
CODEC_FILTER_MAP = {
'h264': "[vcodec~='^(h264|avc)']",
'h265': "[vcodec~='^(h265|hevc)']",
'av1': "[vcodec~='^av0?1']",
'vp9': "[vcodec~='^vp0?9']",
}
def _normalize_caption_mode(mode: str) -> str: def _normalize_caption_mode(mode: str) -> str:
mode = (mode or "").strip() mode = (mode or "").strip()
@@ -14,84 +21,90 @@ def _normalize_subtitle_language(language: str) -> str:
return language or "en" return language or "en"
def get_format(format: str, quality: str) -> 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)
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(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:
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",
) -> 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",
@@ -100,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(
@@ -113,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
+109 -12
View File
@@ -194,6 +194,68 @@ routes = web.RouteTableDef()
VALID_SUBTITLE_FORMATS = {'srt', 'txt', 'vtt', 'ttml', 'sbv', 'scc', 'dfxp'} VALID_SUBTITLE_FORMATS = {'srt', 'txt', 'vtt', 'ttml', 'sbv', 'scc', 'dfxp'}
VALID_SUBTITLE_MODES = {'auto_only', 'manual_only', 'prefer_manual', 'prefer_auto'} VALID_SUBTITLE_MODES = {'auto_only', 'manual_only', 'prefer_manual', 'prefer_auto'}
SUBTITLE_LANGUAGE_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9-]{0,34}$') SUBTITLE_LANGUAGE_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9-]{0,34}$')
VALID_DOWNLOAD_TYPES = {'video', 'audio', 'captions', 'thumbnail'}
VALID_VIDEO_CODECS = {'auto', 'h264', 'h265', 'av1', 'vp9'}
VALID_VIDEO_FORMATS = {'any', 'mp4', 'ios'}
VALID_AUDIO_FORMATS = {'m4a', 'mp3', 'opus', 'wav', 'flac'}
VALID_THUMBNAIL_FORMATS = {'jpg'}
def _migrate_legacy_request(post: dict) -> dict:
"""
BACKWARD COMPATIBILITY: Translate old API request schema into the new one.
Old API:
format (any/mp4/m4a/mp3/opus/wav/flac/thumbnail/captions)
quality
video_codec
subtitle_format (only when format=captions)
New API:
download_type (video/audio/captions/thumbnail)
codec
format
quality
"""
if "download_type" in post:
return post
old_format = str(post.get("format") or "any").strip().lower()
old_quality = str(post.get("quality") or "best").strip().lower()
old_video_codec = str(post.get("video_codec") or "auto").strip().lower()
if old_format in VALID_AUDIO_FORMATS:
post["download_type"] = "audio"
post["codec"] = "auto"
post["format"] = old_format
elif old_format == "thumbnail":
post["download_type"] = "thumbnail"
post["codec"] = "auto"
post["format"] = "jpg"
post["quality"] = "best"
elif old_format == "captions":
post["download_type"] = "captions"
post["codec"] = "auto"
post["format"] = str(post.get("subtitle_format") or "srt").strip().lower()
post["quality"] = "best"
else:
# old_format is usually any/mp4 (legacy video path)
post["download_type"] = "video"
post["codec"] = old_video_codec
if old_quality == "best_ios":
post["format"] = "ios"
post["quality"] = "best"
elif old_quality == "audio":
# Legacy "audio only" under video format maps to m4a audio.
post["download_type"] = "audio"
post["codec"] = "auto"
post["format"] = "m4a"
post["quality"] = "best"
else:
post["format"] = old_format
post["quality"] = old_quality
return post
class Notifier(DownloadQueueNotifier): class Notifier(DownloadQueueNotifier):
async def added(self, dl): async def added(self, dl):
@@ -272,20 +334,22 @@ 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')
@@ -301,37 +365,70 @@ 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 download_type not in VALID_DOWNLOAD_TYPES:
raise web.HTTPBadRequest(reason=f'download_type must be one of {sorted(VALID_DOWNLOAD_TYPES)}')
if codec not in VALID_VIDEO_CODECS:
raise web.HTTPBadRequest(reason=f'codec must be one of {sorted(VALID_VIDEO_CODECS)}')
if download_type == 'video':
if format not in VALID_VIDEO_FORMATS:
raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_VIDEO_FORMATS)} for video')
if quality not in {'best', 'worst', '2160', '1440', '1080', '720', '480', '360', '240'}:
raise web.HTTPBadRequest(reason="quality must be one of ['best', '2160', '1440', '1080', '720', '480', '360', '240', 'worst'] for video")
elif download_type == 'audio':
if format not in VALID_AUDIO_FORMATS:
raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_AUDIO_FORMATS)} for audio')
allowed_audio_qualities = {'best'}
if format == 'mp3':
allowed_audio_qualities |= {'320', '192', '128'}
elif format == 'm4a':
allowed_audio_qualities |= {'192', '128'}
if quality not in allowed_audio_qualities:
raise web.HTTPBadRequest(reason=f'quality must be one of {sorted(allowed_audio_qualities)} for format {format}')
codec = 'auto'
elif download_type == 'captions':
if format not in VALID_SUBTITLE_FORMATS:
raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_SUBTITLE_FORMATS)} for captions')
quality = 'best'
codec = 'auto'
elif download_type == 'thumbnail':
if format not in VALID_THUMBNAIL_FORMATS:
raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_THUMBNAIL_FORMATS)} for thumbnail')
quality = 'best'
codec = 'auto'
playlist_item_limit = int(playlist_item_limit) 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,
) )
+96 -43
View File
@@ -151,6 +151,8 @@ class DownloadInfo:
title, title,
url, url,
quality, quality,
download_type,
codec,
format, format,
folder, folder,
custom_name_prefix, custom_name_prefix,
@@ -159,7 +161,6 @@ 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",
): ):
@@ -167,6 +168,8 @@ class DownloadInfo:
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
@@ -180,11 +183,49 @@ 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 = []
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
@@ -194,12 +235,18 @@ 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) 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'),
) )
@@ -241,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'):
@@ -349,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
@@ -381,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
@@ -397,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
@@ -431,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
@@ -622,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.'}
@@ -640,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}'
@@ -674,15 +721,16 @@ 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,
@@ -705,15 +753,16 @@ 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,
@@ -744,15 +793,16 @@ 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,
@@ -770,21 +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,
subtitle_mode=subtitle_mode,
) )
await self.__add_download(dl, auto_start) await self.__add_download(dl, auto_start)
return {'status': 'ok'} return {'status': 'ok'}
@@ -793,24 +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,
): ):
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=}'
) )
if already is None: if already is None:
_add_gen = self._add_generation _add_gen = self._add_generation
@@ -827,15 +879,16 @@ 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,
@@ -879,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}')
+2 -1
View File
@@ -77,7 +77,8 @@
"buildTarget": "metube:build:production" "buildTarget": "metube:build:production"
}, },
"development": { "development": {
"buildTarget": "metube:build:development" "buildTarget": "metube:build:development",
"proxyConfig": "proxy.conf.json"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"
+239 -94
View File
@@ -104,7 +104,15 @@
Canceling... Canceling...
</button> </button>
} @else if (addInProgress) { } @else if (addInProgress) {
<button class="btn btn-danger btn-lg px-3" type="button" (click)="cancelAdding()"> <button class="btn btn-secondary btn-lg px-3 add-progress-btn" type="button" disabled>
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Adding...
</button>
<button class="btn btn-outline-danger btn-lg px-3 add-cancel-btn"
type="button"
(click)="cancelAdding()"
aria-label="Cancel adding URL"
title="Cancel adding URL">
<fa-icon [icon]="faTimesCircle" class="me-1" /> Cancel <fa-icon [icon]="faTimesCircle" class="me-1" /> Cancel
</button> </button>
} @else { } @else {
@@ -120,39 +128,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 +335,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,60 +399,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>
}
<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">
@@ -489,7 +609,11 @@
<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">Codec / Format</th>
<th scope="col">File Size</th> <th scope="col">File Size</th>
<th scope="col">Downloaded</th>
<th scope="col" style="width: 8rem;"></th> <th scope="col" style="width: 8rem;"></th>
</tr> </tr>
</thead> </thead>
@@ -516,15 +640,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);">
@@ -551,11 +678,25 @@
</div> </div>
} }
</td> </td>
<td class="text-nowrap">
{{ downloadTypeLabel(entry[1]) }}
</td>
<td class="text-nowrap">
{{ formatQualityLabel(entry[1]) }}
</td>
<td class="text-nowrap">
{{ formatCodecLabel(entry[1]) }}
</td>
<td> <td>
@if (entry[1].size) { @if (entry[1].size) {
<span>{{ entry[1].size | fileSize }}</span> <span>{{ entry[1].size | fileSize }}</span>
} }
</td> </td>
<td class="text-nowrap">
@if (entry[1].timestamp) {
<span>{{ entry[1].timestamp / 1000000 | date:'yyyy-MM-dd HH:mm' }}</span>
}
</td>
<td> <td>
<div class="d-flex"> <div class="d-flex">
@if (entry[1].status === 'error') { @if (entry[1].status === 'error') {
@@ -580,11 +721,15 @@
getChapterFileName(chapterFile.filename) }}</a> getChapterFileName(chapterFile.filename) }}</a>
</div> </div>
</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>
} }
</td> </td>
<td></td>
<td> <td>
<div class="d-flex"> <div class="d-flex">
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" download <a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" download
+7
View File
@@ -112,6 +112,13 @@ td
.spinner-border .spinner-border
margin-right: 0.5rem margin-right: 0.5rem
.add-progress-btn
min-width: 9.5rem
cursor: default
.add-cancel-btn
min-width: 3.25rem
::ng-deep .ng-select ::ng-deep .ng-select
flex: 1 flex: 1
.ng-select-container .ng-select-container
+239 -46
View File
@@ -1,4 +1,4 @@
import { AsyncPipe, KeyValuePipe } from '@angular/common'; import { AsyncPipe, DatePipe, KeyValuePipe } from '@angular/common';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { AfterViewInit, Component, ElementRef, viewChild, inject, OnInit } from '@angular/core'; import { AfterViewInit, Component, ElementRef, viewChild, inject, OnInit } from '@angular/core';
import { Observable, map, distinctUntilChanged } from 'rxjs'; import { Observable, map, distinctUntilChanged } from 'rxjs';
@@ -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/';
@@ -21,6 +36,7 @@ import { MasterCheckboxComponent , SlaveCheckboxComponent} from './components/';
FormsModule, FormsModule,
KeyValuePipe, KeyValuePipe,
AsyncPipe, AsyncPipe,
DatePipe,
FontAwesomeModule, FontAwesomeModule,
NgbModule, NgbModule,
NgSelectModule, NgSelectModule,
@@ -39,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;
@@ -49,7 +72,6 @@ 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;
addInProgress = false; addInProgress = false;
@@ -70,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;
@@ -110,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' },
@@ -168,27 +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' },
]; ];
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';
const allowedSubtitleFormats = new Set(this.subtitleFormats.map(fmt => fmt.id)); const allowedDownloadTypes = new Set(this.downloadTypes.map(t => t.id));
const allowedSubtitleModes = new Set(this.subtitleModes.map(mode => mode.id)); const allowedVideoCodecs = new Set(this.videoCodecs.map(c => c.id));
if (!allowedSubtitleFormats.has(this.subtitleFormat)) { if (!allowedDownloadTypes.has(this.downloadType)) {
this.subtitleFormat = 'srt'; this.downloadType = 'video';
} }
if (!allowedVideoCodecs.has(this.codec)) {
this.codec = 'auto';
}
const allowedSubtitleModes = new Set(this.subtitleModes.map(mode => mode.id));
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);
@@ -209,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();
@@ -254,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'];
} }
@@ -270,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[]> {
@@ -344,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);
} }
@@ -366,16 +417,44 @@ 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);
}
isVideoType() {
return this.downloadType === 'video';
}
formatQualityLabel(download: Download): string {
if (download.download_type === 'captions' || download.download_type === 'thumbnail') {
return '-';
}
const q = download.quality;
if (!q) return '';
if (/^\d+$/.test(q) && download.download_type === 'audio') return `${q} kbps`;
if (/^\d+$/.test(q)) return `${q}p`;
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 {
if (download.download_type !== 'video') {
const format = (download.format || '').toUpperCase();
return format || '-';
}
const codec = download.codec;
if (!codec || codec === 'auto') return 'Auto';
return this.videoCodecs.find(c => c.id === codec)?.text ?? codec;
} }
queueSelectionChanged(checked: number) { queueSelectionChanged(checked: number) {
@@ -389,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,
@@ -408,11 +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,
) { ) {
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
@@ -421,7 +614,6 @@ 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
@@ -431,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); 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).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') {
@@ -461,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,
@@ -469,7 +663,6 @@ 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,
); );
@@ -521,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"];
} }
@@ -545,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"];
} }
@@ -616,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.subtitleLanguage, this.subtitleMode)
.subscribe({ .subscribe({
next: (status: Status) => { next: (status: Status) => {
if (status.status === 'error') { if (status.status === 'error') {
@@ -852,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);
}); });
} }
+4 -2
View File
@@ -3,6 +3,8 @@ export interface Download {
id: string; id: string;
title: string; title: string;
url: string; url: string;
download_type: string;
codec?: string;
quality: string; quality: string;
format: string; format: string;
folder: string; folder: string;
@@ -10,7 +12,6 @@ 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;
status: string; status: string;
@@ -20,8 +21,9 @@ export interface Download {
eta: number; eta: number;
filename: string; filename: string;
checked: boolean; checked: boolean;
timestamp?: number;
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 }[];
} }
+74 -78
View File
@@ -1,81 +1,77 @@
import { Format } from "./format"; import { Quality } from "./quality";
export interface Option {
id: string;
text: string;
}
export const Formats: Format[] = [ export interface AudioFormatOption extends Option {
{ qualities: Quality[];
id: 'any', }
text: 'Any',
qualities: [ export const DOWNLOAD_TYPES: Option[] = [
{ id: 'best', text: 'Best' }, { id: "video", text: "Video" },
{ id: '2160', text: '2160p' }, { id: "audio", text: "Audio" },
{ id: '1440', text: '1440p' }, { id: "captions", text: "Captions" },
{ id: '1080', text: '1080p' }, { id: "thumbnail", text: "Thumbnail" },
{ id: '720', text: '720p' },
{ id: '480', text: '480p' },
{ id: '360', text: '360p' },
{ id: '240', text: '240p' },
{ id: 'worst', text: 'Worst' },
{ id: 'audio', text: 'Audio Only' },
],
},
{
id: 'mp4',
text: 'MP4',
qualities: [
{ id: 'best', text: 'Best' },
{ id: 'best_ios', text: 'Best (iOS)' },
{ id: '2160', text: '2160p' },
{ id: '1440', text: '1440p' },
{ id: '1080', text: '1080p' },
{ id: '720', text: '720p' },
{ id: '480', text: '480p' },
{ id: '360', text: '360p' },
{ id: '240', text: '240p' },
{ id: 'worst', text: 'Worst' },
],
},
{
id: 'm4a',
text: 'M4A',
qualities: [
{ id: 'best', text: 'Best' },
{ id: '192', text: '192 kbps' },
{ id: '128', text: '128 kbps' },
],
},
{
id: 'mp3',
text: 'MP3',
qualities: [
{ id: 'best', text: 'Best' },
{ id: '320', text: '320 kbps' },
{ id: '192', text: '192 kbps' },
{ id: '128', text: '128 kbps' },
],
},
{
id: 'opus',
text: 'OPUS',
qualities: [{ id: 'best', text: 'Best' }],
},
{
id: 'wav',
text: 'WAV',
qualities: [{ id: 'best', text: 'Best' }],
},
{
id: 'flac',
text: 'FLAC',
qualities: [{ id: 'best', text: 'Best' }],
},
{
id: 'thumbnail',
text: 'Thumbnail',
qualities: [{ id: 'best', text: 'Best' }],
},
{
id: 'captions',
text: 'Captions',
qualities: [{ id: 'best', text: 'Best' }],
},
]; ];
export const VIDEO_CODECS: Option[] = [
{ id: "auto", text: "Auto" },
{ id: "h264", text: "H.264" },
{ id: "h265", text: "H.265 (HEVC)" },
{ id: "av1", text: "AV1" },
{ id: "vp9", text: "VP9" },
];
export const VIDEO_FORMATS: Option[] = [
{ id: "any", text: "Auto" },
{ id: "mp4", text: "MP4" },
{ id: "ios", text: "iOS Compatible" },
];
export const VIDEO_QUALITIES: Quality[] = [
{ id: "best", text: "Best" },
{ id: "2160", text: "2160p" },
{ id: "1440", text: "1440p" },
{ id: "1080", text: "1080p" },
{ id: "720", text: "720p" },
{ id: "480", text: "480p" },
{ id: "360", text: "360p" },
{ id: "240", text: "240p" },
{ id: "worst", text: "Worst" },
];
export const AUDIO_FORMATS: AudioFormatOption[] = [
{
id: "m4a",
text: "M4A",
qualities: [
{ id: "best", text: "Best" },
{ id: "192", text: "192 kbps" },
{ id: "128", text: "128 kbps" },
],
},
{
id: "mp3",
text: "MP3",
qualities: [
{ id: "best", text: "Best" },
{ id: "320", text: "320 kbps" },
{ id: "192", text: "192 kbps" },
{ id: "128", text: "128 kbps" },
],
},
{ id: "opus", text: "OPUS", qualities: [{ id: "best", text: "Best" }] },
{ id: "wav", text: "WAV", qualities: [{ id: "best", text: "Best" }] },
{ id: "flac", text: "FLAC", qualities: [{ id: "best", text: "Best" }] },
];
export const CAPTION_FORMATS: Option[] = [
{ id: "srt", text: "SRT" },
{ id: "txt", text: "TXT (Text only)" },
{ id: "vtt", text: "VTT" },
{ id: "ttml", text: "TTML" },
];
export const THUMBNAIL_FORMATS: Option[] = [{ id: "jpg", text: "JPG" }];
+12 -8
View File
@@ -109,6 +109,8 @@ export class DownloadsService {
public add( public add(
url: string, url: string,
downloadType: string,
codec: string,
quality: string, quality: string,
format: string, format: string,
folder: string, folder: string,
@@ -117,12 +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,
) { ) {
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,
@@ -131,9 +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,
}).pipe( }).pipe(
catchError(this.handleHTTPError) catchError(this.handleHTTPError)
); );
@@ -172,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 = '';
@@ -180,13 +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';
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.add( this.add(
url, url,
defaultDownloadType,
defaultCodec,
defaultQuality, defaultQuality,
defaultFormat, defaultFormat,
defaultFolder, defaultFolder,
@@ -195,7 +200,6 @@ export class DownloadsService {
defaultAutoStart, defaultAutoStart,
defaultSplitByChapters, defaultSplitByChapters,
defaultChapterTemplate, defaultChapterTemplate,
defaultSubtitleFormat,
defaultSubtitleLanguage, defaultSubtitleLanguage,
defaultSubtitleMode, defaultSubtitleMode,
) )
@@ -217,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)
); );
} }