feat: support using output template for metadata generation. Closes #539

This commit is contained in:
arabcoders 2026-01-08 17:03:22 +03:00
parent 4a3f0316ea
commit cf2ee306d2
5 changed files with 281 additions and 41 deletions

View file

@ -77,6 +77,9 @@ class Task:
if self.cli: if self.cli:
params = params.add_cli(self.cli, from_user=True) params = params.add_cli(self.cli, from_user=True)
if self.template:
params = params.add({"outtmpl": {"default": self.template}}, from_user=False)
return params return params
def mark(self) -> tuple[bool, str]: def mark(self) -> tuple[bool, str]:
@ -140,7 +143,9 @@ class Task:
params = params.get_all() params = params.get_all()
ie_info: dict | None = extract_info(params, self.url, no_archive=True, follow_redirect=False, cache=True) ie_info: dict | None = extract_info(
params, self.url, no_archive=True, follow_redirect=False, sanitize_info=True
)
if not ie_info or not isinstance(ie_info, dict): if not ie_info or not isinstance(ie_info, dict):
return ({}, False, "Failed to extract information from URL.") return ({}, False, "Failed to extract information from URL.")

View file

@ -65,7 +65,7 @@ REMOVE_KEYS: list = [
] ]
"Keys to remove from yt-dlp options at various levels." "Keys to remove from yt-dlp options at various levels."
YTDLP_INFO_CLS: YTDLP = None YTDLP_INFO_CLS: YTDLP | None = None
"Cached YTDLP info class." "Cached YTDLP info class."
ALLOWED_SUBS_EXTENSIONS: set[str] = {".srt", ".vtt", ".ass"} ALLOWED_SUBS_EXTENSIONS: set[str] = {".srt", ".vtt", ".ass"}
@ -96,6 +96,32 @@ class FileLogFormatter(logging.Formatter):
return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds") return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds")
def get_static_ytdlp(reload: bool = False) -> YTDLP:
"""
Get a static YTDLP instance for info extraction.
Args:
reload (bool): If True, forces re-creation of the instance.
Returns:
YTDLP: A static YTDLP instance.
"""
global YTDLP_INFO_CLS # noqa: PLW0603
if YTDLP_INFO_CLS is None or reload:
YTDLP_INFO_CLS = YTDLP(
params={
"color": "no_color",
"extract_flat": True,
"skip_download": True,
"ignoreerrors": True,
"ignore_no_formats_error": True,
"quiet": True,
}
)
return YTDLP_INFO_CLS
def patch_metadataparser() -> None: def patch_metadataparser() -> None:
""" """
Patches yt_dlp MetadataParserPP action to handle subprocess pickling issues. Patches yt_dlp MetadataParserPP action to handle subprocess pickling issues.
@ -382,7 +408,7 @@ def extract_info(
if not data["is_premiere"]: if not data["is_premiere"]:
data["is_premiere"] = "video" == data.get("media_type") and "is_upcoming" == data.get("live_status") data["is_premiere"] = "video" == data.get("media_type") and "is_upcoming" == data.get("live_status")
return YTDLP.sanitize_info(data) if sanitize_info else data return YTDLP.sanitize_info(data, remove_private_keys=True) if sanitize_info else data
def _is_safe_key(key: any) -> bool: def _is_safe_key(key: any) -> bool:
@ -1406,27 +1432,13 @@ def get_archive_id(url: str) -> dict[str, str | None]:
} }
""" """
global YTDLP_INFO_CLS # noqa: PLW0603
idDict: dict[str, None] = { idDict: dict[str, None] = {
"id": None, "id": None,
"ie_key": None, "ie_key": None,
"archive_id": None, "archive_id": None,
} }
if YTDLP_INFO_CLS is None: for key, _ie in get_static_ytdlp()._ies.items():
YTDLP_INFO_CLS = YTDLP(
params={
"color": "no_color",
"extract_flat": True,
"skip_download": True,
"ignoreerrors": True,
"ignore_no_formats_error": True,
"quiet": True,
}
)
for key, _ie in YTDLP_INFO_CLS._ies.items():
try: try:
if not _ie.suitable(url): if not _ie.suitable(url):
continue continue
@ -1923,3 +1935,18 @@ def get_extras(entry: dict, kind: str = "video") -> dict:
extras["thumbnail"] = "https://img.youtube.com/vi/{id}/maxresdefault.jpg".format(**entry) extras["thumbnail"] = "https://img.youtube.com/vi/{id}/maxresdefault.jpg".format(**entry)
return extras return extras
def parse_outtmpl(output_template: str, info_dict: dict) -> str:
"""
Parse yt-dlp output template with given info_dict.
Args:
output_template (str): The output template string.
info_dict (dict): The info dictionary from yt-dlp.
Returns:
str: The parsed output string.
"""
return get_static_ytdlp().prepare_filename(info_dict=info_dict, outtmpl=output_template)

