Merge pull request #904 from vitaliibudnyi/feat/captions-format

Captions extraction feature with advanced options
This commit is contained in:
Alex
2026-02-27 12:59:46 +02:00
committed by GitHub
8 changed files with 530 additions and 21 deletions

View File

@@ -1,6 +1,17 @@
import copy import copy
AUDIO_FORMATS = ("m4a", "mp3", "opus", "wav", "flac") 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: 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 # Quality is irrelevant in this case since we skip the download
return "bestaudio/best" 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: if format in AUDIO_FORMATS:
# Audio quality needs to be set post-download, set in opts # Audio quality needs to be set post-download, set in opts
return f"bestaudio[ext={format}]/bestaudio/best" 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}") 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 Returns extra download options
Mostly postprocessing 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"} {"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"] = postprocessors + (
opts["postprocessors"] if "postprocessors" in opts else [] opts["postprocessors"] if "postprocessors" in opts else []
) )

View File

@@ -156,6 +156,9 @@ app = web.Application()
sio = socketio.AsyncServer(cors_allowed_origins='*') sio = socketio.AsyncServer(cors_allowed_origins='*')
sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io') sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io')
routes = web.RouteTableDef() 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): class Notifier(DownloadQueueNotifier):
async def added(self, dl): async def added(self, dl):
@@ -247,6 +250,9 @@ async def add(request):
auto_start = post.get('auto_start') auto_start = post.get('auto_start')
split_by_chapters = post.get('split_by_chapters') split_by_chapters = post.get('split_by_chapters')
chapter_template = post.get('chapter_template') 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: if custom_name_prefix is None:
custom_name_prefix = '' custom_name_prefix = ''
@@ -260,12 +266,40 @@ async def add(request):
split_by_chapters = False split_by_chapters = False
if chapter_template is None: if chapter_template is None:
chapter_template = config.OUTPUT_TEMPLATE_CHAPTER 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('\\')): 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') 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) 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)) return web.Response(text=serializer.encode(status))
@routes.post(config.URL_PREFIX + 'delete') @routes.post(config.URL_PREFIX + 'delete')

View File

@@ -69,6 +69,45 @@ def _convert_generators_to_lists(obj):
else: else:
return obj 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: class DownloadQueueNotifier:
async def added(self, dl): async def added(self, dl):
raise NotImplementedError raise NotImplementedError
@@ -86,7 +125,24 @@ class DownloadQueueNotifier:
raise NotImplementedError raise NotImplementedError
class DownloadInfo: 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.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.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
self.url = url self.url = url
@@ -104,6 +160,10 @@ class DownloadInfo:
self.playlist_item_limit = playlist_item_limit self.playlist_item_limit = playlist_item_limit
self.split_by_chapters = split_by_chapters self.split_by_chapters = split_by_chapters
self.chapter_template = chapter_template 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: class Download:
manager = None manager = None
@@ -113,11 +173,18 @@ class Download:
self.temp_dir = temp_dir self.temp_dir = temp_dir
self.output_template = output_template self.output_template = output_template
self.output_template_chapter = output_template_chapter self.output_template_chapter = output_template_chapter
self.info = info
self.format = get_format(format, quality) 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: if "impersonate" in self.ytdl_opts:
self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"]) self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"])
self.info = info
self.canceled = False self.canceled = False
self.tmpfilename = None self.tmpfilename = None
self.status_queue = None self.status_queue = None
@@ -160,6 +227,14 @@ class Download:
else: else:
filename = filepath filename = filepath
self.status_queue.put({'status': 'finished', 'filename': filename}) 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 # Capture all chapter files when SplitChapters finishes
elif d.get('postprocessor') == 'SplitChapters' and d.get('status') == 'finished': elif d.get('postprocessor') == 'SplitChapters' and d.get('status') == 'finished':
@@ -260,7 +335,15 @@ class Download:
self.tmpfilename = status.get('tmpfilename') self.tmpfilename = status.get('tmpfilename')
if 'filename' in status: if 'filename' in status:
fileName = status.get('filename') 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 self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
if self.info.format == 'thumbnail': if self.info.format == 'thumbnail':
self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename) self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename)
@@ -280,6 +363,37 @@ class Download:
# Skip the rest of status processing for chapter files # Skip the rest of status processing for chapter files
continue 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.status = status['status']
self.info.msg = status.get('msg') self.info.msg = status.get('msg')
if 'downloaded_bytes' in status: if 'downloaded_bytes' in status:
@@ -526,7 +640,22 @@ class DownloadQueue:
self.pending.put(download) self.pending.put(download)
await self.notifier.added(dl) 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: if not entry:
return {'status': 'error', 'msg': "Invalid/empty data was given."} return {'status': 'error', 'msg': "Invalid/empty data was given."}
@@ -542,7 +671,21 @@ class DownloadQueue:
if etype.startswith('url'): if etype.startswith('url'):
log.debug('Processing as a 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': elif etype == 'playlist' or etype == 'channel':
log.debug(f'Processing as a {etype}') log.debug(f'Processing as a {etype}')
entries = entry['entries'] entries = entry['entries']
@@ -562,7 +705,23 @@ class DownloadQueue:
for property in ("id", "title", "uploader", "uploader_id"): for property in ("id", "title", "uploader", "uploader_id"):
if property in entry: if property in entry:
etr[f"{etype}_{property}"] = entry[property] 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): 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': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}
return {'status': 'ok'} return {'status': 'ok'}
@@ -570,13 +729,48 @@ class DownloadQueue:
log.debug('Processing as a video') log.debug('Processing as a video')
key = entry.get('webpage_url') or entry['url'] key = entry.get('webpage_url') or entry['url']
if not self.queue.exists(key): 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) await self.__add_download(dl, auto_start)
return {'status': 'ok'} return {'status': 'ok'}
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'} 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): async def add(
log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=}') 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 already = set() if already is None else already
if url in already: if url in already:
log.info('recursion detected, skipping') log.info('recursion detected, skipping')
@@ -587,7 +781,21 @@ class DownloadQueue:
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url) entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url)
except yt_dlp.utils.YoutubeDLError as exc: except yt_dlp.utils.YoutubeDLError as exc:
return {'status': 'error', 'msg': str(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): async def start_pending(self, ids):
for id in ids: for id in ids:

View File

@@ -218,6 +218,60 @@
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)"> ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
</div> </div>
</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="col-12">
<div class="row g-2 align-items-center"> <div class="row g-2 align-items-center">
<div class="col-auto"> <div class="col-auto">

