mirror of
https://github.com/alexta69/metube.git
synced 2026-03-18 14:33:50 +00:00
code review fixes
This commit is contained in:
39
.github/workflows/main.yml
vendored
39
.github/workflows/main.yml
vendored
@@ -6,13 +6,50 @@ on:
|
||||
- 'master'
|
||||
|
||||
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:
|
||||
needs: quality-checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Get current date
|
||||
id: date
|
||||
run: echo "::set-output name=date::$(date +'%Y.%m.%d')"
|
||||
run: echo "date=$(date +'%Y.%m.%d')" >> "$GITHUB_OUTPUT"
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -63,11 +63,12 @@ ENV PUID=1000
|
||||
ENV PGID=1000
|
||||
ENV UMASK=022
|
||||
|
||||
ENV DOWNLOAD_DIR /downloads
|
||||
ENV STATE_DIR /downloads/.metube
|
||||
ENV TEMP_DIR /downloads
|
||||
ENV DOWNLOAD_DIR=/downloads
|
||||
ENV STATE_DIR=/downloads/.metube
|
||||
ENV TEMP_DIR=/downloads
|
||||
VOLUME /downloads
|
||||
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
|
||||
ARG VERSION=dev
|
||||
|
||||
@@ -53,12 +53,12 @@ def get_format(download_type: str, codec: str, format: str, quality: str) -> str
|
||||
|
||||
if download_type == "audio":
|
||||
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"
|
||||
|
||||
if download_type == "video":
|
||||
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 ("", "")
|
||||
vres = f"[height<={quality}]" if quality not in ("best", "worst") else ""
|
||||
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{vcombo}+bestaudio{afmt}/best{vcombo}"
|
||||
|
||||
raise Exception(f"Unknown download_type {download_type}")
|
||||
raise ValueError(f"Unknown download_type {download_type}")
|
||||
|
||||
|
||||
def get_opts(
|
||||
download_type: str,
|
||||
codec: str,
|
||||
_codec: str,
|
||||
format: str,
|
||||
quality: str,
|
||||
ytdl_opts: dict,
|
||||
@@ -96,8 +96,6 @@ def get_opts(
|
||||
Returns:
|
||||
dict: extended options
|
||||
"""
|
||||
del codec # kept for parity with get_format signature
|
||||
|
||||
download_type = (download_type or "video").strip().lower()
|
||||
format = (format or "any").strip().lower()
|
||||
opts = copy.deepcopy(ytdl_opts)
|
||||
@@ -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
|
||||
postprocessors.append(
|
||||
{
|
||||
|
||||
86
app/main.py
86
app/main.py
@@ -16,25 +16,15 @@ import pathlib
|
||||
import re
|
||||
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
|
||||
|
||||
log = logging.getLogger('main')
|
||||
|
||||
def parseLogLevel(logLevel):
|
||||
match logLevel:
|
||||
case 'DEBUG':
|
||||
return logging.DEBUG
|
||||
case 'INFO':
|
||||
return logging.INFO
|
||||
case 'WARNING':
|
||||
return logging.WARNING
|
||||
case 'ERROR':
|
||||
return logging.ERROR
|
||||
case 'CRITICAL':
|
||||
return logging.CRITICAL
|
||||
case _:
|
||||
if not isinstance(logLevel, str):
|
||||
return None
|
||||
return getattr(logging, logLevel.upper(), None)
|
||||
|
||||
# Configure logging before Config() uses it so early messages are not dropped.
|
||||
# Only configure if no handlers are set (avoid clobbering hosting app settings).
|
||||
@@ -71,7 +61,7 @@ class Config:
|
||||
'KEYFILE': '',
|
||||
'BASE_DIR': '',
|
||||
'DEFAULT_THEME': 'auto',
|
||||
'MAX_CONCURRENT_DOWNLOADS': 3,
|
||||
'MAX_CONCURRENT_DOWNLOADS': '3',
|
||||
'LOGLEVEL': 'INFO',
|
||||
'ENABLE_ACCESSLOG': 'false',
|
||||
}
|
||||
@@ -181,7 +171,7 @@ class ObjectSerializer(json.JSONEncoder):
|
||||
elif hasattr(obj, '__iter__') and not isinstance(obj, (str, bytes)):
|
||||
try:
|
||||
return list(obj)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
# Fall back to default behavior
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
@@ -280,6 +270,7 @@ class Notifier(DownloadQueueNotifier):
|
||||
|
||||
dqueue = DownloadQueue(config, Notifier())
|
||||
app.on_startup.append(lambda app: dqueue.initialize())
|
||||
app.on_cleanup.append(lambda app: Download.shutdown_manager())
|
||||
|
||||
class FileOpsFilter(DefaultFilter):
|
||||
def __call__(self, change_type: int, path: str) -> bool:
|
||||
@@ -330,12 +321,30 @@ async def watch_files():
|
||||
if config.YTDL_OPTIONS_FILE:
|
||||
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')
|
||||
async def add(request):
|
||||
log.info("Received request to add download")
|
||||
post = await request.json()
|
||||
post = await _read_json_request(request)
|
||||
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')
|
||||
download_type = post.get('download_type')
|
||||
codec = post.get('codec')
|
||||
@@ -415,7 +424,10 @@ async def add(request):
|
||||
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(
|
||||
url,
|
||||
@@ -441,7 +453,7 @@ async def cancel_add(request):
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'delete')
|
||||
async def delete(request):
|
||||
post = await request.json()
|
||||
post = await _read_json_request(request)
|
||||
ids = post.get('ids')
|
||||
where = post.get('where')
|
||||
if not ids or where not in ['queue', 'done']:
|
||||
@@ -453,7 +465,7 @@ async def delete(request):
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'start')
|
||||
async def start(request):
|
||||
post = await request.json()
|
||||
post = await _read_json_request(request)
|
||||
ids = post.get('ids')
|
||||
log.info(f"Received request to start pending downloads for ids: {ids}")
|
||||
status = await dqueue.start_pending(ids)
|
||||
@@ -468,17 +480,23 @@ async def upload_cookies(request):
|
||||
field = await reader.next()
|
||||
if field is None or field.name != 'cookies':
|
||||
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'No cookies file provided'}))
|
||||
|
||||
max_size = 1_000_000 # 1MB limit
|
||||
size = 0
|
||||
with open(COOKIES_PATH, 'wb') as f:
|
||||
content = bytearray()
|
||||
while True:
|
||||
chunk = await field.read_chunk()
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > 1_000_000: # 1MB limit
|
||||
os.remove(COOKIES_PATH)
|
||||
if size > max_size:
|
||||
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'Cookie file too large (max 1MB)'}))
|
||||
f.write(chunk)
|
||||
content.extend(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)
|
||||
log.info(f'Cookies file 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)
|
||||
|
||||
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):
|
||||
path = pathlib.Path(base)
|
||||
|
||||
@@ -579,10 +613,14 @@ def get_custom_dirs():
|
||||
if config.DOWNLOAD_DIR != config.AUDIO_DOWNLOAD_DIR:
|
||||
audio_download_dir = recursive_dirs(config.AUDIO_DOWNLOAD_DIR)
|
||||
|
||||
return {
|
||||
result = {
|
||||
"download_dir": 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)
|
||||
def index(request):
|
||||
|
||||
21
app/tests/test_dl_formats.py
Normal file
21
app/tests/test_dl_formats.py
Normal 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()
|
||||
43
app/ytdl.py
43
app/ytdl.py
@@ -229,6 +229,12 @@ class DownloadInfo:
|
||||
class Download:
|
||||
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):
|
||||
self.download_dir = download_dir
|
||||
self.temp_dir = temp_dir
|
||||
@@ -568,20 +574,33 @@ class PersistentQueue:
|
||||
log_prefix = f"PersistentQueue:{self.identifier} repair (sqlite3/file)"
|
||||
log.debug(f"{log_prefix} started")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
f"sqlite3 {self.path} '.recover' | sqlite3 {self.path}.tmp",
|
||||
recover_proc = subprocess.Popen(
|
||||
["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,
|
||||
text=True,
|
||||
shell=True,
|
||||
timeout=60
|
||||
timeout=60,
|
||||
)
|
||||
if result.stderr:
|
||||
log.debug(f"{log_prefix} failed: {result.stderr}")
|
||||
if recover_proc.stdout is not None:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
def __init__(self, config, notifier):
|
||||
@@ -629,7 +648,7 @@ class DownloadQueue:
|
||||
if download.tmpfilename and os.path.isfile(download.tmpfilename):
|
||||
try:
|
||||
os.remove(download.tmpfilename)
|
||||
except:
|
||||
except OSError:
|
||||
pass
|
||||
download.info.status = 'error'
|
||||
download.close()
|
||||
@@ -898,7 +917,7 @@ class DownloadQueue:
|
||||
async def start_pending(self, ids):
|
||||
for id in ids:
|
||||
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
|
||||
dl = self.pending.get(id)
|
||||
self.queue.put(dl)
|
||||
@@ -915,7 +934,7 @@ class DownloadQueue:
|
||||
await self.notifier.canceled(id)
|
||||
continue
|
||||
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
|
||||
if self.queue.get(id).started():
|
||||
self.queue.get(id).cancel()
|
||||
@@ -927,7 +946,7 @@ class DownloadQueue:
|
||||
async def clear(self, ids):
|
||||
for id in ids:
|
||||
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
|
||||
if self.config.DELETE_FILE_ON_TRASHCAN:
|
||||
dl = self.done.get(id)
|
||||
@@ -935,7 +954,7 @@ class DownloadQueue:
|
||||
dldirectory, _ = self.__calc_download_path(dl.info.download_type, dl.info.folder)
|
||||
os.remove(os.path.join(dldirectory, dl.info.filename))
|
||||
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)
|
||||
await self.notifier.cleared(id)
|
||||
return {'status': 'ok'}
|
||||
|
||||
@@ -33,9 +33,7 @@
|
||||
"node_modules/@ng-select/ng-select/themes/default.theme.css",
|
||||
"src/styles.sass"
|
||||
],
|
||||
"scripts": [
|
||||
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
|
||||
],
|
||||
"scripts": [],
|
||||
"serviceWorker": "ngsw-config.json",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
|
||||
@@ -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 { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
|
||||
@@ -49,22 +49,22 @@
|
||||
</div>
|
||||
-->
|
||||
<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"
|
||||
id="theme-select"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-display="static">
|
||||
ngbDropdownToggle>
|
||||
@if(activeTheme){
|
||||
<fa-icon [icon]="activeTheme.icon" />
|
||||
}
|
||||
</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) {
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center"
|
||||
[class.active]="activeTheme === theme"
|
||||
ngbDropdownItem
|
||||
(click)="themeChanged(theme)">
|
||||
<span class="me-2 opacity-50">
|
||||
<fa-icon [icon]="theme.icon" />
|
||||
@@ -165,7 +165,7 @@
|
||||
[(ngModel)]="format"
|
||||
(change)="formatChanged()"
|
||||
[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>
|
||||
}
|
||||
</select>
|
||||
@@ -208,7 +208,7 @@
|
||||
[(ngModel)]="format"
|
||||
(change)="formatChanged()"
|
||||
[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>
|
||||
}
|
||||
</select>
|
||||
@@ -252,7 +252,7 @@
|
||||
(change)="formatChanged()"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
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>
|
||||
}
|
||||
</select>
|
||||
@@ -506,16 +506,18 @@
|
||||
|
||||
<!-- Batch Import Modal -->
|
||||
<div class="modal fade" tabindex="-1" role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="batch-import-modal-title"
|
||||
[class.show]="batchImportModalOpen"
|
||||
[style.display]="batchImportModalOpen ? 'block' : 'none'">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<div class="mt-2">
|
||||
@if (batchImportStatus) {
|
||||
@@ -554,7 +556,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<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 scope="col">Video</th>
|
||||
<th scope="col" style="width: 8rem;">Speed</th>
|
||||
@@ -566,7 +568,7 @@
|
||||
@for (download of downloads.queue | keyvalue: asIsOrder; track download.value.id) {
|
||||
<tr [class.disabled]='download.value.deleting'>
|
||||
<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 title="{{ download.value.filename }}">
|
||||
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
|
||||
@@ -580,10 +582,10 @@
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
@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>
|
||||
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
||||
<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" [attr.aria-label]="'Open source URL for ' + download.value.title"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -596,9 +598,9 @@
|
||||
<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" /> {{ 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" /> 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" /> 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" /> 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" /> Retry failed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasCompletedDone" (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" /> Clear completed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" /> Clear failed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt" /> 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" /> Download Selected</button>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
@@ -606,7 +608,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<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 scope="col">Video</th>
|
||||
<th scope="col">Type</th>
|
||||
@@ -621,7 +623,7 @@
|
||||
@for (entry of cachedSortedDone; track entry[1].id) {
|
||||
<tr [class.disabled]='entry[1].deleting'>
|
||||
<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>
|
||||
<div style="display: inline-block; width: 1.5rem;">
|
||||
@@ -700,13 +702,13 @@
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
@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) {
|
||||
<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>
|
||||
<button type="button" class="btn btn-link" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
|
||||
<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" [attr.aria-label]="'Delete completed item ' + entry[1].title" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -717,7 +719,7 @@
|
||||
<td>
|
||||
<div style="padding-left: 2rem;">
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
@@ -732,7 +734,7 @@
|
||||
<td></td>
|
||||
<td>
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,29 +1,7 @@
|
||||
.button-toggle-theme:focus, .button-toggle-theme:active
|
||||
box-shadow: none
|
||||
outline: 0px
|
||||
|
||||
.add-url-box
|
||||
max-width: 960px
|
||||
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
|
||||
font-size: 1.8rem
|
||||
font-weight: 300
|
||||
@@ -66,39 +44,11 @@ td
|
||||
width: 12rem
|
||||
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
|
||||
background-color: rgba(0, 0, 0, 0.5)
|
||||
|
||||
.modal-header
|
||||
border-bottom: 1px solid #eee
|
||||
border-bottom: 1px solid var(--bs-border-color)
|
||||
|
||||
.modal-body
|
||||
textarea.form-control
|
||||
@@ -119,21 +69,6 @@ td
|
||||
.add-cancel-btn
|
||||
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
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { AsyncPipe, DatePipe, KeyValuePipe } from '@angular/common';
|
||||
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 { FormsModule } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
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 { faGithub } from '@fortawesome/free-brands-svg-icons';
|
||||
import { CookieService } from 'ngx-cookie-service';
|
||||
import { DownloadsService } from './services/downloads.service';
|
||||
import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
|
||||
import { Themes } from './theme';
|
||||
import {
|
||||
Download,
|
||||
@@ -28,10 +29,11 @@ import {
|
||||
State,
|
||||
} from './interfaces';
|
||||
import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes';
|
||||
import { MasterCheckboxComponent , SlaveCheckboxComponent} from './components/';
|
||||
import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
FormsModule,
|
||||
KeyValuePipe,
|
||||
@@ -43,16 +45,18 @@ import { MasterCheckboxComponent , SlaveCheckboxComponent} from './components/';
|
||||
EtaPipe,
|
||||
SpeedPipe,
|
||||
FileSizePipe,
|
||||
MasterCheckboxComponent,
|
||||
SlaveCheckboxComponent,
|
||||
SelectAllCheckboxComponent,
|
||||
ItemCheckboxComponent,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.sass',
|
||||
})
|
||||
export class App implements AfterViewInit, OnInit {
|
||||
export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
downloads = inject(DownloadsService);
|
||||
private cookieService = inject(CookieService);
|
||||
private http = inject(HttpClient);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
addUrl!: string;
|
||||
downloadTypes: Option[] = DOWNLOAD_TYPES;
|
||||
@@ -61,6 +65,7 @@ export class App implements AfterViewInit, OnInit {
|
||||
audioFormats: AudioFormatOption[] = AUDIO_FORMATS;
|
||||
captionFormats: Option[] = CAPTION_FORMATS;
|
||||
thumbnailFormats: Option[] = THUMBNAIL_FORMATS;
|
||||
formatOptions: Option[] = [];
|
||||
qualities!: Quality[];
|
||||
downloadType: string;
|
||||
codec: string;
|
||||
@@ -104,6 +109,14 @@ export class App implements AfterViewInit, OnInit {
|
||||
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
|
||||
activeDownloads = 0;
|
||||
@@ -111,15 +124,14 @@ export class App implements AfterViewInit, OnInit {
|
||||
completedDownloads = 0;
|
||||
failedDownloads = 0;
|
||||
totalSpeed = 0;
|
||||
hasCompletedDone = false;
|
||||
hasFailedDone = false;
|
||||
|
||||
readonly queueMasterCheckbox = viewChild<MasterCheckboxComponent>('queueMasterCheckboxRef');
|
||||
readonly queueMasterCheckbox = viewChild<SelectAllCheckboxComponent>('queueMasterCheckboxRef');
|
||||
readonly queueDelSelected = viewChild.required<ElementRef>('queueDelSelected');
|
||||
readonly queueDownloadSelected = viewChild.required<ElementRef>('queueDownloadSelected');
|
||||
readonly doneMasterCheckbox = viewChild<MasterCheckboxComponent>('doneMasterCheckboxRef');
|
||||
readonly doneMasterCheckbox = viewChild<SelectAllCheckboxComponent>('doneMasterCheckboxRef');
|
||||
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');
|
||||
|
||||
faTrashAlt = faTrashAlt;
|
||||
@@ -221,6 +233,7 @@ export class App implements AfterViewInit, OnInit {
|
||||
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';
|
||||
@@ -228,55 +241,54 @@ export class App implements AfterViewInit, OnInit {
|
||||
this.activeTheme = this.getPreferredTheme(this.cookieService);
|
||||
|
||||
// Subscribe to download updates
|
||||
this.downloads.queueChanged.subscribe(() => {
|
||||
this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
this.updateMetrics();
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
this.downloads.doneChanged.subscribe(() => {
|
||||
this.downloads.doneChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
this.updateMetrics();
|
||||
this.rebuildSortedDone();
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
// Subscribe to real-time updates
|
||||
this.downloads.updated.subscribe(() => {
|
||||
this.downloads.updated.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
this.updateMetrics();
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
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.cdr.markForCheck();
|
||||
});
|
||||
this.getConfiguration();
|
||||
this.getYtdlOptionsUpdateTime();
|
||||
this.customDirs$ = this.getMatchingCustomDir();
|
||||
this.setTheme(this.activeTheme!);
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (this.activeTheme && this.activeTheme.id === 'auto') {
|
||||
this.setTheme(this.activeTheme);
|
||||
}
|
||||
});
|
||||
this.colorSchemeMediaQuery.addEventListener('change', this.onColorSchemeChanged);
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.downloads.queueChanged.subscribe(() => {
|
||||
this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
this.queueMasterCheckbox()?.selectionChanged();
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
this.downloads.doneChanged.subscribe(() => {
|
||||
this.downloads.doneChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
this.doneMasterCheckbox()?.selectionChanged();
|
||||
let completed = 0, failed = 0;
|
||||
this.downloads.done.forEach(dl => {
|
||||
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;
|
||||
this.updateDoneActionButtons();
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
// Initialize action button states for already-loaded entries.
|
||||
this.updateDoneActionButtons();
|
||||
this.fetchVersionInfo();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
|
||||
}
|
||||
|
||||
// workaround to allow fetching of Map values in the order they were inserted
|
||||
// https://github.com/angular/angular/issues/31420
|
||||
|
||||
@@ -287,7 +299,7 @@ export class App implements AfterViewInit, OnInit {
|
||||
}
|
||||
|
||||
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
|
||||
this.downloads.customDirsChanged.next(this.downloads.customDirs);
|
||||
@@ -296,16 +308,17 @@ export class App implements AfterViewInit, OnInit {
|
||||
downloadTypeChanged() {
|
||||
this.saveSelection(this.previousDownloadType);
|
||||
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.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: 3650 });
|
||||
this.cookieService.set('metube_codec', this.codec, { expires: this.settingsCookieExpiryDays });
|
||||
this.saveSelection(this.downloadType);
|
||||
}
|
||||
|
||||
@@ -342,7 +355,7 @@ export class App implements AfterViewInit, OnInit {
|
||||
}
|
||||
|
||||
getYtdlOptionsUpdateTime() {
|
||||
this.downloads.ytdlOptionsChanged.subscribe({
|
||||
this.downloads.ytdlOptionsChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
next: (data:any) => {
|
||||
if (data['success']){
|
||||
@@ -351,11 +364,12 @@ export class App implements AfterViewInit, OnInit {
|
||||
}else{
|
||||
alert("Error reload yt-dlp options: "+data['msg']);
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
getConfiguration() {
|
||||
this.downloads.configurationChanged.subscribe({
|
||||
this.downloads.configurationChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
next: (config: any) => {
|
||||
const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'];
|
||||
@@ -366,6 +380,7 @@ export class App implements AfterViewInit, OnInit {
|
||||
if (!this.chapterTemplate) {
|
||||
this.chapterTemplate = config['OUTPUT_TEMPLATE_CHAPTER'];
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -380,7 +395,7 @@ export class App implements AfterViewInit, OnInit {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -394,7 +409,7 @@ export class App implements AfterViewInit, OnInit {
|
||||
}
|
||||
|
||||
formatChanged() {
|
||||
this.cookieService.set('metube_format', this.format, { expires: 3650 });
|
||||
this.cookieService.set('metube_format', this.format, { expires: this.settingsCookieExpiryDays });
|
||||
this.setQualities();
|
||||
this.saveSelection(this.downloadType);
|
||||
// Re-trigger custom directory change
|
||||
@@ -402,28 +417,29 @@ export class App implements AfterViewInit, OnInit {
|
||||
}
|
||||
|
||||
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() {
|
||||
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() {
|
||||
// Restore default if template is cleared - get from configuration
|
||||
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() {
|
||||
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() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -467,6 +483,26 @@ export class App implements AfterViewInit, OnInit {
|
||||
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() {
|
||||
if (this.downloadType === 'video') {
|
||||
this.qualities = this.format === 'ios'
|
||||
@@ -482,6 +518,22 @@ export class App implements AfterViewInit, OnInit {
|
||||
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() {
|
||||
return this.downloadType === 'video';
|
||||
}
|
||||
@@ -497,19 +549,6 @@ export class App implements AfterViewInit, OnInit {
|
||||
return this.downloadType === 'audio';
|
||||
}
|
||||
|
||||
getFormatOptions() {
|
||||
if (this.downloadType === 'video') {
|
||||
return this.videoFormats;
|
||||
}
|
||||
if (this.downloadType === 'audio') {
|
||||
return this.audioFormats;
|
||||
}
|
||||
if (this.downloadType === 'captions') {
|
||||
return this.captionFormats;
|
||||
}
|
||||
return this.thumbnailFormats;
|
||||
}
|
||||
|
||||
private normalizeSelectionsForType(resetForTypeChange = false) {
|
||||
if (this.downloadType === 'video') {
|
||||
const allowedFormats = new Set(this.videoFormats.map(f => f.id));
|
||||
@@ -535,8 +574,8 @@ export class App implements AfterViewInit, OnInit {
|
||||
this.format = 'jpg';
|
||||
this.quality = 'best';
|
||||
}
|
||||
this.cookieService.set('metube_format', this.format, { expires: 3650 });
|
||||
this.cookieService.set('metube_codec', this.codec, { expires: 3650 });
|
||||
this.cookieService.set('metube_format', this.format, { expires: this.settingsCookieExpiryDays });
|
||||
this.cookieService.set('metube_codec', this.codec, { expires: this.settingsCookieExpiryDays });
|
||||
}
|
||||
|
||||
private saveSelection(type: string) {
|
||||
@@ -552,7 +591,7 @@ export class App implements AfterViewInit, OnInit {
|
||||
this.cookieService.set(
|
||||
this.selectionCookiePrefix + type,
|
||||
JSON.stringify(selection),
|
||||
{ expires: 3650 }
|
||||
{ expires: this.settingsCookieExpiryDays }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -588,45 +627,37 @@ export class App implements AfterViewInit, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
addDownload(
|
||||
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,
|
||||
) {
|
||||
url = url ?? this.addUrl
|
||||
downloadType = downloadType ?? this.downloadType
|
||||
codec = codec ?? this.codec
|
||||
quality = quality ?? this.quality
|
||||
format = format ?? this.format
|
||||
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
|
||||
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
|
||||
if (splitByChapters && !chapterTemplate.includes('%(section_number)')) {
|
||||
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
|
||||
alert('Chapter template must include %(section_number)');
|
||||
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.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) {
|
||||
alert(`Error adding URL: ${status.msg}`);
|
||||
} else if (status.status !== 'error') {
|
||||
@@ -651,21 +682,21 @@ export class App implements AfterViewInit, OnInit {
|
||||
}
|
||||
|
||||
retryDownload(key: string, download: Download) {
|
||||
this.addDownload(
|
||||
download.url,
|
||||
download.download_type,
|
||||
download.codec,
|
||||
download.quality,
|
||||
download.format,
|
||||
download.folder,
|
||||
download.custom_name_prefix,
|
||||
download.playlist_item_limit,
|
||||
true,
|
||||
download.split_by_chapters,
|
||||
download.chapter_template,
|
||||
download.subtitle_language,
|
||||
download.subtitle_mode,
|
||||
);
|
||||
this.addDownload({
|
||||
url: download.url,
|
||||
downloadType: download.download_type,
|
||||
codec: download.codec,
|
||||
quality: download.quality,
|
||||
format: download.format,
|
||||
folder: download.folder,
|
||||
customNamePrefix: download.custom_name_prefix,
|
||||
playlistItemLimit: download.playlist_item_limit,
|
||||
autoStart: true,
|
||||
splitByChapters: download.split_by_chapters,
|
||||
chapterTemplate: download.chapter_template,
|
||||
subtitleLanguage: download.subtitle_language,
|
||||
subtitleMode: download.subtitle_mode,
|
||||
});
|
||||
this.downloads.delById('done', [key]).subscribe();
|
||||
}
|
||||
|
||||
@@ -719,7 +750,7 @@ export class App implements AfterViewInit, OnInit {
|
||||
}
|
||||
|
||||
if (download.folder) {
|
||||
baseDir += download.folder + '/';
|
||||
baseDir += this.encodeFolderPath(download.folder);
|
||||
}
|
||||
|
||||
return baseDir + encodeURIComponent(download.filename);
|
||||
@@ -743,12 +774,20 @@ export class App implements AfterViewInit, OnInit {
|
||||
}
|
||||
|
||||
if (download.folder) {
|
||||
baseDir += download.folder + '/';
|
||||
baseDir += this.encodeFolderPath(download.folder);
|
||||
}
|
||||
|
||||
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) {
|
||||
// Extract just the filename from the path
|
||||
const parts = filepath.split('/');
|
||||
@@ -756,8 +795,12 @@ export class App implements AfterViewInit, OnInit {
|
||||
}
|
||||
|
||||
isNumber(event: KeyboardEvent) {
|
||||
const charCode = +event.code || event.keyCode;
|
||||
if (charCode > 31 && (charCode < 48 || charCode > 57)) {
|
||||
const allowedControlKeys = ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab', 'Home', 'End'];
|
||||
if (allowedControlKeys.includes(event.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[0-9]$/.test(event.key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
@@ -769,16 +812,24 @@ export class App implements AfterViewInit, OnInit {
|
||||
|
||||
// Open the Batch Import modal
|
||||
openBatchImportModal(): void {
|
||||
this.lastFocusedElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
this.batchImportModalOpen = true;
|
||||
this.batchImportText = '';
|
||||
this.batchImportStatus = '';
|
||||
this.importInProgress = false;
|
||||
this.cancelImportFlag = false;
|
||||
setTimeout(() => {
|
||||
const textarea = document.getElementById('batch-import-textarea');
|
||||
if (textarea instanceof HTMLTextAreaElement) {
|
||||
textarea.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Close the Batch Import modal
|
||||
closeBatchImportModal(): void {
|
||||
this.batchImportModalOpen = false;
|
||||
this.lastFocusedElement?.focus();
|
||||
}
|
||||
|
||||
// Start importing URLs from the batch modal textarea
|
||||
@@ -810,9 +861,7 @@ export class App implements AfterViewInit, OnInit {
|
||||
const url = urls[index];
|
||||
this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`;
|
||||
// Pass current selection options to backend
|
||||
this.downloads.add(url, this.downloadType, this.codec, this.quality, this.format, this.folder, this.customNamePrefix,
|
||||
this.playlistItemLimit, this.autoStart, this.splitByChapters, this.chapterTemplate,
|
||||
this.subtitleLanguage, this.subtitleMode)
|
||||
this.downloads.add(this.buildAddPayload({ url }))
|
||||
.subscribe({
|
||||
next: (status: Status) => {
|
||||
if (status.status === 'error') {
|
||||
@@ -921,7 +970,7 @@ export class App implements AfterViewInit, OnInit {
|
||||
|
||||
toggleSortOrder() {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1050,15 +1099,35 @@ export class App implements AfterViewInit, OnInit {
|
||||
}
|
||||
|
||||
private updateMetrics() {
|
||||
this.activeDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'downloading' || d.status === 'preparing').length;
|
||||
this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length;
|
||||
this.completedDownloads = Array.from(this.downloads.done.values()).filter(d => d.status === 'finished').length;
|
||||
this.failedDownloads = Array.from(this.downloads.done.values()).filter(d => d.status === 'error').length;
|
||||
let active = 0;
|
||||
let queued = 0;
|
||||
let completed = 0;
|
||||
let failed = 0;
|
||||
let speed = 0;
|
||||
|
||||
// Calculate total speed from downloading items
|
||||
const downloadingItems = Array.from(this.downloads.queue.values())
|
||||
.filter(d => d.status === 'downloading');
|
||||
this.downloads.queue.forEach((download) => {
|
||||
if (download.status === 'downloading') {
|
||||
active++;
|
||||
speed += download.speed || 0;
|
||||
} else if (download.status === 'preparing') {
|
||||
active++;
|
||||
} else if (download.status === 'pending') {
|
||||
queued++;
|
||||
}
|
||||
});
|
||||
|
||||
this.totalSpeed = downloadingItems.reduce((total, item) => total + (item.speed || 0), 0);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { MasterCheckboxComponent } from './master-checkbox.component';
|
||||
export { SlaveCheckboxComponent } from './slave-checkbox.component';
|
||||
export { SelectAllCheckboxComponent } from './master-checkbox.component';
|
||||
export { ItemCheckboxComponent } from './slave-checkbox.component';
|
||||
@@ -3,18 +3,18 @@ import { Checkable } from "../interfaces";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
|
||||
@Component({
|
||||
selector: 'app-master-checkbox',
|
||||
selector: 'app-select-all-checkbox',
|
||||
template: `
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="{{id()}}-select-all" #masterCheckbox [(ngModel)]="selected" (change)="clicked()">
|
||||
<label class="form-check-label" for="{{id()}}-select-all"></label>
|
||||
<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 visually-hidden" for="{{id()}}-select-all">Select all</label>
|
||||
</div>
|
||||
`,
|
||||
imports: [
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class MasterCheckboxComponent {
|
||||
export class SelectAllCheckboxComponent {
|
||||
readonly id = input.required<string>();
|
||||
readonly list = input.required<Map<string, Checkable>>();
|
||||
readonly changed = output<number>();
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { MasterCheckboxComponent } from './master-checkbox.component';
|
||||
import { SelectAllCheckboxComponent } from './master-checkbox.component';
|
||||
import { Checkable } from '../interfaces';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-slave-checkbox',
|
||||
selector: 'app-item-checkbox',
|
||||
template: `
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="{{master().id()}}-{{id()}}-select" [(ngModel)]="checkable().checked" (change)="master().selectionChanged()">
|
||||
<label class="form-check-label" for="{{master().id()}}-{{id()}}-select"></label>
|
||||
<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 visually-hidden" for="{{master().id()}}-{{id()}}-select">Select item</label>
|
||||
</div>
|
||||
`,
|
||||
imports: [
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class SlaveCheckboxComponent {
|
||||
export class ItemCheckboxComponent {
|
||||
readonly id = input.required<string>();
|
||||
readonly master = input.required<MasterCheckboxComponent>();
|
||||
readonly master = input.required<SelectAllCheckboxComponent>();
|
||||
readonly checkable = input.required<Checkable>();
|
||||
}
|
||||
|
||||
15
ui/src/app/pipes/speed.pipe.spec.ts
Normal file
15
ui/src/app/pipes/speed.pipe.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -1,43 +1,19 @@
|
||||
import { Pipe, PipeTransform } from "@angular/core";
|
||||
import { BehaviorSubject, throttleTime } from "rxjs";
|
||||
|
||||
@Pipe({
|
||||
name: 'speed',
|
||||
pure: false // Make the pipe impure so it can handle async updates
|
||||
pure: true
|
||||
})
|
||||
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 {
|
||||
// If speed is invalid or 0, return empty string
|
||||
if (value === null || value === undefined || isNaN(value) || value <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Update the speed subject
|
||||
this.speedSubject.next(value);
|
||||
|
||||
// Return the last formatted speed
|
||||
return this.formattedSpeed;
|
||||
const k = 1024;
|
||||
const decimals = 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(value) / Math.log(k));
|
||||
return `${parseFloat((value / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,22 @@ import { catchError } from 'rxjs/operators';
|
||||
import { MeTubeSocket } from './metube-socket.service';
|
||||
import { Download, Status, State } from '../interfaces';
|
||||
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({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@@ -14,16 +30,15 @@ export class DownloadsService {
|
||||
loading = true;
|
||||
queue = new Map<string, Download>();
|
||||
done = new Map<string, Download>();
|
||||
queueChanged = new Subject();
|
||||
doneChanged = new Subject();
|
||||
customDirsChanged = new Subject();
|
||||
ytdlOptionsChanged = new Subject();
|
||||
configurationChanged = new Subject();
|
||||
updated = new Subject();
|
||||
queueChanged = new Subject<void>();
|
||||
doneChanged = new Subject<void>();
|
||||
customDirsChanged = new Subject<Record<string, string[]>>();
|
||||
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
|
||||
configurationChanged = new Subject<Record<string, unknown>>();
|
||||
updated = new Subject<void>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
configuration: any = {};
|
||||
customDirs = {};
|
||||
configuration: Record<string, unknown> = {};
|
||||
customDirs: Record<string, string[]> = {};
|
||||
|
||||
constructor() {
|
||||
this.socket.fromEvent('all')
|
||||
@@ -35,15 +50,15 @@ export class DownloadsService {
|
||||
data[0].forEach(entry => this.queue.set(...entry));
|
||||
this.done.clear();
|
||||
data[1].forEach(entry => this.done.set(...entry));
|
||||
this.queueChanged.next(null);
|
||||
this.doneChanged.next(null);
|
||||
this.queueChanged.next();
|
||||
this.doneChanged.next();
|
||||
});
|
||||
this.socket.fromEvent('added')
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((strdata: string) => {
|
||||
const data: Download = JSON.parse(strdata);
|
||||
this.queue.set(data.url, data);
|
||||
this.queueChanged.next(null);
|
||||
this.queueChanged.next();
|
||||
});
|
||||
this.socket.fromEvent('updated')
|
||||
.pipe(takeUntilDestroyed())
|
||||
@@ -53,7 +68,7 @@ export class DownloadsService {
|
||||
data.checked = !!dl?.checked;
|
||||
data.deleting = !!dl?.deleting;
|
||||
this.queue.set(data.url, data);
|
||||
this.updated.next(null);
|
||||
this.updated.next();
|
||||
});
|
||||
this.socket.fromEvent('completed')
|
||||
.pipe(takeUntilDestroyed())
|
||||
@@ -61,22 +76,22 @@ export class DownloadsService {
|
||||
const data: Download = JSON.parse(strdata);
|
||||
this.queue.delete(data.url);
|
||||
this.done.set(data.url, data);
|
||||
this.queueChanged.next(null);
|
||||
this.doneChanged.next(null);
|
||||
this.queueChanged.next();
|
||||
this.doneChanged.next();
|
||||
});
|
||||
this.socket.fromEvent('canceled')
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((strdata: string) => {
|
||||
const data: string = JSON.parse(strdata);
|
||||
this.queue.delete(data);
|
||||
this.queueChanged.next(null);
|
||||
this.queueChanged.next();
|
||||
});
|
||||
this.socket.fromEvent('cleared')
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((strdata: string) => {
|
||||
const data: string = JSON.parse(strdata);
|
||||
this.done.delete(data);
|
||||
this.doneChanged.next(null);
|
||||
this.doneChanged.next();
|
||||
});
|
||||
this.socket.fromEvent('configuration')
|
||||
.pipe(takeUntilDestroyed())
|
||||
@@ -103,39 +118,29 @@ export class DownloadsService {
|
||||
}
|
||||
|
||||
handleHTTPError(error: HttpErrorResponse) {
|
||||
const msg = error.error instanceof ErrorEvent ? error.error.message : error.error;
|
||||
return of({status: 'error', msg: msg})
|
||||
const msg = error.error instanceof ErrorEvent
|
||||
? error.error.message
|
||||
: (typeof error.error === 'string'
|
||||
? error.error
|
||||
: (error.error?.msg || error.message || 'Request failed'));
|
||||
return of({ status: 'error', msg });
|
||||
}
|
||||
|
||||
public add(
|
||||
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,
|
||||
) {
|
||||
public add(payload: AddDownloadPayload) {
|
||||
return this.http.post<Status>('add', {
|
||||
url: url,
|
||||
download_type: downloadType,
|
||||
codec: codec,
|
||||
quality: quality,
|
||||
format: format,
|
||||
folder: folder,
|
||||
custom_name_prefix: customNamePrefix,
|
||||
playlist_item_limit: playlistItemLimit,
|
||||
auto_start: autoStart,
|
||||
split_by_chapters: splitByChapters,
|
||||
chapter_template: chapterTemplate,
|
||||
subtitle_language: subtitleLanguage,
|
||||
subtitle_mode: subtitleMode,
|
||||
url: payload.url,
|
||||
download_type: payload.downloadType,
|
||||
codec: payload.codec,
|
||||
quality: payload.quality,
|
||||
format: payload.format,
|
||||
folder: payload.folder,
|
||||
custom_name_prefix: payload.customNamePrefix,
|
||||
playlist_item_limit: payload.playlistItemLimit,
|
||||
auto_start: payload.autoStart,
|
||||
split_by_chapters: payload.splitByChapters,
|
||||
chapter_template: payload.chapterTemplate,
|
||||
subtitle_language: payload.subtitleLanguage,
|
||||
subtitle_mode: payload.subtitleMode,
|
||||
}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
@@ -169,49 +174,6 @@ export class DownloadsService {
|
||||
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
|
||||
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() {
|
||||
return this.http.post<Status>('cancel-add', {}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { DownloadsService } from './downloads.service';
|
||||
export { SpeedService } from './speed.service';
|
||||
export { MeTubeSocket } from './metube-socket.service';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,22 @@
|
||||
|
||||
[data-bs-theme="dark"] &
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user