code review fixes

This commit is contained in:
Alex Shnitman
2026-03-15 20:53:13 +02:00
parent 04959a6189
commit 7fa1fc7938
20 changed files with 498 additions and 448 deletions

View File

@@ -6,13 +6,50 @@ 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.33.1
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

View File

@@ -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

@@ -53,12 +53,12 @@ def get_format(download_type: str, codec: str, format: str, quality: str) -> str
if download_type == "audio": if download_type == "audio":
if format not in AUDIO_FORMATS: if format not in AUDIO_FORMATS:
raise Exception(f"Unknown audio format {format}") raise ValueError(f"Unknown audio format {format}")
return f"bestaudio[ext={format}]/bestaudio/best" return f"bestaudio[ext={format}]/bestaudio/best"
if download_type == "video": if download_type == "video":
if format not in ("any", "mp4", "ios"): if format not in ("any", "mp4", "ios"):
raise Exception(f"Unknown video format {format}") raise ValueError(f"Unknown video format {format}")
vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format in ("mp4", "ios") else ("", "") vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format in ("mp4", "ios") else ("", "")
vres = f"[height<={quality}]" if quality not in ("best", "worst") else "" vres = f"[height<={quality}]" if quality not in ("best", "worst") else ""
vcombo = vres + vfmt vcombo = vres + vfmt
@@ -71,12 +71,12 @@ def get_format(download_type: str, codec: str, format: str, quality: str) -> str
return f"bestvideo{codec_filter}{vcombo}+bestaudio{afmt}/bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}" return f"bestvideo{codec_filter}{vcombo}+bestaudio{afmt}/bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}"
return f"bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}" return f"bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}"
raise Exception(f"Unknown download_type {download_type}") raise ValueError(f"Unknown download_type {download_type}")
def get_opts( def get_opts(
download_type: str, download_type: str,
codec: str, _codec: str,
format: str, format: str,
quality: str, quality: str,
ytdl_opts: dict, ytdl_opts: dict,
@@ -96,8 +96,6 @@ def get_opts(
Returns: Returns:
dict: extended options dict: extended options
""" """
del codec # kept for parity with get_format signature
download_type = (download_type or "video").strip().lower() download_type = (download_type or "video").strip().lower()
format = (format or "any").strip().lower() format = (format or "any").strip().lower()
opts = copy.deepcopy(ytdl_opts) opts = copy.deepcopy(ytdl_opts)
@@ -113,7 +111,7 @@ def get_opts(
} }
) )
if format not in ("wav") and "writethumbnail" not in opts: if format != "wav" and "writethumbnail" not in opts:
opts["writethumbnail"] = True opts["writethumbnail"] = True
postprocessors.append( postprocessors.append(
{ {

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)
@@ -280,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:
@@ -330,12 +321,30 @@ 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)
post = _migrate_legacy_request(post) post = _migrate_legacy_request(post)
log.info(f"Request data: {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')
download_type = post.get('download_type') download_type = post.get('download_type')
codec = post.get('codec') codec = post.get('codec')
@@ -415,7 +424,10 @@ async def add(request):
quality = 'best' quality = 'best'
codec = 'auto' codec = 'auto'
playlist_item_limit = int(playlist_item_limit) 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,
@@ -441,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']:
@@ -453,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)
@@ -468,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)'}))
@@ -543,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)
@@ -579,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

