Compare commits

...

12 Commits

Author SHA1 Message Date
AutoUpdater
0bf508dbc6 upgrade yt-dlp from 2026.3.13 to 2026.3.17 2026-03-18 00:14:51 +00:00
Alex
104d547150 Update Trivy action version in workflow 2026-03-15 21:06:19 +02:00
Alex Shnitman
289133e507 upgrade dependencies 2026-03-15 20:54:46 +02:00
Alex Shnitman
7fa1fc7938 code review fixes 2026-03-15 20:53:13 +02:00
Alex Shnitman
04959a6189 upgrade dependencies 2026-03-14 12:05:04 +02:00
AutoUpdater
8b0d682b35 upgrade yt-dlp from 2026.3.3 to 2026.3.13 2026-03-14 00:13:08 +00:00
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
25 changed files with 1899 additions and 1311 deletions

View File

@@ -6,38 +6,75 @@ on:
- 'master' - 'master'
jobs: jobs:
quality-checks:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
- name: Enable pnpm
run: corepack enable
- name: Install frontend dependencies
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Run frontend lint
working-directory: ui
run: pnpm run lint
- name: Build frontend
working-directory: ui
run: pnpm run build
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Run backend smoke checks
run: python -m compileall app
- name: Run backend tests
run: python -m unittest discover -s app/tests -p "test_*.py"
- name: Run Trivy filesystem scan
uses: aquasecurity/trivy-action@0.35.0
with:
scan-type: fs
scan-ref: .
format: table
severity: CRITICAL,HIGH
dockerhub-build-push: dockerhub-build-push:
needs: quality-checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- -
name: Get current date name: Get current date
id: date id: date
run: echo "::set-output name=date::$(date +'%Y.%m.%d')" run: echo "date=$(date +'%Y.%m.%d')" >> "$GITHUB_OUTPUT"
- -
name: Checkout name: Checkout
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

View File

@@ -34,7 +34,7 @@ RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode && \ UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode && \
uv cache clean && \ uv cache clean && \
rm -f /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/uvw && \ rm -f /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/uvw && \
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y v2.7.2 && \ curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y && \
apt-get purge -y --auto-remove build-essential && \ apt-get purge -y --auto-remove build-essential && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
mkdir /.cache && chmod 777 /.cache mkdir /.cache && chmod 777 /.cache
@@ -63,11 +63,12 @@ ENV PUID=1000
ENV PGID=1000 ENV PGID=1000
ENV UMASK=022 ENV UMASK=022
ENV DOWNLOAD_DIR /downloads ENV DOWNLOAD_DIR=/downloads
ENV STATE_DIR /downloads/.metube ENV STATE_DIR=/downloads/.metube
ENV TEMP_DIR /downloads ENV TEMP_DIR=/downloads
VOLUME /downloads VOLUME /downloads
EXPOSE 8081 EXPOSE 8081
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS "http://localhost:8081/" || exit 1
# Add build-time argument for version # Add build-time argument for version
ARG VERSION=dev ARG VERSION=dev

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,88 @@ 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 ValueError(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 ValueError(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 ValueError(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
""" """
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,8 +111,7 @@ def get_opts(
} }
) )
# Audio formats without thumbnail if format != "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 +123,18 @@ def get_opts(
postprocessors.append({"key": "FFmpegMetadata"}) postprocessors.append({"key": "FFmpegMetadata"})
postprocessors.append({"key": "EmbedThumbnail"}) postprocessors.append({"key": "EmbedThumbnail"})
if format == "thumbnail": if download_type == "thumbnail":
opts["skip_download"] = True opts["skip_download"] = True
opts["writethumbnail"] = True opts["writethumbnail"] = True
postprocessors.append( postprocessors.append(
{"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"} {"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"}
) )
if format == "captions": if download_type == "captions":
mode = _normalize_caption_mode(subtitle_mode) mode = _normalize_caption_mode(subtitle_mode)
language = _normalize_subtitle_language(subtitle_language) language = _normalize_subtitle_language(subtitle_language)
opts["skip_download"] = True opts["skip_download"] = True
requested_subtitle_format = (subtitle_format or "srt").lower() requested_subtitle_format = (format or "srt").lower()
# txt is a derived, non-timed format produced from SRT after download.
if requested_subtitle_format == "txt": if requested_subtitle_format == "txt":
requested_subtitle_format = "srt" requested_subtitle_format = "srt"
opts["subtitlesformat"] = requested_subtitle_format opts["subtitlesformat"] = requested_subtitle_format

View File

@@ -16,25 +16,15 @@ import pathlib
import re import re
from watchfiles import DefaultFilter, Change, awatch from watchfiles import DefaultFilter, Change, awatch
from ytdl import DownloadQueueNotifier, DownloadQueue from ytdl import DownloadQueueNotifier, DownloadQueue, Download
from yt_dlp.version import __version__ as yt_dlp_version from yt_dlp.version import __version__ as yt_dlp_version
log = logging.getLogger('main') log = logging.getLogger('main')
def parseLogLevel(logLevel): def parseLogLevel(logLevel):
match logLevel: if not isinstance(logLevel, str):
case 'DEBUG': return None
return logging.DEBUG return getattr(logging, logLevel.upper(), None)
case 'INFO':
return logging.INFO
case 'WARNING':
return logging.WARNING
case 'ERROR':
return logging.ERROR
case 'CRITICAL':
return logging.CRITICAL
case _:
return None
# Configure logging before Config() uses it so early messages are not dropped. # Configure logging before Config() uses it so early messages are not dropped.
# Only configure if no handlers are set (avoid clobbering hosting app settings). # Only configure if no handlers are set (avoid clobbering hosting app settings).
@@ -71,7 +61,7 @@ class Config:
'KEYFILE': '', 'KEYFILE': '',
'BASE_DIR': '', 'BASE_DIR': '',
'DEFAULT_THEME': 'auto', 'DEFAULT_THEME': 'auto',
'MAX_CONCURRENT_DOWNLOADS': 3, 'MAX_CONCURRENT_DOWNLOADS': '3',
'LOGLEVEL': 'INFO', 'LOGLEVEL': 'INFO',
'ENABLE_ACCESSLOG': 'false', 'ENABLE_ACCESSLOG': 'false',
} }
@@ -181,7 +171,7 @@ class ObjectSerializer(json.JSONEncoder):
elif hasattr(obj, '__iter__') and not isinstance(obj, (str, bytes)): elif hasattr(obj, '__iter__') and not isinstance(obj, (str, bytes)):
try: try:
return list(obj) return list(obj)
except: except Exception:
pass pass
# Fall back to default behavior # Fall back to default behavior
return json.JSONEncoder.default(self, obj) return json.JSONEncoder.default(self, obj)
@@ -194,6 +184,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):
@@ -218,6 +270,7 @@ class Notifier(DownloadQueueNotifier):
dqueue = DownloadQueue(config, Notifier()) dqueue = DownloadQueue(config, Notifier())
app.on_startup.append(lambda app: dqueue.initialize()) app.on_startup.append(lambda app: dqueue.initialize())
app.on_cleanup.append(lambda app: Download.shutdown_manager())
class FileOpsFilter(DefaultFilter): class FileOpsFilter(DefaultFilter):
def __call__(self, change_type: int, path: str) -> bool: def __call__(self, change_type: int, path: str) -> bool:
@@ -268,24 +321,44 @@ async def watch_files():
if config.YTDL_OPTIONS_FILE: if config.YTDL_OPTIONS_FILE:
app.on_startup.append(lambda app: watch_files()) app.on_startup.append(lambda app: watch_files())
async def _read_json_request(request: web.Request) -> dict:
try:
post = await request.json()
except json.JSONDecodeError as exc:
raise web.HTTPBadRequest(reason='Invalid JSON request body') from exc
if not isinstance(post, dict):
raise web.HTTPBadRequest(reason='JSON request body must be an object')
return post
@routes.post(config.URL_PREFIX + 'add') @routes.post(config.URL_PREFIX + 'add')
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 _read_json_request(request)
log.info(f"Request data: {post}") post = _migrate_legacy_request(post)
log.info(
"Add download request: type=%s quality=%s format=%s has_folder=%s auto_start=%s",
post.get('download_type'),
post.get('quality'),
post.get('format'),
bool(post.get('folder')),
post.get('auto_start'),
)
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 +374,73 @@ 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)}')
playlist_item_limit = int(playlist_item_limit) 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'
try:
playlist_item_limit = int(playlist_item_limit)
except (TypeError, ValueError) as exc:
raise web.HTTPBadRequest(reason='playlist_item_limit must be an integer') from exc
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,
) )
@@ -344,7 +453,7 @@ async def cancel_add(request):
@routes.post(config.URL_PREFIX + 'delete') @routes.post(config.URL_PREFIX + 'delete')
async def delete(request): async def delete(request):
post = await request.json() post = await _read_json_request(request)
ids = post.get('ids') ids = post.get('ids')
where = post.get('where') where = post.get('where')
if not ids or where not in ['queue', 'done']: if not ids or where not in ['queue', 'done']:
@@ -356,7 +465,7 @@ async def delete(request):
@routes.post(config.URL_PREFIX + 'start') @routes.post(config.URL_PREFIX + 'start')
async def start(request): async def start(request):
post = await request.json() post = await _read_json_request(request)
ids = post.get('ids') ids = post.get('ids')
log.info(f"Received request to start pending downloads for ids: {ids}") log.info(f"Received request to start pending downloads for ids: {ids}")
status = await dqueue.start_pending(ids) status = await dqueue.start_pending(ids)
@@ -371,17 +480,23 @@ async def upload_cookies(request):
field = await reader.next() field = await reader.next()
if field is None or field.name != 'cookies': if field is None or field.name != 'cookies':
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'No cookies file provided'})) return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'No cookies file provided'}))
max_size = 1_000_000 # 1MB limit
size = 0 size = 0
with open(COOKIES_PATH, 'wb') as f: content = bytearray()
while True: while True:
chunk = await field.read_chunk() chunk = await field.read_chunk()
if not chunk: if not chunk:
break break
size += len(chunk) size += len(chunk)
if size > 1_000_000: # 1MB limit if size > max_size:
os.remove(COOKIES_PATH) return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'Cookie file too large (max 1MB)'}))
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'Cookie file too large (max 1MB)'})) content.extend(chunk)
f.write(chunk)
tmp_cookie_path = f"{COOKIES_PATH}.tmp"
with open(tmp_cookie_path, 'wb') as f:
f.write(content)
os.replace(tmp_cookie_path, COOKIES_PATH)
config.set_runtime_override('cookiefile', COOKIES_PATH) config.set_runtime_override('cookiefile', COOKIES_PATH)
log.info(f'Cookies file uploaded ({size} bytes)') log.info(f'Cookies file uploaded ({size} bytes)')
return web.Response(text=serializer.encode({'status': 'ok', 'msg': f'Cookies uploaded ({size} bytes)'})) return web.Response(text=serializer.encode({'status': 'ok', 'msg': f'Cookies uploaded ({size} bytes)'}))
@@ -446,6 +561,22 @@ async def connect(sid, environ):
await sio.emit('ytdl_options_changed', serializer.encode(get_options_update_time()), to=sid) await sio.emit('ytdl_options_changed', serializer.encode(get_options_update_time()), to=sid)
def get_custom_dirs(): def get_custom_dirs():
cache_ttl_seconds = 5
now = asyncio.get_running_loop().time()
cache_key = (
config.DOWNLOAD_DIR,
config.AUDIO_DOWNLOAD_DIR,
config.CUSTOM_DIRS_EXCLUDE_REGEX,
)
if (
hasattr(get_custom_dirs, "_cache_key")
and hasattr(get_custom_dirs, "_cache_value")
and hasattr(get_custom_dirs, "_cache_time")
and get_custom_dirs._cache_key == cache_key
and (now - get_custom_dirs._cache_time) < cache_ttl_seconds
):
return get_custom_dirs._cache_value
def recursive_dirs(base): def recursive_dirs(base):
path = pathlib.Path(base) path = pathlib.Path(base)
@@ -482,10 +613,14 @@ def get_custom_dirs():
if config.DOWNLOAD_DIR != config.AUDIO_DOWNLOAD_DIR: if config.DOWNLOAD_DIR != config.AUDIO_DOWNLOAD_DIR:
audio_download_dir = recursive_dirs(config.AUDIO_DOWNLOAD_DIR) audio_download_dir = recursive_dirs(config.AUDIO_DOWNLOAD_DIR)
return { result = {
"download_dir": download_dir, "download_dir": download_dir,
"audio_download_dir": audio_download_dir "audio_download_dir": audio_download_dir
} }
get_custom_dirs._cache_key = cache_key
get_custom_dirs._cache_time = now
get_custom_dirs._cache_value = result
return result
@routes.get(config.URL_PREFIX) @routes.get(config.URL_PREFIX)
def index(request): def index(request):

