mirror of
https://github.com/alexta69/metube.git
synced 2026-03-19 23:13:38 +00:00
Compare commits
6 Commits
2026.03.13
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bf508dbc6 | ||
|
|
104d547150 | ||
|
|
289133e507 | ||
|
|
7fa1fc7938 | ||
|
|
04959a6189 | ||
|
|
8b0d682b35 |
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.35.0
|
||||||
|
with:
|
||||||
|
scan-type: fs
|
||||||
|
scan-ref: .
|
||||||
|
format: table
|
||||||
|
severity: CRITICAL,HIGH
|
||||||
|
|
||||||
dockerhub-build-push:
|
dockerhub-build-push:
|
||||||
|
needs: quality-checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Get current date
|
name: Get current date
|
||||||
id: date
|
id: date
|
||||||
run: echo "::set-output name=date::$(date +'%Y.%m.%d')"
|
run: echo "date=$(date +'%Y.%m.%d')" >> "$GITHUB_OUTPUT"
|
||||||
-
|
-
|
||||||
name: Checkout
|
name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
|
|||||||
UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode && \
|
UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode && \
|
||||||
uv cache clean && \
|
uv cache clean && \
|
||||||
rm -f /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/uvw && \
|
rm -f /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/uvw && \
|
||||||
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y v2.7.2 && \
|
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y && \
|
||||||
apt-get purge -y --auto-remove build-essential && \
|
apt-get purge -y --auto-remove build-essential && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
mkdir /.cache && chmod 777 /.cache
|
mkdir /.cache && chmod 777 /.cache
|
||||||
@@ -63,11 +63,12 @@ ENV PUID=1000
|
|||||||
ENV PGID=1000
|
ENV PGID=1000
|
||||||
ENV UMASK=022
|
ENV UMASK=022
|
||||||
|
|
||||||
ENV DOWNLOAD_DIR /downloads
|
ENV DOWNLOAD_DIR=/downloads
|
||||||
ENV STATE_DIR /downloads/.metube
|
ENV STATE_DIR=/downloads/.metube
|
||||||
ENV TEMP_DIR /downloads
|
ENV TEMP_DIR=/downloads
|
||||||
VOLUME /downloads
|
VOLUME /downloads
|
||||||
EXPOSE 8081
|
EXPOSE 8081
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS "http://localhost:8081/" || exit 1
|
||||||
|
|
||||||
# Add build-time argument for version
|
# Add build-time argument for version
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -23,14 +23,14 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^21.2.1",
|
"@angular/animations": "^21.2.4",
|
||||||
"@angular/common": "^21.2.1",
|
"@angular/common": "^21.2.4",
|
||||||
"@angular/compiler": "^21.2.1",
|
"@angular/compiler": "^21.2.4",
|
||||||
"@angular/core": "^21.2.1",
|
"@angular/core": "^21.2.4",
|
||||||
"@angular/forms": "^21.2.1",
|
"@angular/forms": "^21.2.4",
|
||||||
"@angular/platform-browser": "^21.2.1",
|
"@angular/platform-browser": "^21.2.4",
|
||||||
"@angular/platform-browser-dynamic": "^21.2.1",
|
"@angular/platform-browser-dynamic": "^21.2.4",
|
||||||
"@angular/service-worker": "^21.2.1",
|
"@angular/service-worker": "^21.2.4",
|
||||||
"@fortawesome/angular-fontawesome": "~4.0.0",
|
"@fortawesome/angular-fontawesome": "~4.0.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
"@ng-select/ng-select": "^21.5.2",
|
"@ng-select/ng-select": "^21.5.2",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"ngx-cookie-service": "^21.1.0",
|
"ngx-cookie-service": "^21.3.1",
|
||||||
"ngx-socket-io": "~4.10.0",
|
"ngx-socket-io": "~4.10.0",
|
||||||
"rxjs": "~7.8.2",
|
"rxjs": "~7.8.2",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
@@ -48,16 +48,16 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-eslint/builder": "21.1.0",
|
"@angular-eslint/builder": "21.1.0",
|
||||||
"@angular/build": "^21.2.1",
|
"@angular/build": "^21.2.2",
|
||||||
"@angular/cli": "^21.2.1",
|
"@angular/cli": "^21.2.2",
|
||||||
"@angular/compiler-cli": "^21.2.1",
|
"@angular/compiler-cli": "^21.2.4",
|
||||||
"@angular/localize": "^21.2.1",
|
"@angular/localize": "^21.2.4",
|
||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.4",
|
||||||
"angular-eslint": "21.1.0",
|
"angular-eslint": "21.1.0",
|
||||||
"eslint": "^9.39.3",
|
"eslint": "^9.39.4",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "8.47.0",
|
"typescript-eslint": "8.47.0",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1018
ui/pnpm-lock.yaml
generated
1018
ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
137
uv.lock
generated
137
uv.lock
generated
@@ -160,18 +160,23 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotlicffi"
|
name = "brotlicffi"
|
||||||
version = "1.2.0.0"
|
version = "1.2.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cffi" },
|
{ name = "cffi" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/df/a72b284d8c7bef0ed5756b41c2eb7d0219a1dd6ac6762f1c7bdbc31ef3af/brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4", size = 432340, upload-time = "2025-11-21T18:17:42.277Z" },
|
{ url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/2b/cc55a2d1d6fb4f5d458fba44a3d3f91fb4320aa14145799fd3a996af0686/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7", size = 1534002, upload-time = "2025-11-21T18:17:43.746Z" },
|
{ url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/9c/d51486bf366fc7d6735f0e46b5b96ca58dc005b250263525a1eea3cd5d21/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990", size = 1536547, upload-time = "2025-11-21T18:17:45.729Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/37/293a9a0a7caf17e6e657668bebb92dfe730305999fe8c0e2703b8888789c/brotlicffi-1.2.0.0-cp38-abi3-win32.whl", hash = "sha256:23e5c912fdc6fd37143203820230374d24babd078fc054e18070a647118158f6", size = 343085, upload-time = "2025-11-21T18:17:48.887Z" },
|
{ url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/6b/6e92009df3b8b7272f85a0992b306b61c34b7ea1c4776643746e61c380ac/brotlicffi-1.2.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:f139a7cdfe4ae7859513067b736eb44d19fae1186f9e99370092f6915216451b", size = 378586, upload-time = "2025-11-21T18:17:50.531Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -230,43 +235,59 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "charset-normalizer"
|
name = "charset-normalizer"
|
||||||
version = "3.4.4"
|
version = "3.4.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
{ url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
{ url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
{ url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
{ url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
{ url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
{ url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
{ url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
{ url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
{ url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
{ url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
{ url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
{ url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
{ url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -303,15 +324,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deno"
|
name = "deno"
|
||||||
version = "2.7.2"
|
version = "2.7.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/29/b2941d53d94094e20e52def86956528140dbe60b49d715803f7e9799d42f/deno-2.7.2.tar.gz", hash = "sha256:3dc9461ac4dd0d6661769f03460861709e17c4e516dfce14676e6a3146824b7b", size = 8167, upload-time = "2026-03-03T16:10:51.429Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/31/8bbaf3fb6a41929ae161be0b2a79b2747b5e5490811573ef60af7e3aeac3/deno-2.7.5.tar.gz", hash = "sha256:50635e0462697fa6e79d90bcacbe98e19f785e604c0e5061754de89b3668af83", size = 8166, upload-time = "2026-03-11T12:48:44.286Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/a0/9e6f45c25ef36db827e75bd35bf9378c196a6bed2804a8259d1d63bab84f/deno-2.7.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:67509919fa9df639d9375e441648ae5a3ab9bb1ce6fcddc21c49c08368af4d68", size = 46325714, upload-time = "2026-03-03T16:10:35.82Z" },
|
{ url = "https://files.pythonhosted.org/packages/68/15/47c4b8da4e1b312ab14a2517e3f484c4d67a879cb5099cb6c33b8ce00c8c/deno-2.7.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:29cb89cdaea5f36133841fb4da058b1c6cb70d117ebfc7a24c717747b58e8503", size = 46641593, upload-time = "2026-03-11T12:48:16.589Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/ce/085c3002cdfc0d33b30896b3d1469024c23e3971cba4a15ae3983c48d2e4/deno-2.7.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a19f75d7a148a2d030543db88734f03648e31dc7385a9c62aa1d975e2b0df8d9", size = 43264279, upload-time = "2026-03-03T16:10:39.011Z" },
|
{ url = "https://files.pythonhosted.org/packages/1c/3a/c3f8842b7499ff3faeb7508711a82b736d3a4c6e0ffb359191386bcf539d/deno-2.7.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6456980341e97e4eb88e0c560fa57cd1b5f732e0eaadccc6c47d5ada73a71ff3", size = 43537874, upload-time = "2026-03-11T12:48:21.958Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/f0/c415c08ca30fb084887a96b88df7f6511c98575b365db87b0fac76a82773/deno-2.7.2-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:f7b63f13c9fdeb18d0435e80aa4677878ac1b9ac23a49c7570958b9d81772e06", size = 47024484, upload-time = "2026-03-03T16:10:42.619Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/a2/53a013ba3509648582748678d5c6980210a45e0913934f91bfe1ec237e07/deno-2.7.5-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:fdc1e647a06ef792643237c030f45295692b0abc05d5bc9894fb11fd70876953", size = 47265090, upload-time = "2026-03-11T12:48:26.819Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/14/bfac1928082f78f120aaff7608f211a8beab8f66e72defc0ac85d6f52f84/deno-2.7.2-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:bded39ebc9d19748a13a4c046a715f12c445a3e15c0b4cde6d42cc47793efcf0", size = 48981918, upload-time = "2026-03-03T16:10:45.822Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/85/88c76daa72575f7229bb94191f15f4771f0614227bf8467bfe06e051f4ab/deno-2.7.5-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:c15e6b8ccf5f0808cd5ba243ea4eea7d8d78f6fdff228f5c6c85b96ba286bd3c", size = 49262188, upload-time = "2026-03-11T12:48:32.125Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/07/b332f98969937d435ba2905195a0b3dd2162f192659595dde88c615b04e1/deno-2.7.2-py3-none-win_amd64.whl", hash = "sha256:5d525d270e16d5ea22ad90a65e1ebc0dff8b83068d698f6bad138bfa857e4d28", size = 48330774, upload-time = "2026-03-03T16:10:49.209Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/5e/501a92ef93d6d46ed8a1a8c03cff8bcbccbc06c1f59b163113ff09cd23cf/deno-2.7.5-py3-none-win_amd64.whl", hash = "sha256:3e3d06006ee39901dd23068c4a501a4a524fb71c323e22503b1b2ddf236da463", size = 48481169, upload-time = "2026-03-11T12:48:38.684Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -555,11 +576,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "platformdirs"
|
||||||
version = "4.9.2"
|
version = "4.9.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
|
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -951,11 +972,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yt-dlp"
|
name = "yt-dlp"
|
||||||
version = "2026.3.3"
|
version = "2026.3.17"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/66/6f/7427d23609353e5ef3470ff43ef551b8bd7b166dd4fef48957f0d0e040fe/yt_dlp-2026.3.3.tar.gz", hash = "sha256:3db7969e3a8964dc786bdebcffa2653f31123bf2a630f04a17bdafb7bbd39952", size = 3118658, upload-time = "2026-03-03T16:54:53.909Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/8b/34/7c6b4e3f89cb6416d2cd7ab6dab141a1df97ab0fb22d15816db2c92148c9/yt_dlp-2026.3.17.tar.gz", hash = "sha256:ba7aa31d533f1ffccfe70e421596d7ca8ff0bf1398dc6bb658b7d9dec057d2c9", size = 3119221, upload-time = "2026-03-17T23:43:00.244Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/a4/8b5cd28ab87aef48ef15e74241befec3445496327db028f34147a9e0f14f/yt_dlp-2026.3.3-py3-none-any.whl", hash = "sha256:166c6e68c49ba526474bd400e0129f58aa522c2896204aa73be669c3d2f15e63", size = 3315599, upload-time = "2026-03-03T16:54:51.899Z" },
|
{ url = "https://files.pythonhosted.org/packages/cd/13/5093bcb954878e50f7217fd2ab94282b53934022e4e4a03265582da83bf5/yt_dlp-2026.3.17-py3-none-any.whl", hash = "sha256:32992db94303a8a5d211a183f2174834fe7f8c29d83ed2e7a324eae97a8f26d8", size = 3315134, upload-time = "2026-03-17T23:42:57.863Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -979,9 +1000,9 @@ deno = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yt-dlp-ejs"
|
name = "yt-dlp-ejs"
|
||||||
version = "0.5.0"
|
version = "0.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/0d/b9e4ab1b47cdeba0842df634b74b3c0144307640ad5b632a5e189c4ab7ce/yt_dlp_ejs-0.5.0.tar.gz", hash = "sha256:8dfae59e418232f485253dcf8e197fefa232423c3af7824fe19e4517b173293b", size = 98925, upload-time = "2026-02-21T19:29:16.844Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d3/e6/cceb9530e8f4e5940f6f7822d90e9d94f1b85343329a16baaf47bbbb3de1/yt_dlp_ejs-0.8.0.tar.gz", hash = "sha256:d5fa1639f63b5c4af8d932495f60689d5370f1a095782c944f7f62a303eb104e", size = 96571, upload-time = "2026-03-17T22:49:19.299Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/5b/1283356b70d4893a8a050cee15092e1b08ea15310b94365f88067146721b/yt_dlp_ejs-0.5.0-py3-none-any.whl", hash = "sha256:674fc0efea741d3100cdf3f0f9e123150715ee41edf47ea7a62fbdeda204bdec", size = 54032, upload-time = "2026-02-21T19:29:15.408Z" },
|
{ url = "https://files.pythonhosted.org/packages/e3/bd/520769863744b669440a924271a6159ddd82ad5ae26b4ac4d4b69e9f8d44/yt_dlp_ejs-0.8.0-py3-none-any.whl", hash = "sha256:79300e5fca7f937a1eeede11f0456862c1b41107ce1d726871e0207424f4bdb4", size = 53443, upload-time = "2026-03-17T22:49:17.736Z" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user