@@ -229,6 +229,12 @@ class DownloadInfo:
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
@@ -568,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):
@@ -629,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()
@@ -898,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)
@@ -915,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()
@@ -927,7 +946,7 @@ 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)
@@ -935,7 +954,7 @@ class DownloadQueue:
dldirectory, _ = self.__calc_download_path(dl.info.download_type, 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": [

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" />
@@ -165,7 +165,7 @@
[(ngModel)]="format" [(ngModel)]="format"
(change)="formatChanged()" (change)="formatChanged()"
[disabled]="addInProgress || downloads.loading"> [disabled]="addInProgress || downloads.loading">
@for (f of getFormatOptions(); track f.id) { @for (f of formatOptions; track f.id) {
<option [ngValue]="f.id">{{ f.text }}</option> <option [ngValue]="f.id">{{ f.text }}</option>
} }
</select> </select>
@@ -208,7 +208,7 @@
[(ngModel)]="format" [(ngModel)]="format"
(change)="formatChanged()" (change)="formatChanged()"
[disabled]="addInProgress || downloads.loading"> [disabled]="addInProgress || downloads.loading">
@for (f of getFormatOptions(); track f.id) { @for (f of formatOptions; track f.id) {
<option [ngValue]="f.id">{{ f.text }}</option> <option [ngValue]="f.id">{{ f.text }}</option>
} }
</select> </select>
@@ -252,7 +252,7 @@
(change)="formatChanged()" (change)="formatChanged()"
[disabled]="addInProgress || downloads.loading" [disabled]="addInProgress || downloads.loading"
ngbTooltip="Subtitle output format for captions mode"> ngbTooltip="Subtitle output format for captions mode">
@for (f of getFormatOptions(); track f.id) { @for (f of formatOptions; track f.id) {
<option [ngValue]="f.id">{{ f.text }}</option> <option [ngValue]="f.id">{{ f.text }}</option>
} }
</select> </select>
@@ -506,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) {
@@ -554,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>
@@ -566,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">
@@ -580,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>
@@ -596,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">
@@ -606,7 +608,7 @@
<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">Type</th>
@@ -621,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;">
@@ -700,13 +702,13 @@
<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>
@@ -717,7 +719,7 @@
<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>
@@ -732,7 +734,7 @@
<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
@@ -119,21 +69,6 @@ td
.add-cancel-btn .add-cancel-btn
min-width: 3.25rem min-width: 3.25rem
::ng-deep .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
:host :host
display: flex display: flex
flex-direction: column flex-direction: column

View File

@@ -1,15 +1,16 @@
import { AsyncPipe, DatePipe, 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, faChevronDown, 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 { import {
Download, Download,
@@ -28,10 +29,11 @@ import {
State, State,
} from './interfaces'; } 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,
@@ -43,16 +45,18 @@ import { MasterCheckboxComponent , SlaveCheckboxComponent} from './components/';
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;
downloadTypes: Option[] = DOWNLOAD_TYPES; downloadTypes: Option[] = DOWNLOAD_TYPES;
@@ -61,6 +65,7 @@ export class App implements AfterViewInit, OnInit {
audioFormats: AudioFormatOption[] = AUDIO_FORMATS; audioFormats: AudioFormatOption[] = AUDIO_FORMATS;
captionFormats: Option[] = CAPTION_FORMATS; captionFormats: Option[] = CAPTION_FORMATS;
thumbnailFormats: Option[] = THUMBNAIL_FORMATS; thumbnailFormats: Option[] = THUMBNAIL_FORMATS;
formatOptions: Option[] = [];
qualities!: Quality[]; qualities!: Quality[];
downloadType: string; downloadType: string;
codec: string; codec: string;
@@ -104,6 +109,14 @@ export class App implements AfterViewInit, OnInit {
subtitleMode: string; subtitleMode: string;
}> = {}; }> = {};
private readonly selectionCookiePrefix = 'metube_selection_'; 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;
@@ -111,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;
@@ -221,6 +233,7 @@ export class App implements AfterViewInit, OnInit {
this.restoreSelection(this.downloadType); this.restoreSelection(this.downloadType);
this.normalizeSelectionsForType(); this.normalizeSelectionsForType();
this.setQualities(); this.setQualities();
this.refreshFormatOptions();
this.previousDownloadType = this.downloadType; this.previousDownloadType = this.downloadType;
this.saveSelection(this.downloadType); this.saveSelection(this.downloadType);
this.sortAscending = this.cookieService.get('metube_sort_ascending') === 'true'; this.sortAscending = this.cookieService.get('metube_sort_ascending') === 'true';
@@ -228,55 +241,54 @@ export class App implements AfterViewInit, OnInit {
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 && typeof data === 'object' && 'has_cookies' in data && data.has_cookies); 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
@@ -287,7 +299,7 @@ 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); 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);
@@ -296,16 +308,17 @@ export class App implements AfterViewInit, OnInit {
downloadTypeChanged() { downloadTypeChanged() {
this.saveSelection(this.previousDownloadType); this.saveSelection(this.previousDownloadType);
this.restoreSelection(this.downloadType); this.restoreSelection(this.downloadType);
this.cookieService.set('metube_download_type', this.downloadType, { expires: 3650 }); this.cookieService.set('metube_download_type', this.downloadType, { expires: this.settingsCookieExpiryDays });
this.normalizeSelectionsForType(false); this.normalizeSelectionsForType(false);
this.setQualities(); this.setQualities();
this.refreshFormatOptions();
this.saveSelection(this.downloadType); this.saveSelection(this.downloadType);
this.previousDownloadType = this.downloadType; this.previousDownloadType = this.downloadType;
this.downloads.customDirsChanged.next(this.downloads.customDirs); this.downloads.customDirsChanged.next(this.downloads.customDirs);
} }
codecChanged() { codecChanged() {
this.cookieService.set('metube_codec', this.codec, { expires: 3650 }); this.cookieService.set('metube_codec', this.codec, { expires: this.settingsCookieExpiryDays });
this.saveSelection(this.downloadType); this.saveSelection(this.downloadType);
} }
@@ -342,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']){
@@ -351,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'];
@@ -366,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();
} }
}); });
} }
@@ -380,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);
} }
@@ -394,7 +409,7 @@ 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 });
this.setQualities(); this.setQualities();
this.saveSelection(this.downloadType); this.saveSelection(this.downloadType);
// Re-trigger custom directory change // Re-trigger custom directory change
@@ -402,28 +417,29 @@ export class App implements AfterViewInit, OnInit {
} }
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 });
} }
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); 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); this.saveSelection(this.downloadType);
} }
@@ -467,6 +483,26 @@ export class App implements AfterViewInit, OnInit {
this.doneDownloadSelected().nativeElement.disabled = checked == 0; this.doneDownloadSelected().nativeElement.disabled = checked == 0;
} }
private updateDoneActionButtons() {
let completed = 0;
let failed = 0;
this.downloads.done.forEach((download) => {
const isFailed = download.status === 'error';
const isCompleted = !isFailed && (
download.status === 'finished' ||
download.status === 'completed' ||
Boolean(download.filename)
);
if (isCompleted) {
completed++;
} else if (isFailed) {
failed++;
}
});
this.hasCompletedDone = completed > 0;
this.hasFailedDone = failed > 0;
}
setQualities() { setQualities() {
if (this.downloadType === 'video') { if (this.downloadType === 'video') {
this.qualities = this.format === 'ios' this.qualities = this.format === 'ios'
@@ -482,6 +518,22 @@ export class App implements AfterViewInit, OnInit {
this.quality = exists ? this.quality : 'best'; this.quality = exists ? this.quality : 'best';
} }
refreshFormatOptions() {
if (this.downloadType === 'video') {
this.formatOptions = this.videoFormats;
return;
}
if (this.downloadType === 'audio') {
this.formatOptions = this.audioFormats;
return;
}
if (this.downloadType === 'captions') {
this.formatOptions = this.captionFormats;
return;
}
this.formatOptions = this.thumbnailFormats;
}
showCodecSelector() { showCodecSelector() {
return this.downloadType === 'video'; return this.downloadType === 'video';
} }
@@ -497,19 +549,6 @@ export class App implements AfterViewInit, OnInit {
return this.downloadType === 'audio'; return this.downloadType === 'audio';
} }
getFormatOptions() {
if (this.downloadType === 'video') {
return this.videoFormats;
}
if (this.downloadType === 'audio') {
return this.audioFormats;
}
if (this.downloadType === 'captions') {
return this.captionFormats;
}
return this.thumbnailFormats;
}
private normalizeSelectionsForType(resetForTypeChange = false) { private normalizeSelectionsForType(resetForTypeChange = false) {
if (this.downloadType === 'video') { if (this.downloadType === 'video') {
const allowedFormats = new Set(this.videoFormats.map(f => f.id)); const allowedFormats = new Set(this.videoFormats.map(f => f.id));
@@ -535,8 +574,8 @@ export class App implements AfterViewInit, OnInit {
this.format = 'jpg'; this.format = 'jpg';
this.quality = 'best'; this.quality = 'best';
} }
this.cookieService.set('metube_format', this.format, { expires: 3650 }); this.cookieService.set('metube_format', this.format, { expires: this.settingsCookieExpiryDays });
this.cookieService.set('metube_codec', this.codec, { expires: 3650 }); this.cookieService.set('metube_codec', this.codec, { expires: this.settingsCookieExpiryDays });
} }
private saveSelection(type: string) { private saveSelection(type: string) {
@@ -552,7 +591,7 @@ export class App implements AfterViewInit, OnInit {
this.cookieService.set( this.cookieService.set(
this.selectionCookiePrefix + type, this.selectionCookiePrefix + type,
JSON.stringify(selection), JSON.stringify(selection),
{ expires: 3650 } { expires: this.settingsCookieExpiryDays }
); );
} }
@@ -588,45 +627,37 @@ export class App implements AfterViewInit, OnInit {
} }
} }
addDownload( private buildAddPayload(overrides: Partial<AddDownloadPayload> = {}): AddDownloadPayload {
url?: string, return {
downloadType?: string, url: overrides.url ?? this.addUrl,
codec?: string, downloadType: overrides.downloadType ?? this.downloadType,
quality?: string, codec: overrides.codec ?? this.codec,
format?: string, quality: overrides.quality ?? this.quality,
folder?: string, format: overrides.format ?? this.format,
customNamePrefix?: string, folder: overrides.folder ?? this.folder,
playlistItemLimit?: number, customNamePrefix: overrides.customNamePrefix ?? this.customNamePrefix,
autoStart?: boolean, playlistItemLimit: overrides.playlistItemLimit ?? this.playlistItemLimit,
splitByChapters?: boolean, autoStart: overrides.autoStart ?? this.autoStart,
chapterTemplate?: string, splitByChapters: overrides.splitByChapters ?? this.splitByChapters,
subtitleLanguage?: string, chapterTemplate: overrides.chapterTemplate ?? this.chapterTemplate,
subtitleMode?: string, subtitleLanguage: overrides.subtitleLanguage ?? this.subtitleLanguage,
) { subtitleMode: overrides.subtitleMode ?? this.subtitleMode,
url = url ?? this.addUrl };
downloadType = downloadType ?? this.downloadType }
codec = codec ?? this.codec
quality = quality ?? this.quality addDownload(overrides: Partial<AddDownloadPayload> = {}) {
format = format ?? this.format const payload = this.buildAddPayload(overrides);
folder = folder ?? this.folder
customNamePrefix = customNamePrefix ?? this.customNamePrefix
playlistItemLimit = playlistItemLimit ?? this.playlistItemLimit
autoStart = autoStart ?? this.autoStart
splitByChapters = splitByChapters ?? this.splitByChapters
chapterTemplate = chapterTemplate ?? this.chapterTemplate
subtitleLanguage = subtitleLanguage ?? this.subtitleLanguage
subtitleMode = subtitleMode ?? this.subtitleMode
// 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 + ' downloadType=' + downloadType + ' codec=' + codec + ' quality=' + quality + ' format=' + format + ' folder=' + folder + ' customNamePrefix=' + customNamePrefix + ' playlistItemLimit=' + playlistItemLimit + ' autoStart=' + autoStart + ' splitByChapters=' + splitByChapters + ' chapterTemplate=' + chapterTemplate + ' subtitleLanguage=' + subtitleLanguage + ' subtitleMode=' + subtitleMode); console.debug('Downloading:', payload);
this.addInProgress = true; this.addInProgress = true;
this.cancelRequested = false; this.cancelRequested = false;
this.downloads.add(url, downloadType, codec, quality, format, folder, customNamePrefix, playlistItemLimit, autoStart, splitByChapters, chapterTemplate, 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') {
@@ -651,21 +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.download_type, downloadType: download.download_type,
download.codec, codec: download.codec,
download.quality, quality: download.quality,
download.format, format: download.format,
download.folder, folder: download.folder,
download.custom_name_prefix, customNamePrefix: download.custom_name_prefix,
download.playlist_item_limit, playlistItemLimit: download.playlist_item_limit,
true, autoStart: true,
download.split_by_chapters, splitByChapters: download.split_by_chapters,
download.chapter_template, chapterTemplate: download.chapter_template,
download.subtitle_language, subtitleLanguage: download.subtitle_language,
download.subtitle_mode, subtitleMode: download.subtitle_mode,
); });
this.downloads.delById('done', [key]).subscribe(); this.downloads.delById('done', [key]).subscribe();
} }
@@ -719,7 +750,7 @@ export class App implements AfterViewInit, OnInit {
} }
if (download.folder) { if (download.folder) {
baseDir += download.folder + '/'; baseDir += this.encodeFolderPath(download.folder);
} }
return baseDir + encodeURIComponent(download.filename); return baseDir + encodeURIComponent(download.filename);
@@ -743,12 +774,20 @@ export class App implements AfterViewInit, OnInit {
} }
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('/');
@@ -756,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();
} }
} }
@@ -769,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
@@ -810,9 +861,7 @@ 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}`;
// Pass current selection options to backend // Pass current selection options to backend
this.downloads.add(url, this.downloadType, this.codec, this.quality, this.format, this.folder, this.customNamePrefix, this.downloads.add(this.buildAddPayload({ url }))
this.playlistItemLimit, this.autoStart, this.splitByChapters, this.chapterTemplate,
this.subtitleLanguage, this.subtitleMode)
.subscribe({ .subscribe({
next: (status: Status) => { next: (status: Status) => {
if (status.status === 'error') { if (status.status === 'error') {
@@ -921,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();
} }
@@ -1050,15 +1099,35 @@ export class App implements AfterViewInit, OnInit {
} }
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

@@ -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,39 +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,
downloadType: string,
codec: string,
quality: string,
format: string,
folder: string,
customNamePrefix: string,
playlistItemLimit: number,
autoStart: boolean,
splitByChapters: boolean,
chapterTemplate: string,
subtitleLanguage: string,
subtitleMode: string,
) {
return this.http.post<Status>('add', { return this.http.post<Status>('add', {
url: url, url: payload.url,
download_type: downloadType, download_type: payload.downloadType,
codec: codec, codec: payload.codec,
quality: quality, quality: payload.quality,
format: format, format: payload.format,
folder: folder, folder: payload.folder,
custom_name_prefix: customNamePrefix, custom_name_prefix: payload.customNamePrefix,
playlist_item_limit: playlistItemLimit, playlist_item_limit: payload.playlistItemLimit,
auto_start: autoStart, auto_start: payload.autoStart,
split_by_chapters: splitByChapters, split_by_chapters: payload.splitByChapters,
chapter_template: chapterTemplate, chapter_template: payload.chapterTemplate,
subtitle_language: subtitleLanguage, subtitle_language: payload.subtitleLanguage,
subtitle_mode: subtitleMode, subtitle_mode: payload.subtitleMode,
}).pipe( }).pipe(
catchError(this.handleHTTPError) catchError(this.handleHTTPError)
); );
@@ -169,49 +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 defaultDownloadType = 'video';
const defaultCodec = 'auto';
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 defaultSubtitleLanguage = 'en';
const defaultSubtitleMode = 'prefer_manual';
return new Promise((resolve, reject) => {
this.add(
url,
defaultDownloadType,
defaultCodec,
defaultQuality,
defaultFormat,
defaultFolder,
defaultCustomNamePrefix,
defaultPlaylistItemLimit,
defaultAutoStart,
defaultSplitByChapters,
defaultChapterTemplate,
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)

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