View File

@@ -49,6 +49,9 @@ export class App implements AfterViewInit, OnInit {
playlistItemLimit!: number; playlistItemLimit!: number;
splitByChapters: boolean; splitByChapters: boolean;
chapterTemplate: string; chapterTemplate: string;
subtitleFormat: string;
subtitleLanguage: string;
subtitleMode: string;
addInProgress = false; addInProgress = false;
themes: Theme[] = Themes; themes: Theme[] = Themes;
activeTheme: Theme | undefined; activeTheme: Theme | undefined;
@@ -97,6 +100,63 @@ export class App implements AfterViewInit, OnInit {
faGithub = faGithub; faGithub = faGithub;
faClock = faClock; faClock = faClock;
faTachometerAlt = faTachometerAlt; 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() { constructor() {
this.format = this.cookieService.get('metube_format') || 'any'; 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'; this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
// Will be set from backend configuration, use empty string as placeholder // Will be set from backend configuration, use empty string as placeholder
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || ''; 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); 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 }); 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) { queueSelectionChanged(checked: number) {
this.queueDelSelected().nativeElement.disabled = checked == 0; this.queueDelSelected().nativeElement.disabled = checked == 0;
this.queueDownloadSelected().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 url = url ?? this.addUrl
quality = quality ?? this.quality quality = quality ?? this.quality
format = format ?? this.format format = format ?? this.format
@@ -309,6 +405,9 @@ export class App implements AfterViewInit, OnInit {
autoStart = autoStart ?? this.autoStart autoStart = autoStart ?? this.autoStart
splitByChapters = splitByChapters ?? this.splitByChapters splitByChapters = splitByChapters ?? this.splitByChapters
chapterTemplate = chapterTemplate ?? this.chapterTemplate chapterTemplate = chapterTemplate ?? this.chapterTemplate
subtitleFormat = subtitleFormat ?? this.subtitleFormat
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 (splitByChapters && !chapterTemplate.includes('%(section_number)')) {
@@ -316,9 +415,9 @@ export class App implements AfterViewInit, OnInit {
return; 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.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') { if (status.status === 'error') {
alert(`Error adding URL: ${status.msg}`); alert(`Error adding URL: ${status.msg}`);
} else { } else {
@@ -333,7 +432,20 @@ export class App implements AfterViewInit, OnInit {
} }
retryDownload(key: string, download: Download) { 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(); 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}`; this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`;
// Now pass the selected quality, format, folder, etc. to the add() method // 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.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({ .subscribe({
next: (status: Status) => { next: (status: Status) => {
if (status.status === 'error') { if (status.status === 'error') {

View File

@@ -10,6 +10,9 @@ export interface Download {
playlist_item_limit: number; playlist_item_limit: number;
split_by_chapters?: boolean; split_by_chapters?: boolean;
chapter_template?: string; chapter_template?: string;
subtitle_format?: string;
subtitle_language?: string;
subtitle_mode?: string;
status: string; status: string;
msg: string; msg: string;
percent: number; percent: number;

View File

@@ -73,4 +73,9 @@ export const Formats: Format[] = [
text: 'Thumbnail', text: 'Thumbnail',
qualities: [{ id: 'best', text: 'Best' }], qualities: [{ id: 'best', text: 'Best' }],
}, },
{
id: 'captions',
text: 'Captions',
qualities: [{ id: 'best', text: 'Best' }],
},
]; ];

View File

@@ -107,8 +107,34 @@ export class DownloadsService {
return of({status: 'error', msg: msg}) 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) { public add(
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( 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) catchError(this.handleHTTPError)
); );
} }
@@ -154,9 +180,25 @@ export class DownloadsService {
const defaultAutoStart = true; const defaultAutoStart = true;
const defaultSplitByChapters = false; const defaultSplitByChapters = false;
const defaultChapterTemplate = this.configuration['OUTPUT_TEMPLATE_CHAPTER']; const defaultChapterTemplate = this.configuration['OUTPUT_TEMPLATE_CHAPTER'];
const defaultSubtitleFormat = 'srt';
const defaultSubtitleLanguage = 'en';
const defaultSubtitleMode = 'prefer_manual';
return new Promise((resolve, reject) => { 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({ .subscribe({
next: (response) => resolve(response), next: (response) => resolve(response),
error: (error) => reject(error) error: (error) => reject(error)