View File

@@ -0,0 +1,21 @@
import unittest
from app.dl_formats import get_format, get_opts
class DlFormatsTests(unittest.TestCase):
def test_audio_unknown_format_raises_value_error(self):
with self.assertRaises(ValueError):
get_format("audio", "auto", "invalid", "best")
def test_wav_does_not_enable_thumbnail_postprocessing(self):
opts = get_opts("audio", "auto", "wav", "best", {})
self.assertNotIn("writethumbnail", opts)
def test_mp3_enables_thumbnail_postprocessing(self):
opts = get_opts("audio", "auto", "mp3", "best", {})
self.assertTrue(opts.get("writethumbnail"))
if __name__ == "__main__":
unittest.main()

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,26 +183,76 @@ 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
@classmethod
def shutdown_manager(cls):
if cls.manager is not None:
cls.manager.shutdown()
cls.manager = None
def __init__(self, download_dir, temp_dir, output_template, output_template_chapter, quality, format, ytdl_opts, info): def __init__(self, download_dir, temp_dir, output_template, output_template_chapter, quality, format, ytdl_opts, info):
self.download_dir = download_dir self.download_dir = download_dir
self.temp_dir = temp_dir self.temp_dir = temp_dir
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 +294,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 +402,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 +434,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 +450,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 +484,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
@@ -521,20 +574,33 @@ class PersistentQueue:
log_prefix = f"PersistentQueue:{self.identifier} repair (sqlite3/file)" log_prefix = f"PersistentQueue:{self.identifier} repair (sqlite3/file)"
log.debug(f"{log_prefix} started") log.debug(f"{log_prefix} started")
try: try:
result = subprocess.run( recover_proc = subprocess.Popen(
f"sqlite3 {self.path} '.recover' | sqlite3 {self.path}.tmp", ["sqlite3", self.path, ".recover"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
run_result = subprocess.run(
["sqlite3", f"{self.path}.tmp"],
stdin=recover_proc.stdout,
capture_output=True, capture_output=True,
text=True, text=True,
shell=True, timeout=60,
timeout=60
) )
if result.stderr: if recover_proc.stdout is not None:
log.debug(f"{log_prefix} failed: {result.stderr}") recover_proc.stdout.close()
recover_stderr = recover_proc.stderr.read() if recover_proc.stderr is not None else ""
recover_proc.wait(timeout=60)
if run_result.stderr or recover_stderr:
error_text = " ".join(part for part in [recover_stderr.strip(), run_result.stderr.strip()] if part)
log.debug(f"{log_prefix} failed: {error_text}")
else: else:
shutil.move(f"{self.path}.tmp", self.path) shutil.move(f"{self.path}.tmp", self.path)
log.debug(f"{log_prefix}{result.stdout or " was successful, no output"}") log.debug(f"{log_prefix}{run_result.stdout or ' was successful, no output'}")
except FileNotFoundError: except FileNotFoundError:
log.debug(f"{log_prefix} failed: 'sqlite3' was not found") log.debug(f"{log_prefix} failed: 'sqlite3' was not found")
except subprocess.TimeoutExpired:
log.debug(f"{log_prefix} failed: sqlite recovery timed out")
class DownloadQueue: class DownloadQueue:
def __init__(self, config, notifier): def __init__(self, config, notifier):
@@ -582,7 +648,7 @@ class DownloadQueue:
if download.tmpfilename and os.path.isfile(download.tmpfilename): if download.tmpfilename and os.path.isfile(download.tmpfilename):
try: try:
os.remove(download.tmpfilename) os.remove(download.tmpfilename)
except: except OSError:
pass pass
download.info.status = 'error' download.info.status = 'error'
download.close() download.close()
@@ -622,8 +688,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 +706,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 +740,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 +772,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 +812,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 +839,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 +863,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 +898,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,
@@ -845,7 +917,7 @@ class DownloadQueue:
async def start_pending(self, ids): async def start_pending(self, ids):
for id in ids: for id in ids:
if not self.pending.exists(id): if not self.pending.exists(id):
log.warn(f'requested start for non-existent download {id}') log.warning(f'requested start for non-existent download {id}')
continue continue
dl = self.pending.get(id) dl = self.pending.get(id)
self.queue.put(dl) self.queue.put(dl)
@@ -862,7 +934,7 @@ class DownloadQueue:
await self.notifier.canceled(id) await self.notifier.canceled(id)
continue continue
if not self.queue.exists(id): if not self.queue.exists(id):
log.warn(f'requested cancel for non-existent download {id}') log.warning(f'requested cancel for non-existent download {id}')
continue continue
if self.queue.get(id).started(): if self.queue.get(id).started():
self.queue.get(id).cancel() self.queue.get(id).cancel()
@@ -874,15 +946,15 @@ class DownloadQueue:
async def clear(self, ids): async def clear(self, ids):
for id in ids: for id in ids:
if not self.done.exists(id): if not self.done.exists(id):
log.warn(f'requested delete for non-existent download {id}') log.warning(f'requested delete for non-existent download {id}')
continue continue
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.warning(f'deleting file for download {id} failed with error message {e!r}')
self.done.delete(id) self.done.delete(id)
await self.notifier.cleared(id) await self.notifier.cleared(id)
return {'status': 'ok'} return {'status': 'ok'}

View File

@@ -33,9 +33,7 @@
"node_modules/@ng-select/ng-select/themes/default.theme.css", "node_modules/@ng-select/ng-select/themes/default.theme.css",
"src/styles.sass" "src/styles.sass"
], ],
"scripts": [ "scripts": [],
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
],
"serviceWorker": "ngsw-config.json", "serviceWorker": "ngsw-config.json",
"browser": "src/main.ts", "browser": "src/main.ts",
"polyfills": [ "polyfills": [
@@ -77,7 +75,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"

View File

@@ -23,14 +23,14 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^21.2.1", "@angular/animations": "^21.2.4",
"@angular/common": "^21.2.1", "@angular/common": "^21.2.4",
"@angular/compiler": "^21.2.1", "@angular/compiler": "^21.2.4",
"@angular/core": "^21.2.1", "@angular/core": "^21.2.4",
"@angular/forms": "^21.2.1", "@angular/forms": "^21.2.4",
"@angular/platform-browser": "^21.2.1", "@angular/platform-browser": "^21.2.4",
"@angular/platform-browser-dynamic": "^21.2.1", "@angular/platform-browser-dynamic": "^21.2.4",
"@angular/service-worker": "^21.2.1", "@angular/service-worker": "^21.2.4",
"@fortawesome/angular-fontawesome": "~4.0.0", "@fortawesome/angular-fontawesome": "~4.0.0",
"@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.2.0", "@fortawesome/free-brands-svg-icons": "^7.2.0",
@@ -40,7 +40,7 @@
"@ng-select/ng-select": "^21.5.2", "@ng-select/ng-select": "^21.5.2",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"ngx-cookie-service": "^21.1.0", "ngx-cookie-service": "^21.3.1",
"ngx-socket-io": "~4.10.0", "ngx-socket-io": "~4.10.0",
"rxjs": "~7.8.2", "rxjs": "~7.8.2",
"tslib": "^2.8.1", "tslib": "^2.8.1",
@@ -48,16 +48,16 @@
}, },
"devDependencies": { "devDependencies": {
"@angular-eslint/builder": "21.1.0", "@angular-eslint/builder": "21.1.0",
"@angular/build": "^21.2.1", "@angular/build": "^21.2.2",
"@angular/cli": "^21.2.1", "@angular/cli": "^21.2.2",
"@angular/compiler-cli": "^21.2.1", "@angular/compiler-cli": "^21.2.4",
"@angular/localize": "^21.2.1", "@angular/localize": "^21.2.4",
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.4",
"angular-eslint": "21.1.0", "angular-eslint": "21.1.0",
"eslint": "^9.39.3", "eslint": "^9.39.4",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "8.47.0", "typescript-eslint": "8.47.0",
"vitest": "^4.0.18" "vitest": "^4.1.0"
} }
} }

