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:
params = params.add_cli(self.cli, from_user=True)
if self.template:
params = params.add({"outtmpl": {"default": self.template}}, from_user=False)
return params
def mark(self) -> tuple[bool, str]:
@ -140,7 +143,9 @@ class Task:
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):
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."
YTDLP_INFO_CLS: YTDLP = None
YTDLP_INFO_CLS: YTDLP | None = None
"Cached YTDLP info class."
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")
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:
"""
Patches yt_dlp MetadataParserPP action to handle subprocess pickling issues.
@ -382,7 +408,7 @@ def extract_info(
if not data["is_premiere"]:
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:
@ -1406,27 +1432,13 @@ def get_archive_id(url: str) -> dict[str, str | None]:
}
"""
global YTDLP_INFO_CLS # noqa: PLW0603
idDict: dict[str, None] = {
"id": None,
"ie_key": None,
"archive_id": None,
}
if YTDLP_INFO_CLS is None:
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():
for key, _ie in get_static_ytdlp()._ies.items():
try:
if not _ie.suitable(url):
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)
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 uuid
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.router import route
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:
from pathlib import Path
@ -252,10 +254,38 @@ async def task_metadata(request: Request, config: Config, encoder: Encoder) -> R
if not save_path.exists():
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:
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 = {
"id": ag(metadata, ["id", "channel_id"]),
"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
title: str = sanitize_filename(info.get("title"))
filename: Path = save_path / f"{title} [{info.get('id')}].info.json"
filename.write_text(encoder.encode(metadata), encoding="utf-8")
info["json_file"] = str(filename.relative_to(config.download_path))
info_file: Path = save_path / f"{title} [{info.get('id')}].info.json"
info_file.write_text(encoder.encode(metadata), encoding="utf-8")
info["json_file"] = str(info_file.relative_to(config.download_path))
filename = save_path / "tvshow.nfo"
info["nfo_file"] = f"{save_path}/{filename}"
xml_file: Path = save_path / "tvshow.nfo"
info["nfo_file"] = str(xml_file.relative_to(config.download_path))
xml_content = "<tvshow>\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 += " <status>Continuing</status>\n"
xml_content += "</tvshow>\n"
filename.write_text(xml_content, encoding="utf-8")
xml_file.write_text(xml_content, encoding="utf-8")
try:
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)
filename = save_path / f"{key}.jpg"
filename.write_bytes(resp.content)
info["thumbnails"][key] = str(filename.relative_to(config.download_path))
img_file = save_path / f"{key}.jpg"
img_file.write_bytes(resp.content)
info["thumbnails"][key] = str(img_file.relative_to(config.download_path))
except Exception as e:
LOG.warning(f"Failed to fetch thumbnail '{key}' from '{url}'. '{e!s}'")
continue

View file

@ -34,6 +34,7 @@ from app.library.Utils import (
get_files,
get_mime_type,
get_possible_images,
get_static_ytdlp,
init_class,
is_private_address,
list_folders,
@ -41,6 +42,7 @@ from app.library.Utils import (
load_modules,
merge_dict,
move_file,
parse_outtmpl,
parse_tags,
read_logfile,
rename_file,
@ -2675,3 +2677,180 @@ class TestGetExtras:
result = get_extras(entry)
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 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">
<button type="button" class="button" @click="masterSelectAll = !masterSelectAll"
:class="{ 'has-text-primary': !masterSelectAll, 'has-text-danger': masterSelectAll }">
@ -89,8 +90,7 @@
<div class="column is-2-tablet is-5-mobile">
<Dropdown label="Actions" icons="fa-solid fa-list">
<a class="dropdown-item has-text-purple"
@click="(selectedElms.length > 0 && !massRun) ? runConfirm() : null"
<a class="dropdown-item has-text-purple" @click="(selectedElms.length > 0 && !massRun) ? runConfirm() : null"
: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>Run Selected</span>
@ -447,6 +447,11 @@
<li><strong>Custom Handlers:</strong> Leave timer empty for custom handler definitions. The handler runs
hourly and doesn't require timer.
</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>
</Message>
</div>
@ -847,10 +852,10 @@ const editItem = (item: task_item) => {
}
const calcPath = (path: string) => {
const loc = config.app.download_path || '/downloads'
const loc = shortPath(config.app.download_path || '/downloads')
if (path) {
return loc + '/' + sTrim(path, '/')
return eTrim(loc, '/') + '/' + sTrim(path, '/')
}
return loc
@ -1068,14 +1073,10 @@ const unarchiveAll = async (item: task_item) => {
const generateMeta = async (item: task_item) => {
try {
let path = '/';
if (item.folder) {
path = `/${sTrim(item.folder, '/')}`
}
const { status } = await cDialog({
rawHTML: `
<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>
<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>Warning</span>: This will overwrite existing metadata files if they exist.
</p>`
})
if (true !== status) {