mirror of
https://github.com/alexta69/metube.git
synced 2026-03-19 15:03:50 +00:00
Compare commits
11 Commits
2026.02.13
...
2026.02.27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7427cbb0c0 | ||
|
|
053e41cf52 | ||
|
|
77da359234 | ||
|
|
8dff6448b2 | ||
|
|
dd4e05325a | ||
|
|
ce9703cd04 | ||
|
|
973a87ffc6 | ||
|
|
e24890fd9b | ||
|
|
5170c708cd | ||
|
|
56258a4f1b | ||
|
|
3bf7fb51f4 |
@@ -1,6 +1,17 @@
|
||||
import copy
|
||||
|
||||
AUDIO_FORMATS = ("m4a", "mp3", "opus", "wav", "flac")
|
||||
CAPTION_MODES = ("auto_only", "manual_only", "prefer_manual", "prefer_auto")
|
||||
|
||||
|
||||
def _normalize_caption_mode(mode: str) -> str:
|
||||
mode = (mode or "").strip()
|
||||
return mode if mode in CAPTION_MODES else "prefer_manual"
|
||||
|
||||
|
||||
def _normalize_subtitle_language(language: str) -> str:
|
||||
language = (language or "").strip()
|
||||
return language or "en"
|
||||
|
||||
|
||||
def get_format(format: str, quality: str) -> str:
|
||||
@@ -26,6 +37,10 @@ def get_format(format: str, quality: str) -> str:
|
||||
# Quality is irrelevant in this case since we skip the download
|
||||
return "bestaudio/best"
|
||||
|
||||
if format == "captions":
|
||||
# Quality is irrelevant in this case since we skip the download
|
||||
return "bestaudio/best"
|
||||
|
||||
if format in AUDIO_FORMATS:
|
||||
# Audio quality needs to be set post-download, set in opts
|
||||
return f"bestaudio[ext={format}]/bestaudio/best"
|
||||
@@ -51,7 +66,14 @@ def get_format(format: str, quality: str) -> str:
|
||||
raise Exception(f"Unkown format {format}")
|
||||
|
||||
|
||||
def get_opts(format: str, quality: str, ytdl_opts: dict) -> dict:
|
||||
def get_opts(
|
||||
format: str,
|
||||
quality: str,
|
||||
ytdl_opts: dict,
|
||||
subtitle_format: str = "srt",
|
||||
subtitle_language: str = "en",
|
||||
subtitle_mode: str = "prefer_manual",
|
||||
) -> dict:
|
||||
"""
|
||||
Returns extra download options
|
||||
Mostly postprocessing options
|
||||
@@ -98,6 +120,34 @@ def get_opts(format: str, quality: str, ytdl_opts: dict) -> dict:
|
||||
{"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"}
|
||||
)
|
||||
|
||||
if format == "captions":
|
||||
mode = _normalize_caption_mode(subtitle_mode)
|
||||
language = _normalize_subtitle_language(subtitle_language)
|
||||
opts["skip_download"] = True
|
||||
requested_subtitle_format = (subtitle_format or "srt").lower()
|
||||
# txt is a derived, non-timed format produced from SRT after download.
|
||||
if requested_subtitle_format == "txt":
|
||||
requested_subtitle_format = "srt"
|
||||
opts["subtitlesformat"] = requested_subtitle_format
|
||||
if mode == "manual_only":
|
||||
opts["writesubtitles"] = True
|
||||
opts["writeautomaticsub"] = False
|
||||
opts["subtitleslangs"] = [language]
|
||||
elif mode == "auto_only":
|
||||
opts["writesubtitles"] = False
|
||||
opts["writeautomaticsub"] = True
|
||||
# `-orig` captures common YouTube auto-sub tags. The plain language
|
||||
# fallback keeps behavior useful across other extractors.
|
||||
opts["subtitleslangs"] = [f"{language}-orig", language]
|
||||
elif mode == "prefer_auto":
|
||||
opts["writesubtitles"] = True
|
||||
opts["writeautomaticsub"] = True
|
||||
opts["subtitleslangs"] = [f"{language}-orig", language]
|
||||
else:
|
||||
opts["writesubtitles"] = True
|
||||
opts["writeautomaticsub"] = True
|
||||
opts["subtitleslangs"] = [language, f"{language}-orig"]
|
||||
|
||||
opts["postprocessors"] = postprocessors + (
|
||||
opts["postprocessors"] if "postprocessors" in opts else []
|
||||
)
|
||||
|
||||
40
app/main.py
40
app/main.py
@@ -156,6 +156,9 @@ app = web.Application()
|
||||
sio = socketio.AsyncServer(cors_allowed_origins='*')
|
||||
sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io')
|
||||
routes = web.RouteTableDef()
|
||||
VALID_SUBTITLE_FORMATS = {'srt', 'txt', 'vtt', 'ttml', 'sbv', 'scc', 'dfxp'}
|
||||
VALID_SUBTITLE_MODES = {'auto_only', 'manual_only', 'prefer_manual', 'prefer_auto'}
|
||||
SUBTITLE_LANGUAGE_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9-]{0,34}$')
|
||||
|
||||
class Notifier(DownloadQueueNotifier):
|
||||
async def added(self, dl):
|
||||
@@ -247,9 +250,14 @@ async def add(request):
|
||||
auto_start = post.get('auto_start')
|
||||
split_by_chapters = post.get('split_by_chapters')
|
||||
chapter_template = post.get('chapter_template')
|
||||
subtitle_format = post.get('subtitle_format')
|
||||
subtitle_language = post.get('subtitle_language')
|
||||
subtitle_mode = post.get('subtitle_mode')
|
||||
|
||||
if custom_name_prefix is None:
|
||||
custom_name_prefix = ''
|
||||
if custom_name_prefix and ('..' in custom_name_prefix or custom_name_prefix.startswith('/') or custom_name_prefix.startswith('\\')):
|
||||
raise web.HTTPBadRequest(reason='custom_name_prefix must not contain ".." or start with a path separator')
|
||||
if auto_start is None:
|
||||
auto_start = True
|
||||
if playlist_item_limit is None:
|
||||
@@ -258,10 +266,40 @@ async def add(request):
|
||||
split_by_chapters = False
|
||||
if chapter_template is None:
|
||||
chapter_template = config.OUTPUT_TEMPLATE_CHAPTER
|
||||
if subtitle_format is None:
|
||||
subtitle_format = 'srt'
|
||||
if subtitle_language is None:
|
||||
subtitle_language = 'en'
|
||||
if subtitle_mode is None:
|
||||
subtitle_mode = 'prefer_manual'
|
||||
subtitle_format = str(subtitle_format).strip().lower()
|
||||
subtitle_language = str(subtitle_language).strip()
|
||||
subtitle_mode = str(subtitle_mode).strip()
|
||||
if chapter_template and ('..' in chapter_template or chapter_template.startswith('/') or chapter_template.startswith('\\')):
|
||||
raise web.HTTPBadRequest(reason='chapter_template must not contain ".." or start with a path separator')
|
||||
if subtitle_format not in VALID_SUBTITLE_FORMATS:
|
||||
raise web.HTTPBadRequest(reason=f'subtitle_format must be one of {sorted(VALID_SUBTITLE_FORMATS)}')
|
||||
if not SUBTITLE_LANGUAGE_RE.fullmatch(subtitle_language):
|
||||
raise web.HTTPBadRequest(reason='subtitle_language must match pattern [A-Za-z0-9-] and be at most 35 characters')
|
||||
if subtitle_mode not in VALID_SUBTITLE_MODES:
|
||||
raise web.HTTPBadRequest(reason=f'subtitle_mode must be one of {sorted(VALID_SUBTITLE_MODES)}')
|
||||
|
||||
playlist_item_limit = int(playlist_item_limit)
|
||||
|
||||
status = await dqueue.add(url, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template)
|
||||
status = await dqueue.add(
|
||||
url,
|
||||
quality,
|
||||
format,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
playlist_item_limit,
|
||||
auto_start,
|
||||
split_by_chapters,
|
||||
chapter_template,
|
||||
subtitle_format,
|
||||
subtitle_language,
|
||||
subtitle_mode,
|
||||
)
|
||||
return web.Response(text=serializer.encode(status))
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'delete')
|
||||
|
||||
246
app/ytdl.py
246
app/ytdl.py
@@ -69,6 +69,45 @@ def _convert_generators_to_lists(obj):
|
||||
else:
|
||||
return obj
|
||||
|
||||
|
||||
def _convert_srt_to_txt_file(subtitle_path: str):
|
||||
"""Convert an SRT subtitle file into plain text by stripping cue numbers/timestamps."""
|
||||
txt_path = os.path.splitext(subtitle_path)[0] + ".txt"
|
||||
try:
|
||||
with open(subtitle_path, "r", encoding="utf-8", errors="replace") as infile:
|
||||
content = infile.read()
|
||||
|
||||
# Normalize newlines so cue splitting is consistent across platforms.
|
||||
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
cues = []
|
||||
for block in re.split(r"\n{2,}", content):
|
||||
lines = [line.strip() for line in block.split("\n") if line.strip()]
|
||||
if not lines:
|
||||
continue
|
||||
if re.fullmatch(r"\d+", lines[0]):
|
||||
lines = lines[1:]
|
||||
if lines and "-->" in lines[0]:
|
||||
lines = lines[1:]
|
||||
|
||||
text_lines = []
|
||||
for line in lines:
|
||||
if "-->" in line:
|
||||
continue
|
||||
clean_line = re.sub(r"<[^>]+>", "", line).strip()
|
||||
if clean_line:
|
||||
text_lines.append(clean_line)
|
||||
if text_lines:
|
||||
cues.append(" ".join(text_lines))
|
||||
|
||||
with open(txt_path, "w", encoding="utf-8") as outfile:
|
||||
if cues:
|
||||
outfile.write("\n".join(cues))
|
||||
outfile.write("\n")
|
||||
return txt_path
|
||||
except OSError as exc:
|
||||
log.warning(f"Failed to convert subtitle file {subtitle_path} to txt: {exc}")
|
||||
return None
|
||||
|
||||
class DownloadQueueNotifier:
|
||||
async def added(self, dl):
|
||||
raise NotImplementedError
|
||||
@@ -86,7 +125,24 @@ class DownloadQueueNotifier:
|
||||
raise NotImplementedError
|
||||
|
||||
class DownloadInfo:
|
||||
def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit, split_by_chapters, chapter_template):
|
||||
def __init__(
|
||||
self,
|
||||
id,
|
||||
title,
|
||||
url,
|
||||
quality,
|
||||
format,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
error,
|
||||
entry,
|
||||
playlist_item_limit,
|
||||
split_by_chapters,
|
||||
chapter_template,
|
||||
subtitle_format="srt",
|
||||
subtitle_language="en",
|
||||
subtitle_mode="prefer_manual",
|
||||
):
|
||||
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
|
||||
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
|
||||
self.url = url
|
||||
@@ -104,6 +160,10 @@ class DownloadInfo:
|
||||
self.playlist_item_limit = playlist_item_limit
|
||||
self.split_by_chapters = split_by_chapters
|
||||
self.chapter_template = chapter_template
|
||||
self.subtitle_format = subtitle_format
|
||||
self.subtitle_language = subtitle_language
|
||||
self.subtitle_mode = subtitle_mode
|
||||
self.subtitle_files = []
|
||||
|
||||
class Download:
|
||||
manager = None
|
||||
@@ -113,11 +173,18 @@ class Download:
|
||||
self.temp_dir = temp_dir
|
||||
self.output_template = output_template
|
||||
self.output_template_chapter = output_template_chapter
|
||||
self.info = info
|
||||
self.format = get_format(format, quality)
|
||||
self.ytdl_opts = get_opts(format, quality, ytdl_opts)
|
||||
self.ytdl_opts = get_opts(
|
||||
format,
|
||||
quality,
|
||||
ytdl_opts,
|
||||
subtitle_format=getattr(info, 'subtitle_format', 'srt'),
|
||||
subtitle_language=getattr(info, 'subtitle_language', 'en'),
|
||||
subtitle_mode=getattr(info, 'subtitle_mode', 'prefer_manual'),
|
||||
)
|
||||
if "impersonate" in self.ytdl_opts:
|
||||
self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"])
|
||||
self.info = info
|
||||
self.canceled = False
|
||||
self.tmpfilename = None
|
||||
self.status_queue = None
|
||||
@@ -144,8 +211,30 @@ class Download:
|
||||
|
||||
def put_status_postprocessor(d):
|
||||
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
|
||||
filename = d['info_dict']['filepath']
|
||||
filepath = d['info_dict']['filepath']
|
||||
if '__finaldir' in d['info_dict']:
|
||||
finaldir = d['info_dict']['__finaldir']
|
||||
# Compute relative path from temp dir to preserve
|
||||
# subdirectory structure from the output template.
|
||||
try:
|
||||
rel_path = os.path.relpath(filepath, self.temp_dir)
|
||||
except ValueError:
|
||||
rel_path = os.path.basename(filepath)
|
||||
if rel_path.startswith('..'):
|
||||
# filepath is not under temp_dir, fall back to basename
|
||||
rel_path = os.path.basename(filepath)
|
||||
filename = os.path.join(finaldir, rel_path)
|
||||
else:
|
||||
filename = filepath
|
||||
self.status_queue.put({'status': 'finished', 'filename': filename})
|
||||
# For captions-only downloads, yt-dlp may still report a media-like
|
||||
# filepath in MoveFiles. Capture subtitle outputs explicitly so the
|
||||
# UI can link to real caption files.
|
||||
if self.info.format == 'captions':
|
||||
requested_subtitles = d.get('info_dict', {}).get('requested_subtitles', {}) or {}
|
||||
for subtitle in requested_subtitles.values():
|
||||
if isinstance(subtitle, dict) and subtitle.get('filepath'):
|
||||
self.status_queue.put({'subtitle_file': subtitle['filepath']})
|
||||
|
||||
# Capture all chapter files when SplitChapters finishes
|
||||
elif d.get('postprocessor') == 'SplitChapters' and d.get('status') == 'finished':
|
||||
@@ -246,7 +335,15 @@ class Download:
|
||||
self.tmpfilename = status.get('tmpfilename')
|
||||
if 'filename' in status:
|
||||
fileName = status.get('filename')
|
||||
self.info.filename = os.path.relpath(fileName, self.download_dir)
|
||||
rel_name = os.path.relpath(fileName, self.download_dir)
|
||||
# For captions mode, ignore media-like placeholders and let subtitle_file
|
||||
# statuses define the final file shown in the UI.
|
||||
if self.info.format == 'captions':
|
||||
requested_subtitle_format = str(getattr(self.info, 'subtitle_format', '')).lower()
|
||||
allowed_caption_exts = ('.txt',) if requested_subtitle_format == 'txt' else ('.vtt', '.srt', '.sbv', '.scc', '.ttml', '.dfxp')
|
||||
if not rel_name.lower().endswith(allowed_caption_exts):
|
||||
continue
|
||||
self.info.filename = rel_name
|
||||
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
|
||||
if self.info.format == 'thumbnail':
|
||||
self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename)
|
||||
@@ -266,6 +363,37 @@ class Download:
|
||||
# Skip the rest of status processing for chapter files
|
||||
continue
|
||||
|
||||
if 'subtitle_file' in status:
|
||||
subtitle_file = status.get('subtitle_file')
|
||||
if not subtitle_file:
|
||||
continue
|
||||
subtitle_output_file = subtitle_file
|
||||
|
||||
# txt mode is derived from SRT by stripping cue metadata.
|
||||
if self.info.format == 'captions' and str(getattr(self.info, 'subtitle_format', '')).lower() == 'txt':
|
||||
converted_txt = _convert_srt_to_txt_file(subtitle_file)
|
||||
if converted_txt:
|
||||
subtitle_output_file = converted_txt
|
||||
if converted_txt != subtitle_file:
|
||||
try:
|
||||
os.remove(subtitle_file)
|
||||
except OSError as exc:
|
||||
log.debug(f"Could not remove temporary SRT file {subtitle_file}: {exc}")
|
||||
|
||||
rel_path = os.path.relpath(subtitle_output_file, self.download_dir)
|
||||
file_size = os.path.getsize(subtitle_output_file) if os.path.exists(subtitle_output_file) else None
|
||||
existing = next((sf for sf in self.info.subtitle_files if sf['filename'] == rel_path), None)
|
||||
if not existing:
|
||||
self.info.subtitle_files.append({'filename': rel_path, 'size': file_size})
|
||||
# Prefer first subtitle file as the primary result link in captions mode.
|
||||
if self.info.format == 'captions' and (
|
||||
not getattr(self.info, 'filename', None) or
|
||||
str(getattr(self.info, 'subtitle_format', '')).lower() == 'txt'
|
||||
):
|
||||
self.info.filename = rel_path
|
||||
self.info.size = file_size
|
||||
continue
|
||||
|
||||
self.info.status = status['status']
|
||||
self.info.msg = status.get('msg')
|
||||
if 'downloaded_bytes' in status:
|
||||
@@ -512,7 +640,22 @@ class DownloadQueue:
|
||||
self.pending.put(download)
|
||||
await self.notifier.added(dl)
|
||||
|
||||
async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already):
|
||||
async def __add_entry(
|
||||
self,
|
||||
entry,
|
||||
quality,
|
||||
format,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
playlist_item_limit,
|
||||
auto_start,
|
||||
split_by_chapters,
|
||||
chapter_template,
|
||||
subtitle_format,
|
||||
subtitle_language,
|
||||
subtitle_mode,
|
||||
already,
|
||||
):
|
||||
if not entry:
|
||||
return {'status': 'error', 'msg': "Invalid/empty data was given."}
|
||||
|
||||
@@ -528,7 +671,21 @@ class DownloadQueue:
|
||||
|
||||
if etype.startswith('url'):
|
||||
log.debug('Processing as a url')
|
||||
return await self.add(entry['url'], quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already)
|
||||
return await self.add(
|
||||
entry['url'],
|
||||
quality,
|
||||
format,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
playlist_item_limit,
|
||||
auto_start,
|
||||
split_by_chapters,
|
||||
chapter_template,
|
||||
subtitle_format,
|
||||
subtitle_language,
|
||||
subtitle_mode,
|
||||
already,
|
||||
)
|
||||
elif etype == 'playlist' or etype == 'channel':
|
||||
log.debug(f'Processing as a {etype}')
|
||||
entries = entry['entries']
|
||||
@@ -548,7 +705,23 @@ class DownloadQueue:
|
||||
for property in ("id", "title", "uploader", "uploader_id"):
|
||||
if property in entry:
|
||||
etr[f"{etype}_{property}"] = entry[property]
|
||||
results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already))
|
||||
results.append(
|
||||
await self.__add_entry(
|
||||
etr,
|
||||
quality,
|
||||
format,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
playlist_item_limit,
|
||||
auto_start,
|
||||
split_by_chapters,
|
||||
chapter_template,
|
||||
subtitle_format,
|
||||
subtitle_language,
|
||||
subtitle_mode,
|
||||
already,
|
||||
)
|
||||
)
|
||||
if any(res['status'] == 'error' for res in results):
|
||||
return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}
|
||||
return {'status': 'ok'}
|
||||
@@ -556,13 +729,48 @@ class DownloadQueue:
|
||||
log.debug('Processing as a video')
|
||||
key = entry.get('webpage_url') or entry['url']
|
||||
if not self.queue.exists(key):
|
||||
dl = DownloadInfo(entry['id'], entry.get('title') or entry['id'], key, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit, split_by_chapters, chapter_template)
|
||||
dl = DownloadInfo(
|
||||
entry['id'],
|
||||
entry.get('title') or entry['id'],
|
||||
key,
|
||||
quality,
|
||||
format,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
error,
|
||||
entry,
|
||||
playlist_item_limit,
|
||||
split_by_chapters,
|
||||
chapter_template,
|
||||
subtitle_format,
|
||||
subtitle_language,
|
||||
subtitle_mode,
|
||||
)
|
||||
await self.__add_download(dl, auto_start)
|
||||
return {'status': 'ok'}
|
||||
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
|
||||
|
||||
async def add(self, url, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start=True, split_by_chapters=False, chapter_template=None, already=None):
|
||||
log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=}')
|
||||
async def add(
|
||||
self,
|
||||
url,
|
||||
quality,
|
||||
format,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
playlist_item_limit,
|
||||
auto_start=True,
|
||||
split_by_chapters=False,
|
||||
chapter_template=None,
|
||||
subtitle_format="srt",
|
||||
subtitle_language="en",
|
||||
subtitle_mode="prefer_manual",
|
||||
already=None,
|
||||
):
|
||||
log.info(
|
||||
f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} '
|
||||
f'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} '
|
||||
f'{subtitle_format=} {subtitle_language=} {subtitle_mode=}'
|
||||
)
|
||||
already = set() if already is None else already
|
||||
if url in already:
|
||||
log.info('recursion detected, skipping')
|
||||
@@ -573,7 +781,21 @@ class DownloadQueue:
|
||||
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url)
|
||||
except yt_dlp.utils.YoutubeDLError as exc:
|
||||
return {'status': 'error', 'msg': str(exc)}
|
||||
return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already)
|
||||
return await self.__add_entry(
|
||||
entry,
|
||||
quality,
|
||||
format,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
playlist_item_limit,
|
||||
auto_start,
|
||||
split_by_chapters,
|
||||
chapter_template,
|
||||
subtitle_format,
|
||||
subtitle_language,
|
||||
subtitle_mode,
|
||||
already,
|
||||
)
|
||||
|
||||
async def start_pending(self, ids):
|
||||
for id in ids:
|
||||
|
||||
@@ -23,21 +23,21 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.1.2",
|
||||
"@angular/common": "^21.1.2",
|
||||
"@angular/compiler": "^21.1.2",
|
||||
"@angular/core": "^21.1.2",
|
||||
"@angular/forms": "^21.1.2",
|
||||
"@angular/platform-browser": "^21.1.2",
|
||||
"@angular/platform-browser-dynamic": "^21.1.2",
|
||||
"@angular/service-worker": "^21.1.2",
|
||||
"@angular/animations": "^21.1.5",
|
||||
"@angular/common": "^21.1.5",
|
||||
"@angular/compiler": "^21.1.5",
|
||||
"@angular/core": "^21.1.5",
|
||||
"@angular/forms": "^21.1.5",
|
||||
"@angular/platform-browser": "^21.1.5",
|
||||
"@angular/platform-browser-dynamic": "^21.1.5",
|
||||
"@angular/service-worker": "^21.1.5",
|
||||
"@fortawesome/angular-fontawesome": "~4.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.2.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
||||
"@ng-select/ng-select": "^21.2.0",
|
||||
"@ng-select/ng-select": "^21.4.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
"ngx-cookie-service": "^21.1.0",
|
||||
@@ -48,10 +48,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-eslint/builder": "21.1.0",
|
||||
"@angular/build": "^21.1.2",
|
||||
"@angular/cli": "^21.1.2",
|
||||
"@angular/compiler-cli": "^21.1.2",
|
||||
"@angular/localize": "^21.1.2",
|
||||
"@angular/build": "^21.1.4",
|
||||
"@angular/cli": "^21.1.4",
|
||||
"@angular/compiler-cli": "^21.1.5",
|
||||
"@angular/localize": "^21.1.5",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"angular-eslint": "21.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
|
||||
1333
ui/pnpm-lock.yaml
generated
1333
ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -218,6 +218,60 @@
|
||||
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
|
||||
</div>
|
||||
</div>
|
||||
@if (format === 'captions') {
|
||||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Subtitles</span>
|
||||
<select class="form-select"
|
||||
name="subtitleFormat"
|
||||
[(ngModel)]="subtitleFormat"
|
||||
(change)="subtitleFormatChanged()"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
ngbTooltip="Subtitle output format for captions mode">
|
||||
@for (fmt of subtitleFormats; track fmt.id) {
|
||||
<option [ngValue]="fmt.id">{{ fmt.text }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (subtitleFormat === 'txt') {
|
||||
<div class="form-text">TXT is generated from SRT by stripping timestamps and cue numbers.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Language</span>
|
||||
<input class="form-control"
|
||||
type="text"
|
||||
list="subtitleLanguageOptions"
|
||||
name="subtitleLanguage"
|
||||
[(ngModel)]="subtitleLanguage"
|
||||
(change)="subtitleLanguageChanged()"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
placeholder="e.g. en, es, zh-Hans"
|
||||
ngbTooltip="Subtitle language (you can type any language code)">
|
||||
<datalist id="subtitleLanguageOptions">
|
||||
@for (lang of subtitleLanguages; track lang.id) {
|
||||
<option [value]="lang.id">{{ lang.text }}</option>
|
||||
}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Subtitle Source</span>
|
||||
<select class="form-select"
|
||||
name="subtitleMode"
|
||||
[(ngModel)]="subtitleMode"
|
||||
(change)="subtitleModeChanged()"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
ngbTooltip="Choose manual, auto, or fallback preference for captions mode">
|
||||
@for (mode of subtitleModes; track mode.id) {
|
||||
<option [ngValue]="mode.id">{{ mode.text }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="col-12">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-auto">
|
||||
|
||||
@@ -49,6 +49,9 @@ export class App implements AfterViewInit, OnInit {
|
||||
playlistItemLimit!: number;
|
||||
splitByChapters: boolean;
|
||||
chapterTemplate: string;
|
||||
subtitleFormat: string;
|
||||
subtitleLanguage: string;
|
||||
subtitleMode: string;
|
||||
addInProgress = false;
|
||||
themes: Theme[] = Themes;
|
||||
activeTheme: Theme | undefined;
|
||||
@@ -97,6 +100,63 @@ export class App implements AfterViewInit, OnInit {
|
||||
faGithub = faGithub;
|
||||
faClock = faClock;
|
||||
faTachometerAlt = faTachometerAlt;
|
||||
subtitleFormats = [
|
||||
{ id: 'srt', text: 'SRT' },
|
||||
{ id: 'txt', text: 'TXT (Text only)' },
|
||||
{ id: 'vtt', text: 'VTT' },
|
||||
{ id: 'ttml', text: 'TTML' }
|
||||
];
|
||||
subtitleLanguages = [
|
||||
{ id: 'en', text: 'English' },
|
||||
{ id: 'ar', text: 'Arabic' },
|
||||
{ id: 'bn', text: 'Bengali' },
|
||||
{ id: 'bg', text: 'Bulgarian' },
|
||||
{ id: 'ca', text: 'Catalan' },
|
||||
{ id: 'cs', text: 'Czech' },
|
||||
{ id: 'da', text: 'Danish' },
|
||||
{ id: 'nl', text: 'Dutch' },
|
||||
{ id: 'es', text: 'Spanish' },
|
||||
{ id: 'et', text: 'Estonian' },
|
||||
{ id: 'fi', text: 'Finnish' },
|
||||
{ id: 'fr', text: 'French' },
|
||||
{ id: 'de', text: 'German' },
|
||||
{ id: 'el', text: 'Greek' },
|
||||
{ id: 'he', text: 'Hebrew' },
|
||||
{ id: 'hi', text: 'Hindi' },
|
||||
{ id: 'hu', text: 'Hungarian' },
|
||||
{ id: 'id', text: 'Indonesian' },
|
||||
{ id: 'it', text: 'Italian' },
|
||||
{ id: 'lt', text: 'Lithuanian' },
|
||||
{ id: 'lv', text: 'Latvian' },
|
||||
{ id: 'ms', text: 'Malay' },
|
||||
{ id: 'no', text: 'Norwegian' },
|
||||
{ id: 'pl', text: 'Polish' },
|
||||
{ id: 'pt', text: 'Portuguese' },
|
||||
{ id: 'pt-BR', text: 'Portuguese (Brazil)' },
|
||||
{ id: 'ro', text: 'Romanian' },
|
||||
{ id: 'ru', text: 'Russian' },
|
||||
{ id: 'sk', text: 'Slovak' },
|
||||
{ id: 'sl', text: 'Slovenian' },
|
||||
{ id: 'sr', text: 'Serbian' },
|
||||
{ id: 'sv', text: 'Swedish' },
|
||||
{ id: 'ta', text: 'Tamil' },
|
||||
{ id: 'te', text: 'Telugu' },
|
||||
{ id: 'th', text: 'Thai' },
|
||||
{ id: 'tr', text: 'Turkish' },
|
||||
{ id: 'uk', text: 'Ukrainian' },
|
||||
{ id: 'ur', text: 'Urdu' },
|
||||
{ id: 'vi', text: 'Vietnamese' },
|
||||
{ id: 'ja', text: 'Japanese' },
|
||||
{ id: 'ko', text: 'Korean' },
|
||||
{ id: 'zh-Hans', text: 'Chinese (Simplified)' },
|
||||
{ id: 'zh-Hant', text: 'Chinese (Traditional)' },
|
||||
];
|
||||
subtitleModes = [
|
||||
{ id: 'prefer_manual', text: 'Prefer Manual' },
|
||||
{ id: 'prefer_auto', text: 'Prefer Auto' },
|
||||
{ id: 'manual_only', text: 'Manual Only' },
|
||||
{ id: 'auto_only', text: 'Auto Only' },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.format = this.cookieService.get('metube_format') || 'any';
|
||||
@@ -107,6 +167,17 @@ export class App implements AfterViewInit, OnInit {
|
||||
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
|
||||
// Will be set from backend configuration, use empty string as placeholder
|
||||
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
|
||||
this.subtitleFormat = this.cookieService.get('metube_subtitle_format') || 'srt';
|
||||
this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en';
|
||||
this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual';
|
||||
const allowedSubtitleFormats = new Set(this.subtitleFormats.map(fmt => fmt.id));
|
||||
const allowedSubtitleModes = new Set(this.subtitleModes.map(mode => mode.id));
|
||||
if (!allowedSubtitleFormats.has(this.subtitleFormat)) {
|
||||
this.subtitleFormat = 'srt';
|
||||
}
|
||||
if (!allowedSubtitleModes.has(this.subtitleMode)) {
|
||||
this.subtitleMode = 'prefer_manual';
|
||||
}
|
||||
|
||||
this.activeTheme = this.getPreferredTheme(this.cookieService);
|
||||
|
||||
@@ -279,6 +350,18 @@ export class App implements AfterViewInit, OnInit {
|
||||
this.cookieService.set('metube_chapter_template', this.chapterTemplate, { expires: 3650 });
|
||||
}
|
||||
|
||||
subtitleFormatChanged() {
|
||||
this.cookieService.set('metube_subtitle_format', this.subtitleFormat, { expires: 3650 });
|
||||
}
|
||||
|
||||
subtitleLanguageChanged() {
|
||||
this.cookieService.set('metube_subtitle_language', this.subtitleLanguage, { expires: 3650 });
|
||||
}
|
||||
|
||||
subtitleModeChanged() {
|
||||
this.cookieService.set('metube_subtitle_mode', this.subtitleMode, { expires: 3650 });
|
||||
}
|
||||
|
||||
queueSelectionChanged(checked: number) {
|
||||
this.queueDelSelected().nativeElement.disabled = checked == 0;
|
||||
this.queueDownloadSelected().nativeElement.disabled = checked == 0;
|
||||
@@ -299,7 +382,20 @@ export class App implements AfterViewInit, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
addDownload(url?: string, quality?: string, format?: string, folder?: string, customNamePrefix?: string, playlistItemLimit?: number, autoStart?: boolean, splitByChapters?: boolean, chapterTemplate?: string) {
|
||||
addDownload(
|
||||
url?: string,
|
||||
quality?: string,
|
||||
format?: string,
|
||||
folder?: string,
|
||||
customNamePrefix?: string,
|
||||
playlistItemLimit?: number,
|
||||
autoStart?: boolean,
|
||||
splitByChapters?: boolean,
|
||||
chapterTemplate?: string,
|
||||
subtitleFormat?: string,
|
||||
subtitleLanguage?: string,
|
||||
subtitleMode?: string,
|
||||
) {
|
||||
url = url ?? this.addUrl
|
||||
quality = quality ?? this.quality
|
||||
format = format ?? this.format
|
||||
@@ -309,6 +405,9 @@ export class App implements AfterViewInit, OnInit {
|
||||
autoStart = autoStart ?? this.autoStart
|
||||
splitByChapters = splitByChapters ?? this.splitByChapters
|
||||
chapterTemplate = chapterTemplate ?? this.chapterTemplate
|
||||
subtitleFormat = subtitleFormat ?? this.subtitleFormat
|
||||
subtitleLanguage = subtitleLanguage ?? this.subtitleLanguage
|
||||
subtitleMode = subtitleMode ?? this.subtitleMode
|
||||
|
||||
// Validate chapter template if chapter splitting is enabled
|
||||
if (splitByChapters && !chapterTemplate.includes('%(section_number)')) {
|
||||
@@ -316,9 +415,9 @@ export class App implements AfterViewInit, OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('Downloading: url=' + url + ' quality=' + quality + ' format=' + format + ' folder=' + folder + ' customNamePrefix=' + customNamePrefix + ' playlistItemLimit=' + playlistItemLimit + ' autoStart=' + autoStart + ' splitByChapters=' + splitByChapters + ' chapterTemplate=' + chapterTemplate);
|
||||
console.debug('Downloading: url=' + url + ' quality=' + quality + ' format=' + format + ' folder=' + folder + ' customNamePrefix=' + customNamePrefix + ' playlistItemLimit=' + playlistItemLimit + ' autoStart=' + autoStart + ' splitByChapters=' + splitByChapters + ' chapterTemplate=' + chapterTemplate + ' subtitleFormat=' + subtitleFormat + ' subtitleLanguage=' + subtitleLanguage + ' subtitleMode=' + subtitleMode);
|
||||
this.addInProgress = true;
|
||||
this.downloads.add(url, quality, format, folder, customNamePrefix, playlistItemLimit, autoStart, splitByChapters, chapterTemplate).subscribe((status: Status) => {
|
||||
this.downloads.add(url, quality, format, folder, customNamePrefix, playlistItemLimit, autoStart, splitByChapters, chapterTemplate, subtitleFormat, subtitleLanguage, subtitleMode).subscribe((status: Status) => {
|
||||
if (status.status === 'error') {
|
||||
alert(`Error adding URL: ${status.msg}`);
|
||||
} else {
|
||||
@@ -333,7 +432,20 @@ export class App implements AfterViewInit, OnInit {
|
||||
}
|
||||
|
||||
retryDownload(key: string, download: Download) {
|
||||
this.addDownload(download.url, download.quality, download.format, download.folder, download.custom_name_prefix, download.playlist_item_limit, true, download.split_by_chapters, download.chapter_template);
|
||||
this.addDownload(
|
||||
download.url,
|
||||
download.quality,
|
||||
download.format,
|
||||
download.folder,
|
||||
download.custom_name_prefix,
|
||||
download.playlist_item_limit,
|
||||
true,
|
||||
download.split_by_chapters,
|
||||
download.chapter_template,
|
||||
download.subtitle_format,
|
||||
download.subtitle_language,
|
||||
download.subtitle_mode,
|
||||
);
|
||||
this.downloads.delById('done', [key]).subscribe();
|
||||
}
|
||||
|
||||
@@ -479,7 +591,8 @@ export class App implements AfterViewInit, OnInit {
|
||||
this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`;
|
||||
// Now pass the selected quality, format, folder, etc. to the add() method
|
||||
this.downloads.add(url, this.quality, this.format, this.folder, this.customNamePrefix,
|
||||
this.playlistItemLimit, this.autoStart, this.splitByChapters, this.chapterTemplate)
|
||||
this.playlistItemLimit, this.autoStart, this.splitByChapters, this.chapterTemplate,
|
||||
this.subtitleFormat, this.subtitleLanguage, this.subtitleMode)
|
||||
.subscribe({
|
||||
next: (status: Status) => {
|
||||
if (status.status === 'error') {
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface Download {
|
||||
playlist_item_limit: number;
|
||||
split_by_chapters?: boolean;
|
||||
chapter_template?: string;
|
||||
subtitle_format?: string;
|
||||
subtitle_language?: string;
|
||||
subtitle_mode?: string;
|
||||
status: string;
|
||||
msg: string;
|
||||
percent: number;
|
||||
|
||||
@@ -73,4 +73,9 @@ export const Formats: Format[] = [
|
||||
text: 'Thumbnail',
|
||||
qualities: [{ id: 'best', text: 'Best' }],
|
||||
},
|
||||
{
|
||||
id: 'captions',
|
||||
text: 'Captions',
|
||||
qualities: [{ id: 'best', text: 'Best' }],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -107,8 +107,34 @@ export class DownloadsService {
|
||||
return of({status: 'error', msg: msg})
|
||||
}
|
||||
|
||||
public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, playlistItemLimit: number, autoStart: boolean, splitByChapters: boolean, chapterTemplate: string) {
|
||||
return this.http.post<Status>('add', { url: url, quality: quality, format: format, folder: folder, custom_name_prefix: customNamePrefix, playlist_item_limit: playlistItemLimit, auto_start: autoStart, split_by_chapters: splitByChapters, chapter_template: chapterTemplate }).pipe(
|
||||
public add(
|
||||
url: string,
|
||||
quality: string,
|
||||
format: string,
|
||||
folder: string,
|
||||
customNamePrefix: string,
|
||||
playlistItemLimit: number,
|
||||
autoStart: boolean,
|
||||
splitByChapters: boolean,
|
||||
chapterTemplate: string,
|
||||
subtitleFormat: string,
|
||||
subtitleLanguage: string,
|
||||
subtitleMode: string,
|
||||
) {
|
||||
return this.http.post<Status>('add', {
|
||||
url: url,
|
||||
quality: quality,
|
||||
format: format,
|
||||
folder: folder,
|
||||
custom_name_prefix: customNamePrefix,
|
||||
playlist_item_limit: playlistItemLimit,
|
||||
auto_start: autoStart,
|
||||
split_by_chapters: splitByChapters,
|
||||
chapter_template: chapterTemplate,
|
||||
subtitle_format: subtitleFormat,
|
||||
subtitle_language: subtitleLanguage,
|
||||
subtitle_mode: subtitleMode
|
||||
}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
@@ -154,9 +180,25 @@ export class DownloadsService {
|
||||
const defaultAutoStart = true;
|
||||
const defaultSplitByChapters = false;
|
||||
const defaultChapterTemplate = this.configuration['OUTPUT_TEMPLATE_CHAPTER'];
|
||||
const defaultSubtitleFormat = 'srt';
|
||||
const defaultSubtitleLanguage = 'en';
|
||||
const defaultSubtitleMode = 'prefer_manual';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.add(url, defaultQuality, defaultFormat, defaultFolder, defaultCustomNamePrefix, defaultPlaylistItemLimit, defaultAutoStart, defaultSplitByChapters, defaultChapterTemplate)
|
||||
this.add(
|
||||
url,
|
||||
defaultQuality,
|
||||
defaultFormat,
|
||||
defaultFolder,
|
||||
defaultCustomNamePrefix,
|
||||
defaultPlaylistItemLimit,
|
||||
defaultAutoStart,
|
||||
defaultSplitByChapters,
|
||||
defaultChapterTemplate,
|
||||
defaultSubtitleFormat,
|
||||
defaultSubtitleLanguage,
|
||||
defaultSubtitleMode,
|
||||
)
|
||||
.subscribe({
|
||||
next: (response) => resolve(response),
|
||||
error: (error) => reject(error)
|
||||
|
||||
50
uv.lock
generated
50
uv.lock
generated
@@ -105,11 +105,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "astroid"
|
||||
version = "4.0.3"
|
||||
version = "4.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/c17d0f83016532a1ad87d1de96837164c99d47a3b6bbba28bd597c25b37a/astroid-4.0.3.tar.gz", hash = "sha256:08d1de40d251cc3dc4a7a12726721d475ac189e4e583d596ece7422bc176bda3", size = 406224, upload-time = "2026-01-03T22:14:26.096Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/66/686ac4fc6ef48f5bacde625adac698f41d5316a9753c2b20bb0931c9d4e2/astroid-4.0.3-py3-none-any.whl", hash = "sha256:864a0a34af1bd70e1049ba1e61cee843a7252c826d97825fcee9b2fcbd9e1b14", size = 276443, upload-time = "2026-01-03T22:14:24.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -303,15 +303,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "deno"
|
||||
version = "2.6.8"
|
||||
version = "2.6.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/5c/ba3171b42741d1ede4517f73b2a13f07a1828b5a616b086111a01821b0ce/deno-2.6.8.tar.gz", hash = "sha256:dd9e432c67f3d2f1a110d898612dccd7efc51a9486f8466a5fc482b8212d8e19", size = 8129, upload-time = "2026-02-02T18:06:29.122Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/a8/74db2941f56186028b6e91c6116a3e52c8459dfa28a5f990f578be2b69eb/deno-2.6.10.tar.gz", hash = "sha256:41c12a75197da6d9db20120eee7585c27766af0ac62c817c085c93dfc4081428", size = 8128, upload-time = "2026-02-17T16:09:30.841Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/9a/43c7bb102fe58b1318abe00b557c3dfa2f9a9d79f0cd3d9028b6bf8b9637/deno-2.6.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:078ef3d2a30bcaba91e246f730baddebf1e0e5249adfbfa1b0e169118a41faa5", size = 44303226, upload-time = "2026-02-02T18:06:13.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/65/fc7c99a0f60fe617eb5a533e37a1cd777f81c03b06b6766d0f52c92e94a8/deno-2.6.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0c18549037ef7bc856cf3889df424f43810a0b830c0ffc799d8d951a4da5a033", size = 41472003, upload-time = "2026-02-02T18:06:16.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/4b/617ba0f2e8523c2145e612f4ef610afa231186ba1a860ce2c92c382c3e46/deno-2.6.8-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:332e33ca62bd416cea002a1e50af1c2843d84771e015bfffecdc0d44342eb0eb", size = 45335676, upload-time = "2026-02-02T18:06:19.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/18/5b0af1fb4f52775b58fae4b6a7f0e12f45cf1435e63a0f6af1a8a87d3595/deno-2.6.8-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:2c31bda6da38f9130ef796a9298642b2ca5525420033c74c8b6bbac9d1ab35b0", size = 47240835, upload-time = "2026-02-02T18:06:22.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/e8/defc9de7c0991bfb3c6d3e2a6a5d344fd0022ebe114743af483ff718609b/deno-2.6.8-py3-none-win_amd64.whl", hash = "sha256:dc7d517d8cd0cf43d55e3c8dbd2bbd911dc875cfc116982d882c9aa22f48863f", size = 46136023, upload-time = "2026-02-02T18:06:26.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/2f/030638bfabbd63df9562e3900b791d6c07e5a30346d359cf45a5e1fc4097/deno-2.6.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c381b068b6f58ca1c19a0cf062c8489ee1c7fe430d3ebc48db944f5c80beb2c2", size = 45118320, upload-time = "2026-02-17T16:09:16.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/dc/0f71ff9dd513c8cc094dc76640f80088a27ea9a3b1fc2decd60a8a27148a/deno-2.6.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2da72aa734463600a5a16efd786a4aecd22ddcd6ea907f56fca15f9eb3ca794", size = 42060974, upload-time = "2026-02-17T16:09:19.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/f7/3774540e14111026251aef134bf3de3ae1c0591866f05ce5debc97248127/deno-2.6.10-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b98d6fc98a9cbc10219f6c6d6f2a6e10d1954416bbcd4371d317fc9c20050576", size = 45789105, upload-time = "2026-02-17T16:09:22.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/80/a2b5827f4715f0bcb5a550728ac98a32258f31e2a6f1c63803a078a425b0/deno-2.6.10-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:d1600f4ee7b4a9699b7057841bdb5d7b7eac3fb073df072540064166841c2f4c", size = 47737107, upload-time = "2026-02-17T16:09:25.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/60/75fbdc63d97f381182be1eeebe4bd1630d9819d05ae7c20143f700856a3a/deno-2.6.10-py3-none-win_amd64.whl", hash = "sha256:dc0b6b7a3e558b159c6e599eab6556766c96fe1d75d59383ce291ebf0083fcfd", size = 47088397, upload-time = "2026-02-17T16:09:28.571Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -555,11 +555,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.5.1"
|
||||
version = "4.9.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -690,27 +690,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-engineio"
|
||||
version = "4.13.0"
|
||||
version = "4.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "simple-websocket" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/5a/349caac055e03ef9e56ed29fa304846063b1771ee54ab8132bf98b29491e/python_engineio-4.13.0.tar.gz", hash = "sha256:f9c51a8754d2742ba832c24b46ed425fdd3064356914edd5a1e8ffde76ab7709", size = 92194, upload-time = "2025-12-24T22:38:05.111Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348, upload-time = "2026-02-06T23:38:06.12Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/74/c655a6eda0fd188d490c14142a0f0380655ac7099604e1fbf8fa1a97f0a1/python_engineio-4.13.0-py3-none-any.whl", hash = "sha256:57b94eac094fa07b050c6da59f48b12250ab1cd920765f4849963e3d89ad9de3", size = 59676, upload-time = "2025-12-24T22:38:03.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-socketio"
|
||||
version = "5.16.0"
|
||||
version = "5.16.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bidict" },
|
||||
{ name = "python-engineio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/55/5d8af5884283b58e4405580bcd84af1d898c457173c708736e065f10ca4a/python_socketio-5.16.0.tar.gz", hash = "sha256:f79403c7f1ba8b84460aa8fe4c671414c8145b21a501b46b676f3740286356fd", size = 127120, upload-time = "2025-12-24T23:51:48.826Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508, upload-time = "2026-02-06T23:42:07Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/d2/2ccc2b69a187b80fda3152745670cfba936704f296a9fa54c6c8ac694d12/python_socketio-5.16.0-py3-none-any.whl", hash = "sha256:d95802961e15c7bd54ecf884c6e7644f81be8460f0a02ee66b473df58088ee8a", size = 79607, upload-time = "2025-12-24T23:51:47.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -943,11 +943,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "yt-dlp"
|
||||
version = "2026.2.4"
|
||||
version = "2026.2.21"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/be/8e099f3f34bac6851490525fb1a8b62d525a95fcb5af082e8c52ba884fb5/yt_dlp-2026.2.4.tar.gz", hash = "sha256:24733ef081116f29d8ee6eae7a48127101e6c56eb7aa228dd604a60654760022", size = 3100305, upload-time = "2026-02-04T00:49:27.043Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/55ffff25204733e94a507552ad984d5a8a8e4f9d1f0d91763e6b1a41c79b/yt_dlp-2026.2.21.tar.gz", hash = "sha256:4407dfc1a71fec0dee5ef916a8d4b66057812939b509ae45451fa8fb4376b539", size = 3116630, upload-time = "2026-02-21T20:40:53.522Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/96/38/b17cbeaf6712a4c1b97f7f9ec3a55f3a8ddee678cc88742af47dca0315b7/yt_dlp-2026.2.4-py3-none-any.whl", hash = "sha256:d6ea83257e8127a0097b1d37ee36201f99a292067e4616b2e5d51ab153b3dbb9", size = 3299165, upload-time = "2026-02-04T00:49:25.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/40/664c99ee36d80d84ce7a96cd98aebcb3d16c19e6c3ad3461d2cf5424040e/yt_dlp-2026.2.21-py3-none-any.whl", hash = "sha256:0d8408f5b6d20487f5caeb946dfd04f9bcd2f1a3a125b744a0a982b590e449f7", size = 3313392, upload-time = "2026-02-21T20:40:51.514Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -971,9 +971,9 @@ deno = [
|
||||
|
||||
[[package]]
|
||||
name = "yt-dlp-ejs"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/80/4b6c7f91b373e01cdc18080f41fa399592945abce7db74c2e6d0fb8468db/yt_dlp_ejs-0.4.0.tar.gz", hash = "sha256:3c67e0beb6f9f3603fbcb56f425eabaa37c52243d90d20ccbcce1dd941cfbd07", size = 96768, upload-time = "2026-01-29T16:25:59.964Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/90/8911146822364666be47f184c4180cec20fcc537a268ef40d1ab077dd25b/yt_dlp_ejs-0.4.0-py3-none-any.whl", hash = "sha256:19278cff397b243074df46342bb7616c404296aeaff01986b62b4e21823b0b9c", size = 53600, upload-time = "2026-01-29T16:25:57.87Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user