1018
ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode, provideZonelessChangeDetection, provideZoneChangeDetection } from '@angular/core'; import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode, provideZoneChangeDetection } from '@angular/core';
import { provideServiceWorker } from '@angular/service-worker'; import { provideServiceWorker } from '@angular/service-worker';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';

View File

@@ -49,22 +49,22 @@
</div> </div>
--> -->
<div class="navbar-nav ms-auto"> <div class="navbar-nav ms-auto">
<div class="nav-item dropdown"> <div class="nav-item dropdown" ngbDropdown placement="bottom-end">
<button class="btn btn-link nav-link py-2 px-0 px-sm-2 dropdown-toggle d-flex align-items-center" <button class="btn btn-link nav-link py-2 px-0 px-sm-2 dropdown-toggle d-flex align-items-center"
id="theme-select" id="theme-select"
type="button" type="button"
aria-expanded="false" aria-expanded="false"
data-bs-toggle="dropdown" ngbDropdownToggle>
data-bs-display="static">
@if(activeTheme){ @if(activeTheme){
<fa-icon [icon]="activeTheme.icon" /> <fa-icon [icon]="activeTheme.icon" />
} }
</button> </button>
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select"> <ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select" ngbDropdownMenu>
@for (theme of themes; track theme) { @for (theme of themes; track theme) {
<li> <li>
<button type="button" class="dropdown-item d-flex align-items-center" <button type="button" class="dropdown-item d-flex align-items-center"
[class.active]="activeTheme === theme" [class.active]="activeTheme === theme"
ngbDropdownItem
(click)="themeChanged(theme)"> (click)="themeChanged(theme)">
<span class="me-2 opacity-50"> <span class="me-2 opacity-50">
<fa-icon [icon]="theme.icon" /> <fa-icon [icon]="theme.icon" />
@@ -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 formatOptions; 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 formatOptions; 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 formatOptions; 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">
@@ -386,16 +506,18 @@
<!-- Batch Import Modal --> <!-- Batch Import Modal -->
<div class="modal fade" tabindex="-1" role="dialog" <div class="modal fade" tabindex="-1" role="dialog"
aria-modal="true"
aria-labelledby="batch-import-modal-title"
[class.show]="batchImportModalOpen" [class.show]="batchImportModalOpen"
[style.display]="batchImportModalOpen ? 'block' : 'none'"> [style.display]="batchImportModalOpen ? 'block' : 'none'">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Batch Import URLs</h5> <h5 id="batch-import-modal-title" class="modal-title">Batch Import URLs</h5>
<button type="button" class="btn-close" aria-label="Close" (click)="closeBatchImportModal()"></button> <button type="button" class="btn-close" aria-label="Close" (click)="closeBatchImportModal()"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<textarea [(ngModel)]="batchImportText" class="form-control" rows="6" <textarea id="batch-import-textarea" [(ngModel)]="batchImportText" class="form-control" rows="6"
placeholder="Paste one video URL per line"></textarea> placeholder="Paste one video URL per line"></textarea>
<div class="mt-2"> <div class="mt-2">
@if (batchImportStatus) { @if (batchImportStatus) {
@@ -434,7 +556,7 @@
<thead> <thead>
<tr> <tr>
<th scope="col" style="width: 1rem;"> <th scope="col" style="width: 1rem;">
<app-master-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" /> <app-select-all-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" />
</th> </th>
<th scope="col">Video</th> <th scope="col">Video</th>
<th scope="col" style="width: 8rem;">Speed</th> <th scope="col" style="width: 8rem;">Speed</th>
@@ -446,7 +568,7 @@
@for (download of downloads.queue | keyvalue: asIsOrder; track download.value.id) { @for (download of downloads.queue | keyvalue: asIsOrder; track download.value.id) {
<tr [class.disabled]='download.value.deleting'> <tr [class.disabled]='download.value.deleting'>
<td> <td>
<app-slave-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" /> <app-item-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
</td> </td>
<td title="{{ download.value.filename }}"> <td title="{{ download.value.filename }}">
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3"> <div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
@@ -460,10 +582,10 @@
<td> <td>
<div class="d-flex"> <div class="d-flex">
@if (download.value.status === 'pending') { @if (download.value.status === 'pending') {
<button type="button" class="btn btn-link" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button> <button type="button" class="btn btn-link" [attr.aria-label]="'Start download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
} }
<button type="button" class="btn btn-link" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button> <button type="button" class="btn btn-link" [attr.aria-label]="'Remove ' + download.value.title + ' from queue'" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a> <a href="{{download.value.url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + download.value.title"><fa-icon [icon]="faExternalLinkAlt" /></a>
</div> </div>
</td> </td>
</tr> </tr>
@@ -476,9 +598,9 @@
<div class="px-2 py-3 border-bottom"> <div class="px-2 py-3 border-bottom">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()" ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" />&nbsp; {{ sortAscending ? 'Oldest first' : 'Newest first' }}</button> <button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()" ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" />&nbsp; {{ sortAscending ? 'Oldest first' : 'Newest first' }}</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" />&nbsp; Clear selected</button> <button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" />&nbsp; Clear selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" />&nbsp; Clear completed</button> <button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasCompletedDone" (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" />&nbsp; Clear completed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" />&nbsp; Clear failed</button> <button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" />&nbsp; Clear failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneRetryFailed (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt" />&nbsp; Retry failed</button> <button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt" />&nbsp; Retry failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload" />&nbsp; Download Selected</button> <button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload" />&nbsp; Download Selected</button>
</div> </div>
<div class="overflow-auto"> <div class="overflow-auto">
@@ -486,10 +608,14 @@
<thead> <thead>
<tr> <tr>
<th scope="col" style="width: 1rem;"> <th scope="col" style="width: 1rem;">
<app-master-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)" /> <app-select-all-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>
@@ -497,7 +623,7 @@
@for (entry of cachedSortedDone; track entry[1].id) { @for (entry of cachedSortedDone; track entry[1].id) {
<tr [class.disabled]='entry[1].deleting'> <tr [class.disabled]='entry[1].deleting'>
<td> <td>
<app-slave-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" /> <app-item-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
</td> </td>
<td> <td>
<div style="display: inline-block; width: 1.5rem;"> <div style="display: inline-block; width: 1.5rem;">
@@ -516,15 +642,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,21 +680,35 @@
</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') {
<button type="button" class="btn btn-link" (click)="retryDownload(entry[0], entry[1])"><fa-icon [icon]="faRedoAlt" /></button> <button type="button" class="btn btn-link" [attr.aria-label]="'Retry download for ' + entry[1].title" (click)="retryDownload(entry[0], entry[1])"><fa-icon [icon]="faRedoAlt" /></button>
} }
@if (entry[1].filename) { @if (entry[1].filename) {
<a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link"><fa-icon [icon]="faDownload" /></a> <a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link" [attr.aria-label]="'Download result file for ' + entry[1].title"><fa-icon [icon]="faDownload" /></a>
} }
<a href="{{entry[1].url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a> <a href="{{entry[1].url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + entry[1].title"><fa-icon [icon]="faExternalLinkAlt" /></a>
<button type="button" class="btn btn-link" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button> <button type="button" class="btn btn-link" [attr.aria-label]="'Delete completed item ' + entry[1].title" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -576,18 +719,22 @@
<td> <td>
<div style="padding-left: 2rem;"> <div style="padding-left: 2rem;">
<fa-icon [icon]="faCheckCircle" class="text-success me-2" /> <fa-icon [icon]="faCheckCircle" class="text-success me-2" />
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" target="_blank">{{ <a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" target="_blank" [attr.aria-label]="'Open chapter file ' + getChapterFileName(chapterFile.filename)">{{
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 [attr.aria-label]="'Download chapter file ' + getChapterFileName(chapterFile.filename)"
class="btn btn-link"><fa-icon [icon]="faDownload" /></a> class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
</div> </div>
</td> </td>

View File

@@ -1,29 +1,7 @@
.button-toggle-theme:focus, .button-toggle-theme:active
box-shadow: none
outline: 0px
.add-url-box .add-url-box
max-width: 960px max-width: 960px
margin: 4rem auto margin: 4rem auto
.add-url-component
margin: 0.5rem auto
.add-url-group
width: 100%
button.add-url
width: 100%
.folder-dropdown-menu
width: 500px
max-width: calc(100vw - 3rem)
.folder-dropdown-menu .input-group
display: flex
padding-left: 5px
padding-right: 5px
.metube-section-header .metube-section-header
font-size: 1.8rem font-size: 1.8rem
font-weight: 300 font-weight: 300
@@ -66,39 +44,11 @@ td
width: 12rem width: 12rem
margin-left: auto margin-left: auto
.batch-panel
margin-top: 15px
border: 1px solid #ccc
border-radius: 8px
padding: 15px
background-color: #fff
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1)
.batch-panel-header
border-bottom: 1px solid #eee
padding-bottom: 8px
margin-bottom: 15px
h4
font-size: 1.5rem
margin: 0
.batch-panel-body
textarea.form-control
resize: vertical
.batch-status
font-size: 0.9rem
color: #555
.d-flex.my-3
margin-top: 1rem
margin-bottom: 1rem
.modal.fade.show .modal.fade.show
background-color: rgba(0, 0, 0, 0.5) background-color: rgba(0, 0, 0, 0.5)
.modal-header .modal-header
border-bottom: 1px solid #eee border-bottom: 1px solid var(--bs-border-color)
.modal-body .modal-body
textarea.form-control textarea.form-control
@@ -112,20 +62,12 @@ td
.spinner-border .spinner-border
margin-right: 0.5rem margin-right: 0.5rem
::ng-deep .ng-select .add-progress-btn
flex: 1 min-width: 9.5rem
.ng-select-container cursor: default
min-height: 38px
.ng-value .add-cancel-btn
white-space: nowrap min-width: 3.25rem
overflow: visible
.ng-dropdown-panel
.ng-dropdown-panel-items
max-height: 300px
.ng-option
white-space: nowrap
overflow: visible
text-overflow: ellipsis
:host :host
display: flex display: flex

View File

@@ -1,46 +1,74 @@
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, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, viewChild, inject, OnDestroy, OnInit } from '@angular/core';
import { Observable, map, distinctUntilChanged } from 'rxjs'; import { Observable, map, distinctUntilChanged } from 'rxjs';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
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 { AddDownloadPayload, 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 { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
FormsModule, FormsModule,
KeyValuePipe, KeyValuePipe,
AsyncPipe, AsyncPipe,
DatePipe,
FontAwesomeModule, FontAwesomeModule,
NgbModule, NgbModule,
NgSelectModule, NgSelectModule,
EtaPipe, EtaPipe,
SpeedPipe, SpeedPipe,
FileSizePipe, FileSizePipe,
MasterCheckboxComponent, SelectAllCheckboxComponent,
SlaveCheckboxComponent, ItemCheckboxComponent,
], ],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.sass', styleUrl: './app.sass',
}) })
export class App implements AfterViewInit, OnInit { export class App implements AfterViewInit, OnInit, OnDestroy {
downloads = inject(DownloadsService); downloads = inject(DownloadsService);
private cookieService = inject(CookieService); private cookieService = inject(CookieService);
private http = inject(HttpClient); private http = inject(HttpClient);
private cdr = inject(ChangeDetectorRef);
private destroyRef = inject(DestroyRef);
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;
formatOptions: Option[] = [];
qualities!: Quality[]; qualities!: Quality[];
downloadType: string;
codec: string;
quality: string; quality: string;
format: string; format: string;
folder!: string; folder!: string;
@@ -49,7 +77,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 +97,26 @@ 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_';
private readonly settingsCookieExpiryDays = 3650;
private lastFocusedElement: HTMLElement | null = null;
private colorSchemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
private onColorSchemeChanged = () => {
if (this.activeTheme && this.activeTheme.id === 'auto') {
this.setTheme(this.activeTheme);
}
};
// Download metrics // Download metrics
activeDownloads = 0; activeDownloads = 0;
@@ -80,15 +124,14 @@ export class App implements AfterViewInit, OnInit {
completedDownloads = 0; completedDownloads = 0;
failedDownloads = 0; failedDownloads = 0;
totalSpeed = 0; totalSpeed = 0;
hasCompletedDone = false;
hasFailedDone = false;
readonly queueMasterCheckbox = viewChild<MasterCheckboxComponent>('queueMasterCheckboxRef'); readonly queueMasterCheckbox = viewChild<SelectAllCheckboxComponent>('queueMasterCheckboxRef');
readonly queueDelSelected = viewChild.required<ElementRef>('queueDelSelected'); readonly queueDelSelected = viewChild.required<ElementRef>('queueDelSelected');
readonly queueDownloadSelected = viewChild.required<ElementRef>('queueDownloadSelected'); readonly queueDownloadSelected = viewChild.required<ElementRef>('queueDownloadSelected');
readonly doneMasterCheckbox = viewChild<MasterCheckboxComponent>('doneMasterCheckboxRef'); readonly doneMasterCheckbox = viewChild<SelectAllCheckboxComponent>('doneMasterCheckboxRef');
readonly doneDelSelected = viewChild.required<ElementRef>('doneDelSelected'); readonly doneDelSelected = viewChild.required<ElementRef>('doneDelSelected');
readonly doneClearCompleted = viewChild.required<ElementRef>('doneClearCompleted');
readonly doneClearFailed = viewChild.required<ElementRef>('doneClearFailed');
readonly doneRetryFailed = viewChild.required<ElementRef>('doneRetryFailed');
readonly doneDownloadSelected = viewChild.required<ElementRef>('doneDownloadSelected'); readonly doneDownloadSelected = viewChild.required<ElementRef>('doneDownloadSelected');
faTrashAlt = faTrashAlt; faTrashAlt = faTrashAlt;
@@ -110,13 +153,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,81 +206,89 @@ 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.refreshFormatOptions();
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);
// Subscribe to download updates // Subscribe to download updates
this.downloads.queueChanged.subscribe(() => { this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.updateMetrics(); this.updateMetrics();
this.cdr.markForCheck();
}); });
this.downloads.doneChanged.subscribe(() => { this.downloads.doneChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.updateMetrics(); this.updateMetrics();
this.rebuildSortedDone(); this.rebuildSortedDone();
this.cdr.markForCheck();
}); });
// Subscribe to real-time updates // Subscribe to real-time updates
this.downloads.updated.subscribe(() => { this.downloads.updated.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.updateMetrics(); this.updateMetrics();
this.cdr.markForCheck();
}); });
} }
ngOnInit() { ngOnInit() {
this.downloads.getCookieStatus().subscribe(data => { this.downloads.getCookieStatus().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
this.hasCookies = data?.has_cookies || false; this.hasCookies = !!(data && typeof data === 'object' && 'has_cookies' in data && data.has_cookies);
this.cdr.markForCheck();
}); });
this.getConfiguration(); this.getConfiguration();
this.getYtdlOptionsUpdateTime(); this.getYtdlOptionsUpdateTime();
this.customDirs$ = this.getMatchingCustomDir(); this.customDirs$ = this.getMatchingCustomDir();
this.setTheme(this.activeTheme!); this.setTheme(this.activeTheme!);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { this.colorSchemeMediaQuery.addEventListener('change', this.onColorSchemeChanged);
if (this.activeTheme && this.activeTheme.id === 'auto') {
this.setTheme(this.activeTheme);
}
});
} }
ngAfterViewInit() { ngAfterViewInit() {
this.downloads.queueChanged.subscribe(() => { this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.queueMasterCheckbox()?.selectionChanged(); this.queueMasterCheckbox()?.selectionChanged();
this.cdr.markForCheck();
}); });
this.downloads.doneChanged.subscribe(() => { this.downloads.doneChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.doneMasterCheckbox()?.selectionChanged(); this.doneMasterCheckbox()?.selectionChanged();
let completed = 0, failed = 0; this.updateDoneActionButtons();
this.downloads.done.forEach(dl => { this.cdr.markForCheck();
if (dl.status === 'finished')
completed++;
else if (dl.status === 'error')
failed++;
});
this.doneClearCompleted().nativeElement.disabled = completed === 0;
this.doneClearFailed().nativeElement.disabled = failed === 0;
this.doneRetryFailed().nativeElement.disabled = failed === 0;
}); });
// Initialize action button states for already-loaded entries.
this.updateDoneActionButtons();
this.fetchVersionInfo(); this.fetchVersionInfo();
} }
ngOnDestroy() {
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
}
// workaround to allow fetching of Map values in the order they were inserted // workaround to allow fetching of Map values in the order they were inserted
// https://github.com/angular/angular/issues/31420 // https://github.com/angular/angular/issues/31420
@@ -253,11 +299,29 @@ 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: this.settingsCookieExpiryDays });
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: this.settingsCookieExpiryDays });
this.normalizeSelectionsForType(false);
this.setQualities();
this.refreshFormatOptions();
this.saveSelection(this.downloadType);
this.previousDownloadType = this.downloadType;
this.downloads.customDirsChanged.next(this.downloads.customDirs);
}
codecChanged() {
this.cookieService.set('metube_codec', this.codec, { expires: this.settingsCookieExpiryDays });
this.saveSelection(this.downloadType);
}
showAdvanced() { showAdvanced() {
return this.downloads.configuration['CUSTOM_DIRS']; return this.downloads.configuration['CUSTOM_DIRS'];
} }
@@ -270,7 +334,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[]> {
@@ -291,7 +355,7 @@ export class App implements AfterViewInit, OnInit {
} }
getYtdlOptionsUpdateTime() { getYtdlOptionsUpdateTime() {
this.downloads.ytdlOptionsChanged.subscribe({ this.downloads.ytdlOptionsChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (data:any) => { next: (data:any) => {
if (data['success']){ if (data['success']){
@@ -300,11 +364,12 @@ export class App implements AfterViewInit, OnInit {
}else{ }else{
alert("Error reload yt-dlp options: "+data['msg']); alert("Error reload yt-dlp options: "+data['msg']);
} }
this.cdr.markForCheck();
} }
}); });
} }
getConfiguration() { getConfiguration() {
this.downloads.configurationChanged.subscribe({ this.downloads.configurationChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (config: any) => { next: (config: any) => {
const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT']; const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'];
@@ -315,6 +380,7 @@ export class App implements AfterViewInit, OnInit {
if (!this.chapterTemplate) { if (!this.chapterTemplate) {
this.chapterTemplate = config['OUTPUT_TEMPLATE_CHAPTER']; this.chapterTemplate = config['OUTPUT_TEMPLATE_CHAPTER'];
} }
this.cdr.markForCheck();
} }
}); });
} }
@@ -329,7 +395,7 @@ export class App implements AfterViewInit, OnInit {
} }
themeChanged(theme: Theme) { themeChanged(theme: Theme) {
this.cookieService.set('metube_theme', theme.id, { expires: 3650 }); this.cookieService.set('metube_theme', theme.id, { expires: this.settingsCookieExpiryDays });
this.setTheme(theme); this.setTheme(theme);
} }
@@ -343,39 +409,68 @@ 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: this.settingsCookieExpiryDays });
// 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);
} }
autoStartChanged() { autoStartChanged() {
this.cookieService.set('metube_auto_start', this.autoStart ? 'true' : 'false', { expires: 3650 }); this.cookieService.set('metube_auto_start', this.autoStart ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
} }
splitByChaptersChanged() { splitByChaptersChanged() {
this.cookieService.set('metube_split_chapters', this.splitByChapters ? 'true' : 'false', { expires: 3650 }); this.cookieService.set('metube_split_chapters', this.splitByChapters ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
} }
chapterTemplateChanged() { chapterTemplateChanged() {
// Restore default if template is cleared - get from configuration // Restore default if template is cleared - get from configuration
if (!this.chapterTemplate || this.chapterTemplate.trim() === '') { if (!this.chapterTemplate || this.chapterTemplate.trim() === '') {
this.chapterTemplate = this.downloads.configuration['OUTPUT_TEMPLATE_CHAPTER']; const configuredTemplate = this.downloads.configuration['OUTPUT_TEMPLATE_CHAPTER'];
this.chapterTemplate = typeof configuredTemplate === 'string' ? configuredTemplate : '';
} }
this.cookieService.set('metube_chapter_template', this.chapterTemplate, { expires: 3650 }); this.cookieService.set('metube_chapter_template', this.chapterTemplate, { expires: this.settingsCookieExpiryDays });
}
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: this.settingsCookieExpiryDays });
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: this.settingsCookieExpiryDays });
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) {
@@ -388,53 +483,181 @@ export class App implements AfterViewInit, OnInit {
this.doneDownloadSelected().nativeElement.disabled = checked == 0; this.doneDownloadSelected().nativeElement.disabled = checked == 0;
} }
setQualities() { private updateDoneActionButtons() {
// qualities for specific format let completed = 0;
const format = this.formats.find(el => el.id == this.format) let failed = 0;
if (format) { this.downloads.done.forEach((download) => {
this.qualities = format.qualities const isFailed = download.status === 'error';
const exists = this.qualities.find(el => el.id === this.quality) const isCompleted = !isFailed && (
this.quality = exists ? this.quality : 'best' download.status === 'finished' ||
download.status === 'completed' ||
Boolean(download.filename)
);
if (isCompleted) {
completed++;
} else if (isFailed) {
failed++;
}
});
this.hasCompletedDone = completed > 0;
this.hasFailedDone = failed > 0;
} }
}
addDownload( setQualities() {
url?: string, if (this.downloadType === 'video') {
quality?: string, this.qualities = this.format === 'ios'
format?: string, ? [{ id: 'best', text: 'Best' }]
folder?: string, : VIDEO_QUALITIES;
customNamePrefix?: string, } else if (this.downloadType === 'audio') {
playlistItemLimit?: number, const selectedFormat = this.audioFormats.find(el => el.id === this.format);
autoStart?: boolean, this.qualities = selectedFormat ? selectedFormat.qualities : [{ id: 'best', text: 'Best' }];
splitByChapters?: boolean, } else {
chapterTemplate?: string, this.qualities = [{ id: 'best', text: 'Best' }];
subtitleFormat?: string, }
subtitleLanguage?: string, const exists = this.qualities.find(el => el.id === this.quality);
subtitleMode?: string, this.quality = exists ? this.quality : 'best';
) { }
url = url ?? this.addUrl
quality = quality ?? this.quality refreshFormatOptions() {
format = format ?? this.format if (this.downloadType === 'video') {
folder = folder ?? this.folder this.formatOptions = this.videoFormats;
customNamePrefix = customNamePrefix ?? this.customNamePrefix return;
playlistItemLimit = playlistItemLimit ?? this.playlistItemLimit }
autoStart = autoStart ?? this.autoStart if (this.downloadType === 'audio') {
splitByChapters = splitByChapters ?? this.splitByChapters this.formatOptions = this.audioFormats;
chapterTemplate = chapterTemplate ?? this.chapterTemplate return;
subtitleFormat = subtitleFormat ?? this.subtitleFormat }
subtitleLanguage = subtitleLanguage ?? this.subtitleLanguage if (this.downloadType === 'captions') {
subtitleMode = subtitleMode ?? this.subtitleMode this.formatOptions = this.captionFormats;
return;
}
this.formatOptions = this.thumbnailFormats;
}
showCodecSelector() {
return this.downloadType === 'video';
}
showFormatSelector() {
return this.downloadType !== 'thumbnail';
}
showQualitySelector() {
if (this.downloadType === 'video') {
return this.format !== 'ios';
}
return this.downloadType === 'audio';
}
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: this.settingsCookieExpiryDays });
this.cookieService.set('metube_codec', this.codec, { expires: this.settingsCookieExpiryDays });
}
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: this.settingsCookieExpiryDays }
);
}
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.
}
}
}
private buildAddPayload(overrides: Partial<AddDownloadPayload> = {}): AddDownloadPayload {
return {
url: overrides.url ?? this.addUrl,
downloadType: overrides.downloadType ?? this.downloadType,
codec: overrides.codec ?? this.codec,
quality: overrides.quality ?? this.quality,
format: overrides.format ?? this.format,
folder: overrides.folder ?? this.folder,
customNamePrefix: overrides.customNamePrefix ?? this.customNamePrefix,
playlistItemLimit: overrides.playlistItemLimit ?? this.playlistItemLimit,
autoStart: overrides.autoStart ?? this.autoStart,
splitByChapters: overrides.splitByChapters ?? this.splitByChapters,
chapterTemplate: overrides.chapterTemplate ?? this.chapterTemplate,
subtitleLanguage: overrides.subtitleLanguage ?? this.subtitleLanguage,
subtitleMode: overrides.subtitleMode ?? this.subtitleMode,
};
}
addDownload(overrides: Partial<AddDownloadPayload> = {}) {
const payload = this.buildAddPayload(overrides);
// Validate chapter template if chapter splitting is enabled // Validate chapter template if chapter splitting is enabled
if (splitByChapters && !chapterTemplate.includes('%(section_number)')) { if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
alert('Chapter template must include %(section_number)'); alert('Chapter template must include %(section_number)');
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:', payload);
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(payload).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') {
@@ -459,20 +682,21 @@ export class App implements AfterViewInit, OnInit {
} }
retryDownload(key: string, download: Download) { retryDownload(key: string, download: Download) {
this.addDownload( this.addDownload({
download.url, url: download.url,
download.quality, downloadType: download.download_type,
download.format, codec: download.codec,
download.folder, quality: download.quality,
download.custom_name_prefix, format: download.format,
download.playlist_item_limit, folder: download.folder,
true, customNamePrefix: download.custom_name_prefix,
download.split_by_chapters, playlistItemLimit: download.playlist_item_limit,
download.chapter_template, autoStart: true,
download.subtitle_format, splitByChapters: download.split_by_chapters,
download.subtitle_language, chapterTemplate: download.chapter_template,
download.subtitle_mode, subtitleLanguage: download.subtitle_language,
); subtitleMode: download.subtitle_mode,
});
this.downloads.delById('done', [key]).subscribe(); this.downloads.delById('done', [key]).subscribe();
} }
@@ -521,12 +745,12 @@ 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"];
} }
if (download.folder) { if (download.folder) {
baseDir += download.folder + '/'; baseDir += this.encodeFolderPath(download.folder);
} }
return baseDir + encodeURIComponent(download.filename); return baseDir + encodeURIComponent(download.filename);
@@ -545,17 +769,25 @@ 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"];
} }
if (download.folder) { if (download.folder) {
baseDir += download.folder + '/'; baseDir += this.encodeFolderPath(download.folder);
} }
return baseDir + encodeURIComponent(chapterFilename); return baseDir + encodeURIComponent(chapterFilename);
} }
private encodeFolderPath(folder: string): string {
return folder
.split('/')
.filter(segment => segment.length > 0)
.map(segment => encodeURIComponent(segment))
.join('/') + '/';
}
getChapterFileName(filepath: string) { getChapterFileName(filepath: string) {
// Extract just the filename from the path // Extract just the filename from the path
const parts = filepath.split('/'); const parts = filepath.split('/');
@@ -563,8 +795,12 @@ export class App implements AfterViewInit, OnInit {
} }
isNumber(event: KeyboardEvent) { isNumber(event: KeyboardEvent) {
const charCode = +event.code || event.keyCode; const allowedControlKeys = ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab', 'Home', 'End'];
if (charCode > 31 && (charCode < 48 || charCode > 57)) { if (allowedControlKeys.includes(event.key)) {
return;
}
if (!/^[0-9]$/.test(event.key)) {
event.preventDefault(); event.preventDefault();
} }
} }
@@ -576,16 +812,24 @@ export class App implements AfterViewInit, OnInit {
// Open the Batch Import modal // Open the Batch Import modal
openBatchImportModal(): void { openBatchImportModal(): void {
this.lastFocusedElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
this.batchImportModalOpen = true; this.batchImportModalOpen = true;
this.batchImportText = ''; this.batchImportText = '';
this.batchImportStatus = ''; this.batchImportStatus = '';
this.importInProgress = false; this.importInProgress = false;
this.cancelImportFlag = false; this.cancelImportFlag = false;
setTimeout(() => {
const textarea = document.getElementById('batch-import-textarea');
if (textarea instanceof HTMLTextAreaElement) {
textarea.focus();
}
}, 0);
} }
// Close the Batch Import modal // Close the Batch Import modal
closeBatchImportModal(): void { closeBatchImportModal(): void {
this.batchImportModalOpen = false; this.batchImportModalOpen = false;
this.lastFocusedElement?.focus();
} }
// Start importing URLs from the batch modal textarea // Start importing URLs from the batch modal textarea
@@ -616,10 +860,8 @@ 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(this.buildAddPayload({ url }))
this.playlistItemLimit, this.autoStart, this.splitByChapters, this.chapterTemplate,
this.subtitleFormat, this.subtitleLanguage, this.subtitleMode)
.subscribe({ .subscribe({
next: (status: Status) => { next: (status: Status) => {
if (status.status === 'error') { if (status.status === 'error') {
@@ -728,7 +970,7 @@ export class App implements AfterViewInit, OnInit {
toggleSortOrder() { toggleSortOrder() {
this.sortAscending = !this.sortAscending; this.sortAscending = !this.sortAscending;
this.cookieService.set('metube_sort_ascending', this.sortAscending ? 'true' : 'false', { expires: 3650 }); this.cookieService.set('metube_sort_ascending', this.sortAscending ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
this.rebuildSortedDone(); this.rebuildSortedDone();
} }
@@ -852,20 +1094,40 @@ 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);
}); });
} }
private updateMetrics() { private updateMetrics() {
this.activeDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'downloading' || d.status === 'preparing').length; let active = 0;
this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length; let queued = 0;
this.completedDownloads = Array.from(this.downloads.done.values()).filter(d => d.status === 'finished').length; let completed = 0;
this.failedDownloads = Array.from(this.downloads.done.values()).filter(d => d.status === 'error').length; let failed = 0;
let speed = 0;
// Calculate total speed from downloading items
const downloadingItems = Array.from(this.downloads.queue.values()) this.downloads.queue.forEach((download) => {
.filter(d => d.status === 'downloading'); if (download.status === 'downloading') {
active++;
this.totalSpeed = downloadingItems.reduce((total, item) => total + (item.speed || 0), 0); speed += download.speed || 0;
} else if (download.status === 'preparing') {
active++;
} else if (download.status === 'pending') {
queued++;
}
});
this.downloads.done.forEach((download) => {
if (download.status === 'finished') {
completed++;
} else if (download.status === 'error') {
failed++;
}
});
this.activeDownloads = active;
this.queuedDownloads = queued;
this.completedDownloads = completed;
this.failedDownloads = failed;
this.totalSpeed = speed;
} }
} }

View File

@@ -1,2 +1,2 @@
export { MasterCheckboxComponent } from './master-checkbox.component'; export { SelectAllCheckboxComponent } from './master-checkbox.component';
export { SlaveCheckboxComponent } from './slave-checkbox.component'; export { ItemCheckboxComponent } from './slave-checkbox.component';

View File

@@ -3,18 +3,18 @@ import { Checkable } from "../interfaces";
import { FormsModule } from "@angular/forms"; import { FormsModule } from "@angular/forms";
@Component({ @Component({
selector: 'app-master-checkbox', selector: 'app-select-all-checkbox',
template: ` template: `
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" id="{{id()}}-select-all" #masterCheckbox [(ngModel)]="selected" (change)="clicked()"> <input type="checkbox" class="form-check-input" id="{{id()}}-select-all" #masterCheckbox [(ngModel)]="selected" (change)="clicked()" [attr.aria-label]="'Select all ' + id() + ' items'">
<label class="form-check-label" for="{{id()}}-select-all"></label> <label class="form-check-label visually-hidden" for="{{id()}}-select-all">Select all</label>
</div> </div>
`, `,
imports: [ imports: [
FormsModule FormsModule
] ]
}) })
export class MasterCheckboxComponent { export class SelectAllCheckboxComponent {
readonly id = input.required<string>(); readonly id = input.required<string>();
readonly list = input.required<Map<string, Checkable>>(); readonly list = input.required<Map<string, Checkable>>();
readonly changed = output<number>(); readonly changed = output<number>();

View File

@@ -1,22 +1,22 @@
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
import { MasterCheckboxComponent } from './master-checkbox.component'; import { SelectAllCheckboxComponent } from './master-checkbox.component';
import { Checkable } from '../interfaces'; import { Checkable } from '../interfaces';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@Component({ @Component({
selector: 'app-slave-checkbox', selector: 'app-item-checkbox',
template: ` template: `
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" id="{{master().id()}}-{{id()}}-select" [(ngModel)]="checkable().checked" (change)="master().selectionChanged()"> <input type="checkbox" class="form-check-input" id="{{master().id()}}-{{id()}}-select" [(ngModel)]="checkable().checked" (change)="master().selectionChanged()" [attr.aria-label]="'Select item ' + id()">
<label class="form-check-label" for="{{master().id()}}-{{id()}}-select"></label> <label class="form-check-label visually-hidden" for="{{master().id()}}-{{id()}}-select">Select item</label>
</div> </div>
`, `,
imports: [ imports: [
FormsModule FormsModule
] ]
}) })
export class SlaveCheckboxComponent { export class ItemCheckboxComponent {
readonly id = input.required<string>(); readonly id = input.required<string>();
readonly master = input.required<MasterCheckboxComponent>(); readonly master = input.required<SelectAllCheckboxComponent>();
readonly checkable = input.required<Checkable>(); readonly checkable = input.required<Checkable>();
} }

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 }[];
} }

