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'
|
- '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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
102
app/main.py
102
app/main.py
@@ -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):
|
||||||
|
|||||||
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:
|
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'}
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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" /> {{ 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" /> {{ 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 #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]="!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 #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]="!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 #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]="!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>
|
<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>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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]}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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"] &
|
[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
|
||||||
|
|||||||
Reference in New Issue
Block a user