View file

@ -1,3 +1,5 @@
import asyncio
import functools
import logging import logging
import uuid import uuid
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@ -10,7 +12,7 @@ from app.library.config import Config
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.router import route from app.library.router import route
from app.library.Tasks import Task, TaskFailure, TaskResult, Tasks from app.library.Tasks import Task, TaskFailure, TaskResult, Tasks
from app.library.Utils import get_channel_images, get_file, init_class, validate_url, validate_uuid from app.library.Utils import get_channel_images, get_file, init_class, parse_outtmpl, validate_url, validate_uuid
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@ -252,10 +254,38 @@ async def task_metadata(request: Request, config: Config, encoder: Encoder) -> R
if not save_path.exists(): if not save_path.exists():
save_path.mkdir(parents=True, exist_ok=True) save_path.mkdir(parents=True, exist_ok=True)
metadata, status, message = task.fetch_metadata(full=False) metadata, status, message = await asyncio.wait_for(
fut=asyncio.get_running_loop().run_in_executor(
None,
functools.partial(task.fetch_metadata, full=False),
),
timeout=120,
)
if not status: if not status:
return web.json_response(data={"error": message}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": message}, status=web.HTTPBadRequest.status_code)
if not task.folder:
try:
outtmpl = parse_outtmpl(
output_template=task.get_ytdlp_opts().get_all().get("outtmpl", {}).get("default", "{title} [{id}]"),
info_dict=metadata,
)
if outtmpl:
_path = save_path / outtmpl
if not _path.is_dir():
_path = _path.parent
(save_path, _) = get_file(config.download_path, _path.relative_to(config.download_path))
if not str(save_path or "").startswith(str(config.download_path)):
return web.json_response(
data={"error": "Invalid final path folder."}, status=web.HTTPBadRequest.status_code
)
if not save_path.exists():
save_path.mkdir(parents=True, exist_ok=True)
except Exception as e:
LOG.warning(f"Failed to resolve final path from outtmpl. '{e!s}'")
info = { info = {
"id": ag(metadata, ["id", "channel_id"]), "id": ag(metadata, ["id", "channel_id"]),
"id_type": metadata.get("extractor", "").split(":")[0].lower() if metadata.get("extractor") else None, "id_type": metadata.get("extractor", "").split(":")[0].lower() if metadata.get("extractor") else None,
@ -277,12 +307,12 @@ async def task_metadata(request: Request, config: Config, encoder: Encoder) -> R
from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP
title: str = sanitize_filename(info.get("title")) title: str = sanitize_filename(info.get("title"))
filename: Path = save_path / f"{title} [{info.get('id')}].info.json" info_file: Path = save_path / f"{title} [{info.get('id')}].info.json"
filename.write_text(encoder.encode(metadata), encoding="utf-8") info_file.write_text(encoder.encode(metadata), encoding="utf-8")
info["json_file"] = str(filename.relative_to(config.download_path)) info["json_file"] = str(info_file.relative_to(config.download_path))
filename = save_path / "tvshow.nfo" xml_file: Path = save_path / "tvshow.nfo"
info["nfo_file"] = f"{save_path}/{filename}" info["nfo_file"] = str(xml_file.relative_to(config.download_path))
xml_content = "<tvshow>\n" xml_content = "<tvshow>\n"
xml_content += f" <title>{NFOMakerPP._escape_text(info.get('title'))}</title>\n" xml_content += f" <title>{NFOMakerPP._escape_text(info.get('title'))}</title>\n"
@ -303,8 +333,7 @@ async def task_metadata(request: Request, config: Config, encoder: Encoder) -> R
xml_content += f" <year>{info.get('year')}</year>\n" xml_content += f" <year>{info.get('year')}</year>\n"
xml_content += " <status>Continuing</status>\n" xml_content += " <status>Continuing</status>\n"
xml_content += "</tvshow>\n" xml_content += "</tvshow>\n"
xml_file.write_text(xml_content, encoding="utf-8")
filename.write_text(xml_content, encoding="utf-8")
try: try:
from yt_dlp.utils.networking import random_user_agent from yt_dlp.utils.networking import random_user_agent
@ -348,9 +377,9 @@ async def task_metadata(request: Request, config: Config, encoder: Encoder) -> R
resp = await client.request(method="GET", url=url, follow_redirects=True) resp = await client.request(method="GET", url=url, follow_redirects=True)
filename = save_path / f"{key}.jpg" img_file = save_path / f"{key}.jpg"
filename.write_bytes(resp.content) img_file.write_bytes(resp.content)
info["thumbnails"][key] = str(filename.relative_to(config.download_path)) info["thumbnails"][key] = str(img_file.relative_to(config.download_path))
except Exception as e: except Exception as e:
LOG.warning(f"Failed to fetch thumbnail '{key}' from '{url}'. '{e!s}'") LOG.warning(f"Failed to fetch thumbnail '{key}' from '{url}'. '{e!s}'")
continue continue

View file

@ -34,6 +34,7 @@ from app.library.Utils import (
get_files, get_files,
get_mime_type, get_mime_type,
get_possible_images, get_possible_images,
get_static_ytdlp,
init_class, init_class,
is_private_address, is_private_address,
list_folders, list_folders,
@ -41,6 +42,7 @@ from app.library.Utils import (
load_modules, load_modules,
merge_dict, merge_dict,
move_file, move_file,
parse_outtmpl,
parse_tags, parse_tags,
read_logfile, read_logfile,
rename_file, rename_file,
@ -2675,3 +2677,180 @@ class TestGetExtras:
result = get_extras(entry) result = get_extras(entry)
assert result["thumbnail"] == "https://example.com/thumb.jpg" assert result["thumbnail"] == "https://example.com/thumb.jpg"
class TestGetStaticYtdlp:
"""Test the get_static_ytdlp function."""
def test_get_static_ytdlp_returns_instance(self):
"""Test that get_static_ytdlp returns a YTDLP instance."""
from app.library.Utils import get_static_ytdlp
from app.library.ytdlp import YTDLP
# Force reload to ensure we get a real instance, not a mock
instance = get_static_ytdlp(reload=True)
assert instance is not None
assert isinstance(instance, YTDLP)
def test_get_static_ytdlp_returns_same_instance(self):
"""Test that get_static_ytdlp returns the same cached instance."""
from app.library.Utils import get_static_ytdlp
instance1 = get_static_ytdlp()
instance2 = get_static_ytdlp()
assert instance1 is instance2
def test_get_static_ytdlp_reload(self):
"""Test that get_static_ytdlp can reload and return a new instance."""
from app.library.Utils import get_static_ytdlp
instance1 = get_static_ytdlp()
instance2 = get_static_ytdlp(reload=True)
assert instance1 is not instance2
assert instance2 is not None
def test_get_static_ytdlp_has_correct_params(self):
"""Test that get_static_ytdlp initializes with correct parameters."""
from app.library.Utils import get_static_ytdlp
instance = get_static_ytdlp(reload=True)
# Access the internal params
params = instance.params
assert params.get("color") == "no_color"
assert params.get("extract_flat") is True
assert params.get("skip_download") is True
assert params.get("ignoreerrors") is True
assert params.get("ignore_no_formats_error") is True
assert params.get("quiet") is True
class TestParseOuttmpl:
"""Test the parse_outtmpl function."""
def test_parse_outtmpl_basic(self):
"""Test basic template parsing with simple placeholders."""
from app.library.Utils import parse_outtmpl
template = "%(title)s.%(ext)s"
info_dict = {
"title": "Test Video",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert result == "Test Video.mp4"
def test_parse_outtmpl_with_id(self):
"""Test template parsing with video ID."""
from app.library.Utils import parse_outtmpl
template = "[%(id)s] %(title)s.%(ext)s"
info_dict = {
"id": "dQw4w9WgXcQ",
"title": "Never Gonna Give You Up",
"ext": "webm",
}
result = parse_outtmpl(template, info_dict)
assert result == "[dQw4w9WgXcQ] Never Gonna Give You Up.webm"
def test_parse_outtmpl_with_uploader(self):
"""Test template parsing with uploader information."""
from app.library.Utils import parse_outtmpl
template = "%(uploader)s - %(title)s.%(ext)s"
info_dict = {
"uploader": "Rick Astley",
"title": "Never Gonna Give You Up",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert result == "Rick Astley - Never Gonna Give You Up.mp4"
def test_parse_outtmpl_with_nested_path(self):
"""Test template parsing with nested directory structure."""
from app.library.Utils import parse_outtmpl
template = "%(uploader)s/%(title)s.%(ext)s"
info_dict = {
"uploader": "Test Channel",
"title": "Test Video",
"ext": "mkv",
}
result = parse_outtmpl(template, info_dict)
assert result == "Test Channel/Test Video.mkv"
def test_parse_outtmpl_with_missing_field(self):
"""Test template parsing with missing field defaults to NA."""
from app.library.Utils import parse_outtmpl
template = "%(title)s - %(upload_date)s.%(ext)s"
info_dict = {
"title": "Test Video",
"ext": "mp4",
# upload_date is missing
}
result = parse_outtmpl(template, info_dict)
assert result == "Test Video - NA.mp4"
def test_parse_outtmpl_complex(self):
"""Test complex template with multiple fields."""
from app.library.Utils import parse_outtmpl
template = "%(uploader)s/%(playlist_title)s/%(playlist_index)03d - %(title)s [%(id)s].%(ext)s"
info_dict = {
"uploader": "Test Channel",
"playlist_title": "Best Videos",
"playlist_index": 5,
"title": "Amazing Content",
"id": "abc123xyz",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert result == "Test Channel/Best Videos/005 - Amazing Content [abc123xyz].mp4"
def test_parse_outtmpl_with_special_characters(self):
"""Test template parsing handles special characters in values."""
from app.library.Utils import parse_outtmpl
template = "%(title)s.%(ext)s"
info_dict = {
"title": "Test: Video / With \\ Special | Characters",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
# yt-dlp sanitizes special characters in filenames
assert ".mp4" in result
assert "Test" in result
def test_parse_outtmpl_with_playlist_info(self):
"""Test template parsing with playlist information."""
from app.library.Utils import parse_outtmpl
template = "%(playlist)s/%(title)s.%(ext)s"
info_dict = {
"playlist": "My Playlist",
"title": "Video Title",
"ext": "webm",
}
result = parse_outtmpl(template, info_dict)
assert result == "My Playlist/Video Title.webm"

View file

@ -72,7 +72,8 @@
</div> </div>
</div> </div>
<div class="columns is-multiline is-mobile is-justify-content-flex-end" v-if="!toggleForm && filteredTasks && filteredTasks.length > 0"> <div class="columns is-multiline is-mobile is-justify-content-flex-end"
v-if="!toggleForm && filteredTasks && filteredTasks.length > 0">
<div class="column is-narrow"> <div class="column is-narrow">
<button type="button" class="button" @click="masterSelectAll = !masterSelectAll" <button type="button" class="button" @click="masterSelectAll = !masterSelectAll"
:class="{ 'has-text-primary': !masterSelectAll, 'has-text-danger': masterSelectAll }"> :class="{ 'has-text-primary': !masterSelectAll, 'has-text-danger': masterSelectAll }">
@ -89,8 +90,7 @@
<div class="column is-2-tablet is-5-mobile"> <div class="column is-2-tablet is-5-mobile">
<Dropdown label="Actions" icons="fa-solid fa-list"> <Dropdown label="Actions" icons="fa-solid fa-list">
<a class="dropdown-item has-text-purple" <a class="dropdown-item has-text-purple" @click="(selectedElms.length > 0 && !massRun) ? runConfirm() : null"
@click="(selectedElms.length > 0 && !massRun) ? runConfirm() : null"
:style="{ opacity: (selectedElms.length < 1 || massRun) ? 0.5 : 1, cursor: (selectedElms.length < 1 || massRun) ? 'not-allowed' : 'pointer' }"> :style="{ opacity: (selectedElms.length < 1 || massRun) ? 0.5 : 1, cursor: (selectedElms.length < 1 || massRun) ? 'not-allowed' : 'pointer' }">
<span class="icon"><i class="fa-solid fa-up-right-from-square" /></span> <span class="icon"><i class="fa-solid fa-up-right-from-square" /></span>
<span>Run Selected</span> <span>Run Selected</span>
@ -447,6 +447,11 @@
<li><strong>Custom Handlers:</strong> Leave timer empty for custom handler definitions. The handler runs <li><strong>Custom Handlers:</strong> Leave timer empty for custom handler definitions. The handler runs
hourly and doesn't require timer. hourly and doesn't require timer.
</li> </li>
<li><strong>Generate metadata:</strong> will attempt first to save metadata based on the task <code>Download
path</code> if not set, it will fallback to the <code>Output template</code> with the
priority of <code>task</code>, <code>preset</code> and then finally to <code>YTP_OUTPUT_TEMPLATE</code>.
The final path must resolve inside <code>{{ config.app.download_path }}</code>.
</li>
</ul> </ul>
</Message> </Message>
</div> </div>
@ -847,10 +852,10 @@ const editItem = (item: task_item) => {
} }
const calcPath = (path: string) => { const calcPath = (path: string) => {
const loc = config.app.download_path || '/downloads' const loc = shortPath(config.app.download_path || '/downloads')
if (path) { if (path) {
return loc + '/' + sTrim(path, '/') return eTrim(loc, '/') + '/' + sTrim(path, '/')
} }
return loc return loc
@ -1068,14 +1073,10 @@ const unarchiveAll = async (item: task_item) => {
const generateMeta = async (item: task_item) => { const generateMeta = async (item: task_item) => {
try { try {
let path = '/';
if (item.folder) {
path = `/${sTrim(item.folder, '/')}`
}
const { status } = await cDialog({ const { status } = await cDialog({
rawHTML: ` rawHTML: `
<p> <p>
Generate '${item.name}' metadata in '<b class="has-text-danger">${path}</b>'? you will be notified when it is done. Generate '${item.name}' metadata? you will be notified when it is done.
</p> </p>
<p> <p>
<b>This action will generate:</b> <b>This action will generate:</b>
@ -1092,7 +1093,6 @@ const generateMeta = async (item: task_item) => {
<span class="icon"><i class="fa-solid fa-triangle-exclamation"></i></span> <span class="icon"><i class="fa-solid fa-triangle-exclamation"></i></span>
<span>Warning</span>: This will overwrite existing metadata files if they exist. <span>Warning</span>: This will overwrite existing metadata files if they exist.
</p>` </p>`
}) })
if (true !== status) { if (true !== status) {