View File

@@ -1,81 +1,77 @@
import { Format } from "./format"; import { Quality } from "./quality";
export interface Option {
id: string;
text: string;
}
export const Formats: Format[] = [ export interface AudioFormatOption extends Option {
{ qualities: Quality[];
id: 'any', }
text: 'Any',
qualities: [ export const DOWNLOAD_TYPES: Option[] = [
{ id: 'best', text: 'Best' }, { id: "video", text: "Video" },
{ id: '2160', text: '2160p' }, { id: "audio", text: "Audio" },
{ id: '1440', text: '1440p' }, { id: "captions", text: "Captions" },
{ id: '1080', text: '1080p' }, { id: "thumbnail", text: "Thumbnail" },
{ id: '720', text: '720p' },
{ id: '480', text: '480p' },
{ id: '360', text: '360p' },
{ id: '240', text: '240p' },
{ id: 'worst', text: 'Worst' },
{ id: 'audio', text: 'Audio Only' },
],
},
{
id: 'mp4',
text: 'MP4',
qualities: [
{ id: 'best', text: 'Best' },
{ id: 'best_ios', text: 'Best (iOS)' },
{ id: '2160', text: '2160p' },
{ id: '1440', text: '1440p' },
{ id: '1080', text: '1080p' },
{ id: '720', text: '720p' },
{ id: '480', text: '480p' },
{ id: '360', text: '360p' },
{ id: '240', text: '240p' },
{ id: 'worst', text: 'Worst' },
],
},
{
id: 'm4a',
text: 'M4A',
qualities: [
{ id: 'best', text: 'Best' },
{ id: '192', text: '192 kbps' },
{ id: '128', text: '128 kbps' },
],
},
{
id: 'mp3',
text: 'MP3',
qualities: [
{ id: 'best', text: 'Best' },
{ id: '320', text: '320 kbps' },
{ id: '192', text: '192 kbps' },
{ id: '128', text: '128 kbps' },
],
},
{
id: 'opus',
text: 'OPUS',
qualities: [{ id: 'best', text: 'Best' }],
},
{
id: 'wav',
text: 'WAV',
qualities: [{ id: 'best', text: 'Best' }],
},
{
id: 'flac',
text: 'FLAC',
qualities: [{ id: 'best', text: 'Best' }],
},
{
id: 'thumbnail',
text: 'Thumbnail',
qualities: [{ id: 'best', text: 'Best' }],
},
{
id: 'captions',
text: 'Captions',
qualities: [{ id: 'best', text: 'Best' }],
},
]; ];
export const VIDEO_CODECS: Option[] = [
{ id: "auto", text: "Auto" },
{ id: "h264", text: "H.264" },
{ id: "h265", text: "H.265 (HEVC)" },
{ id: "av1", text: "AV1" },
{ id: "vp9", text: "VP9" },
];
export const VIDEO_FORMATS: Option[] = [
{ id: "any", text: "Auto" },
{ id: "mp4", text: "MP4" },
{ id: "ios", text: "iOS Compatible" },
];
export const VIDEO_QUALITIES: Quality[] = [
{ id: "best", text: "Best" },
{ id: "2160", text: "2160p" },
{ id: "1440", text: "1440p" },
{ id: "1080", text: "1080p" },
{ id: "720", text: "720p" },
{ id: "480", text: "480p" },
{ id: "360", text: "360p" },
{ id: "240", text: "240p" },
{ id: "worst", text: "Worst" },
];
export const AUDIO_FORMATS: AudioFormatOption[] = [
{
id: "m4a",
text: "M4A",
qualities: [
{ id: "best", text: "Best" },
{ id: "192", text: "192 kbps" },
{ id: "128", text: "128 kbps" },
],
},
{
id: "mp3",
text: "MP3",
qualities: [
{ id: "best", text: "Best" },
{ id: "320", text: "320 kbps" },
{ id: "192", text: "192 kbps" },
{ id: "128", text: "128 kbps" },
],
},
{ id: "opus", text: "OPUS", qualities: [{ id: "best", text: "Best" }] },
{ id: "wav", text: "WAV", qualities: [{ id: "best", text: "Best" }] },
{ id: "flac", text: "FLAC", qualities: [{ id: "best", text: "Best" }] },
];
export const CAPTION_FORMATS: Option[] = [
{ id: "srt", text: "SRT" },
{ id: "txt", text: "TXT (Text only)" },
{ id: "vtt", text: "VTT" },
{ id: "ttml", text: "TTML" },
];
export const THUMBNAIL_FORMATS: Option[] = [{ id: "jpg", text: "JPG" }];

View File

@@ -0,0 +1,15 @@
import { SpeedPipe } from './speed.pipe';
describe('SpeedPipe', () => {
it('returns empty string for non-positive speed values', () => {
const pipe = new SpeedPipe();
expect(pipe.transform(0)).toBe('');
expect(pipe.transform(-1)).toBe('');
});
it('formats bytes per second values', () => {
const pipe = new SpeedPipe();
expect(pipe.transform(1024)).toBe('1 KB/s');
expect(pipe.transform(1536)).toBe('1.5 KB/s');
});
});

View File

@@ -1,43 +1,19 @@
import { Pipe, PipeTransform } from "@angular/core"; import { Pipe, PipeTransform } from "@angular/core";
import { BehaviorSubject, throttleTime } from "rxjs";
@Pipe({ @Pipe({
name: 'speed', name: 'speed',
pure: false // Make the pipe impure so it can handle async updates pure: true
}) })
export class SpeedPipe implements PipeTransform { export class SpeedPipe implements PipeTransform {
private speedSubject = new BehaviorSubject<number>(0);
private formattedSpeed = '';
constructor() {
// Throttle updates to once per second
this.speedSubject.pipe(
throttleTime(1000)
).subscribe(speed => {
// If speed is invalid or 0, return empty string
if (speed === null || speed === undefined || isNaN(speed) || speed <= 0) {
this.formattedSpeed = '';
return;
}
const k = 1024;
const dm = 2;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
const i = Math.floor(Math.log(speed) / Math.log(k));
this.formattedSpeed = parseFloat((speed / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
});
}
transform(value: number): string { transform(value: number): string {
// If speed is invalid or 0, return empty string
if (value === null || value === undefined || isNaN(value) || value <= 0) { if (value === null || value === undefined || isNaN(value) || value <= 0) {
return ''; return '';
} }
// Update the speed subject const k = 1024;
this.speedSubject.next(value); const decimals = 2;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
// Return the last formatted speed const i = Math.floor(Math.log(value) / Math.log(k));
return this.formattedSpeed; return `${parseFloat((value / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
} }
} }

View File

@@ -5,6 +5,22 @@ import { catchError } from 'rxjs/operators';
import { MeTubeSocket } from './metube-socket.service'; import { MeTubeSocket } from './metube-socket.service';
import { Download, Status, State } from '../interfaces'; import { Download, Status, State } from '../interfaces';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export interface AddDownloadPayload {
url: string;
downloadType: string;
codec: string;
quality: string;
format: string;
folder: string;
customNamePrefix: string;
playlistItemLimit: number;
autoStart: boolean;
splitByChapters: boolean;
chapterTemplate: string;
subtitleLanguage: string;
subtitleMode: string;
}
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
@@ -14,16 +30,15 @@ export class DownloadsService {
loading = true; loading = true;
queue = new Map<string, Download>(); queue = new Map<string, Download>();
done = new Map<string, Download>(); done = new Map<string, Download>();
queueChanged = new Subject(); queueChanged = new Subject<void>();
doneChanged = new Subject(); doneChanged = new Subject<void>();
customDirsChanged = new Subject(); customDirsChanged = new Subject<Record<string, string[]>>();
ytdlOptionsChanged = new Subject(); ytdlOptionsChanged = new Subject<Record<string, unknown>>();
configurationChanged = new Subject(); configurationChanged = new Subject<Record<string, unknown>>();
updated = new Subject(); updated = new Subject<void>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any configuration: Record<string, unknown> = {};
configuration: any = {}; customDirs: Record<string, string[]> = {};
customDirs = {};
constructor() { constructor() {
this.socket.fromEvent('all') this.socket.fromEvent('all')
@@ -35,15 +50,15 @@ export class DownloadsService {
data[0].forEach(entry => this.queue.set(...entry)); data[0].forEach(entry => this.queue.set(...entry));
this.done.clear(); this.done.clear();
data[1].forEach(entry => this.done.set(...entry)); data[1].forEach(entry => this.done.set(...entry));
this.queueChanged.next(null); this.queueChanged.next();
this.doneChanged.next(null); this.doneChanged.next();
}); });
this.socket.fromEvent('added') this.socket.fromEvent('added')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata); const data: Download = JSON.parse(strdata);
this.queue.set(data.url, data); this.queue.set(data.url, data);
this.queueChanged.next(null); this.queueChanged.next();
}); });
this.socket.fromEvent('updated') this.socket.fromEvent('updated')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
@@ -53,7 +68,7 @@ export class DownloadsService {
data.checked = !!dl?.checked; data.checked = !!dl?.checked;
data.deleting = !!dl?.deleting; data.deleting = !!dl?.deleting;
this.queue.set(data.url, data); this.queue.set(data.url, data);
this.updated.next(null); this.updated.next();
}); });
this.socket.fromEvent('completed') this.socket.fromEvent('completed')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
@@ -61,22 +76,22 @@ export class DownloadsService {
const data: Download = JSON.parse(strdata); const data: Download = JSON.parse(strdata);
this.queue.delete(data.url); this.queue.delete(data.url);
this.done.set(data.url, data); this.done.set(data.url, data);
this.queueChanged.next(null); this.queueChanged.next();
this.doneChanged.next(null); this.doneChanged.next();
}); });
this.socket.fromEvent('canceled') this.socket.fromEvent('canceled')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data: string = JSON.parse(strdata); const data: string = JSON.parse(strdata);
this.queue.delete(data); this.queue.delete(data);
this.queueChanged.next(null); this.queueChanged.next();
}); });
this.socket.fromEvent('cleared') this.socket.fromEvent('cleared')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data: string = JSON.parse(strdata); const data: string = JSON.parse(strdata);
this.done.delete(data); this.done.delete(data);
this.doneChanged.next(null); this.doneChanged.next();
}); });
this.socket.fromEvent('configuration') this.socket.fromEvent('configuration')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
@@ -103,37 +118,29 @@ export class DownloadsService {
} }
handleHTTPError(error: HttpErrorResponse) { handleHTTPError(error: HttpErrorResponse) {
const msg = error.error instanceof ErrorEvent ? error.error.message : error.error; const msg = error.error instanceof ErrorEvent
return of({status: 'error', msg: msg}) ? error.error.message
: (typeof error.error === 'string'
? error.error
: (error.error?.msg || error.message || 'Request failed'));
return of({ status: 'error', msg });
} }
public add( public add(payload: AddDownloadPayload) {
url: string,
quality: string,
format: string,
folder: string,
customNamePrefix: string,
playlistItemLimit: number,
autoStart: boolean,
splitByChapters: boolean,
chapterTemplate: string,
subtitleFormat: string,
subtitleLanguage: string,
subtitleMode: string,
) {
return this.http.post<Status>('add', { return this.http.post<Status>('add', {
url: url, url: payload.url,
quality: quality, download_type: payload.downloadType,
format: format, codec: payload.codec,
folder: folder, quality: payload.quality,
custom_name_prefix: customNamePrefix, format: payload.format,
playlist_item_limit: playlistItemLimit, folder: payload.folder,
auto_start: autoStart, custom_name_prefix: payload.customNamePrefix,
split_by_chapters: splitByChapters, playlist_item_limit: payload.playlistItemLimit,
chapter_template: chapterTemplate, auto_start: payload.autoStart,
subtitle_format: subtitleFormat, split_by_chapters: payload.splitByChapters,
subtitle_language: subtitleLanguage, chapter_template: payload.chapterTemplate,
subtitle_mode: subtitleMode subtitle_language: payload.subtitleLanguage,
subtitle_mode: payload.subtitleMode,
}).pipe( }).pipe(
catchError(this.handleHTTPError) catchError(this.handleHTTPError)
); );
@@ -167,47 +174,6 @@ export class DownloadsService {
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) }); this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
return this.delById(where, ids); return this.delById(where, ids);
} }
public addDownloadByUrl(url: string): Promise<{
response: Status} | {
status: string;
msg?: string;
}> {
const defaultQuality = 'best';
const defaultFormat = 'mp4';
const defaultFolder = '';
const defaultCustomNamePrefix = '';
const defaultPlaylistItemLimit = 0;
const defaultAutoStart = true;
const defaultSplitByChapters = false;
const defaultChapterTemplate = this.configuration['OUTPUT_TEMPLATE_CHAPTER'];
const defaultSubtitleFormat = 'srt';
const defaultSubtitleLanguage = 'en';
const defaultSubtitleMode = 'prefer_manual';
return new Promise((resolve, reject) => {
this.add(
url,
defaultQuality,
defaultFormat,
defaultFolder,
defaultCustomNamePrefix,
defaultPlaylistItemLimit,
defaultAutoStart,
defaultSplitByChapters,
defaultChapterTemplate,
defaultSubtitleFormat,
defaultSubtitleLanguage,
defaultSubtitleMode,
)
.subscribe({
next: (response) => resolve(response),
error: (error) => reject(error)
});
});
}
public exportQueueUrls(): string[] {
return Array.from(this.queue.values()).map(download => download.url);
}
public cancelAdd() { public cancelAdd() {
return this.http.post<Status>('cancel-add', {}).pipe( return this.http.post<Status>('cancel-add', {}).pipe(
catchError(this.handleHTTPError) catchError(this.handleHTTPError)
@@ -217,19 +183,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)
); );
} }

View File

@@ -1,3 +1,2 @@
export { DownloadsService } from './downloads.service'; export { DownloadsService } from './downloads.service';
export { SpeedService } from './speed.service';
export { MeTubeSocket } from './metube-socket.service'; export { MeTubeSocket } from './metube-socket.service';

View File

@@ -1,39 +0,0 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, interval } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class SpeedService {
private speedBuffer = new BehaviorSubject<number[]>([]);
private readonly BUFFER_SIZE = 10; // Keep last 10 measurements (1 second at 100ms intervals)
// Observable that emits the mean speed every second
public meanSpeed$: Observable<number>;
constructor() {
// Calculate mean speed every second
this.meanSpeed$ = interval(1000).pipe(
map(() => {
const speeds = this.speedBuffer.value;
if (speeds.length === 0) return 0;
return speeds.reduce((sum, speed) => sum + speed, 0) / speeds.length;
})
);
}
// Add a new speed measurement
public addSpeedMeasurement(speed: number) {
const currentBuffer = this.speedBuffer.value;
const newBuffer = [...currentBuffer, speed].slice(-this.BUFFER_SIZE);
this.speedBuffer.next(newBuffer);
}
// Get the current mean speed
public getCurrentMeanSpeed(): number {
const speeds = this.speedBuffer.value;
if (speeds.length === 0) return 0;
return speeds.reduce((sum, speed) => sum + speed, 0) / speeds.length;
}
}

View File

@@ -5,3 +5,22 @@
[data-bs-theme="dark"] & [data-bs-theme="dark"] &
background-color: var(--bs-dark-bg-subtle) !important background-color: var(--bs-dark-bg-subtle) !important
.ng-select
flex: 1
.ng-select-container
min-height: 38px
.ng-value
white-space: nowrap
overflow: visible
.ng-dropdown-panel
.ng-dropdown-panel-items
max-height: 300px
.ng-option
white-space: nowrap
overflow: visible
text-overflow: ellipsis

137
uv.lock generated
View File

@@ -160,18 +160,23 @@ wheels = [
[[package]] [[package]]
name = "brotlicffi" name = "brotlicffi"
version = "1.2.0.0" version = "1.2.0.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cffi" }, { name = "cffi" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" } sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/df/a72b284d8c7bef0ed5756b41c2eb7d0219a1dd6ac6762f1c7bdbc31ef3af/brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4", size = 432340, upload-time = "2025-11-21T18:17:42.277Z" }, { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" },
{ url = "https://files.pythonhosted.org/packages/74/2b/cc55a2d1d6fb4f5d458fba44a3d3f91fb4320aa14145799fd3a996af0686/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7", size = 1534002, upload-time = "2025-11-21T18:17:43.746Z" }, { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" },
{ url = "https://files.pythonhosted.org/packages/e4/9c/d51486bf366fc7d6735f0e46b5b96ca58dc005b250263525a1eea3cd5d21/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990", size = 1536547, upload-time = "2025-11-21T18:17:45.729Z" }, { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" },
{ url = "https://files.pythonhosted.org/packages/1b/37/293a9a0a7caf17e6e657668bebb92dfe730305999fe8c0e2703b8888789c/brotlicffi-1.2.0.0-cp38-abi3-win32.whl", hash = "sha256:23e5c912fdc6fd37143203820230374d24babd078fc054e18070a647118158f6", size = 343085, upload-time = "2025-11-21T18:17:48.887Z" }, { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/07/6b/6e92009df3b8b7272f85a0992b306b61c34b7ea1c4776643746e61c380ac/brotlicffi-1.2.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:f139a7cdfe4ae7859513067b736eb44d19fae1186f9e99370092f6915216451b", size = 378586, upload-time = "2025-11-21T18:17:50.531Z" }, { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" },
{ url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" },
{ url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" },
{ url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" },
] ]
[[package]] [[package]]
@@ -230,43 +235,59 @@ wheels = [
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.4" version = "3.4.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
{ url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
{ url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
{ url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
{ url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
{ url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
{ url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
{ url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
{ url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
{ url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
{ url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
{ url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
{ url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
{ url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
{ url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
] ]
[[package]] [[package]]
@@ -303,15 +324,15 @@ wheels = [
[[package]] [[package]]
name = "deno" name = "deno"
version = "2.7.2" version = "2.7.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bd/29/b2941d53d94094e20e52def86956528140dbe60b49d715803f7e9799d42f/deno-2.7.2.tar.gz", hash = "sha256:3dc9461ac4dd0d6661769f03460861709e17c4e516dfce14676e6a3146824b7b", size = 8167, upload-time = "2026-03-03T16:10:51.429Z" } sdist = { url = "https://files.pythonhosted.org/packages/d8/31/8bbaf3fb6a41929ae161be0b2a79b2747b5e5490811573ef60af7e3aeac3/deno-2.7.5.tar.gz", hash = "sha256:50635e0462697fa6e79d90bcacbe98e19f785e604c0e5061754de89b3668af83", size = 8166, upload-time = "2026-03-11T12:48:44.286Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/a0/9e6f45c25ef36db827e75bd35bf9378c196a6bed2804a8259d1d63bab84f/deno-2.7.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:67509919fa9df639d9375e441648ae5a3ab9bb1ce6fcddc21c49c08368af4d68", size = 46325714, upload-time = "2026-03-03T16:10:35.82Z" }, { url = "https://files.pythonhosted.org/packages/68/15/47c4b8da4e1b312ab14a2517e3f484c4d67a879cb5099cb6c33b8ce00c8c/deno-2.7.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:29cb89cdaea5f36133841fb4da058b1c6cb70d117ebfc7a24c717747b58e8503", size = 46641593, upload-time = "2026-03-11T12:48:16.589Z" },
{ url = "https://files.pythonhosted.org/packages/83/ce/085c3002cdfc0d33b30896b3d1469024c23e3971cba4a15ae3983c48d2e4/deno-2.7.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a19f75d7a148a2d030543db88734f03648e31dc7385a9c62aa1d975e2b0df8d9", size = 43264279, upload-time = "2026-03-03T16:10:39.011Z" }, { url = "https://files.pythonhosted.org/packages/1c/3a/c3f8842b7499ff3faeb7508711a82b736d3a4c6e0ffb359191386bcf539d/deno-2.7.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6456980341e97e4eb88e0c560fa57cd1b5f732e0eaadccc6c47d5ada73a71ff3", size = 43537874, upload-time = "2026-03-11T12:48:21.958Z" },
{ url = "https://files.pythonhosted.org/packages/38/f0/c415c08ca30fb084887a96b88df7f6511c98575b365db87b0fac76a82773/deno-2.7.2-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:f7b63f13c9fdeb18d0435e80aa4677878ac1b9ac23a49c7570958b9d81772e06", size = 47024484, upload-time = "2026-03-03T16:10:42.619Z" }, { url = "https://files.pythonhosted.org/packages/71/a2/53a013ba3509648582748678d5c6980210a45e0913934f91bfe1ec237e07/deno-2.7.5-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:fdc1e647a06ef792643237c030f45295692b0abc05d5bc9894fb11fd70876953", size = 47265090, upload-time = "2026-03-11T12:48:26.819Z" },
{ url = "https://files.pythonhosted.org/packages/e6/14/bfac1928082f78f120aaff7608f211a8beab8f66e72defc0ac85d6f52f84/deno-2.7.2-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:bded39ebc9d19748a13a4c046a715f12c445a3e15c0b4cde6d42cc47793efcf0", size = 48981918, upload-time = "2026-03-03T16:10:45.822Z" }, { url = "https://files.pythonhosted.org/packages/3e/85/88c76daa72575f7229bb94191f15f4771f0614227bf8467bfe06e051f4ab/deno-2.7.5-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:c15e6b8ccf5f0808cd5ba243ea4eea7d8d78f6fdff228f5c6c85b96ba286bd3c", size = 49262188, upload-time = "2026-03-11T12:48:32.125Z" },
{ url = "https://files.pythonhosted.org/packages/79/07/b332f98969937d435ba2905195a0b3dd2162f192659595dde88c615b04e1/deno-2.7.2-py3-none-win_amd64.whl", hash = "sha256:5d525d270e16d5ea22ad90a65e1ebc0dff8b83068d698f6bad138bfa857e4d28", size = 48330774, upload-time = "2026-03-03T16:10:49.209Z" }, { url = "https://files.pythonhosted.org/packages/42/5e/501a92ef93d6d46ed8a1a8c03cff8bcbccbc06c1f59b163113ff09cd23cf/deno-2.7.5-py3-none-win_amd64.whl", hash = "sha256:3e3d06006ee39901dd23068c4a501a4a524fb71c323e22503b1b2ddf236da463", size = 48481169, upload-time = "2026-03-11T12:48:38.684Z" },
] ]
[[package]] [[package]]
@@ -555,11 +576,11 @@ wheels = [
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.9.2" version = "4.9.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
] ]
[[package]] [[package]]
@@ -951,11 +972,11 @@ wheels = [
[[package]] [[package]]
name = "yt-dlp" name = "yt-dlp"
version = "2026.3.3" version = "2026.3.17"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/66/6f/7427d23609353e5ef3470ff43ef551b8bd7b166dd4fef48957f0d0e040fe/yt_dlp-2026.3.3.tar.gz", hash = "sha256:3db7969e3a8964dc786bdebcffa2653f31123bf2a630f04a17bdafb7bbd39952", size = 3118658, upload-time = "2026-03-03T16:54:53.909Z" } sdist = { url = "https://files.pythonhosted.org/packages/8b/34/7c6b4e3f89cb6416d2cd7ab6dab141a1df97ab0fb22d15816db2c92148c9/yt_dlp-2026.3.17.tar.gz", hash = "sha256:ba7aa31d533f1ffccfe70e421596d7ca8ff0bf1398dc6bb658b7d9dec057d2c9", size = 3119221, upload-time = "2026-03-17T23:43:00.244Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/a4/8b5cd28ab87aef48ef15e74241befec3445496327db028f34147a9e0f14f/yt_dlp-2026.3.3-py3-none-any.whl", hash = "sha256:166c6e68c49ba526474bd400e0129f58aa522c2896204aa73be669c3d2f15e63", size = 3315599, upload-time = "2026-03-03T16:54:51.899Z" }, { url = "https://files.pythonhosted.org/packages/cd/13/5093bcb954878e50f7217fd2ab94282b53934022e4e4a03265582da83bf5/yt_dlp-2026.3.17-py3-none-any.whl", hash = "sha256:32992db94303a8a5d211a183f2174834fe7f8c29d83ed2e7a324eae97a8f26d8", size = 3315134, upload-time = "2026-03-17T23:42:57.863Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -979,9 +1000,9 @@ deno = [
[[package]] [[package]]
name = "yt-dlp-ejs" name = "yt-dlp-ejs"
version = "0.5.0" version = "0.8.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/0d/b9e4ab1b47cdeba0842df634b74b3c0144307640ad5b632a5e189c4ab7ce/yt_dlp_ejs-0.5.0.tar.gz", hash = "sha256:8dfae59e418232f485253dcf8e197fefa232423c3af7824fe19e4517b173293b", size = 98925, upload-time = "2026-02-21T19:29:16.844Z" } sdist = { url = "https://files.pythonhosted.org/packages/d3/e6/cceb9530e8f4e5940f6f7822d90e9d94f1b85343329a16baaf47bbbb3de1/yt_dlp_ejs-0.8.0.tar.gz", hash = "sha256:d5fa1639f63b5c4af8d932495f60689d5370f1a095782c944f7f62a303eb104e", size = 96571, upload-time = "2026-03-17T22:49:19.299Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/5b/1283356b70d4893a8a050cee15092e1b08ea15310b94365f88067146721b/yt_dlp_ejs-0.5.0-py3-none-any.whl", hash = "sha256:674fc0efea741d3100cdf3f0f9e123150715ee41edf47ea7a62fbdeda204bdec", size = 54032, upload-time = "2026-02-21T19:29:15.408Z" }, { url = "https://files.pythonhosted.org/packages/e3/bd/520769863744b669440a924271a6159ddd82ad5ae26b4ac4d4b69e9f8d44/yt_dlp_ejs-0.8.0-py3-none-any.whl", hash = "sha256:79300e5fca7f937a1eeede11f0456862c1b41107ce1d726871e0207424f4bdb4", size = 53443, upload-time = "2026-03-17T22:49:17.736Z" },
] ]