[FIX] MetadataParserPP. Ref #475

This commit is contained in:
arabcoders 2025-11-08 20:42:07 +03:00
parent 357ca323e1
commit 5a393bfd9f
7 changed files with 116 additions and 20 deletions

View file

@ -93,6 +93,7 @@
"jsonschema",
"kibibytes",
"Kodi",
"kwdefaults",
"lastgroup",
"levelno",
"libcurl",
@ -109,6 +110,7 @@
"mebibytes",
"MEIPASS",
"merch",
"metadataparser",
"Microformat",
"microformats",
"mkvtoolsnix",

View file

@ -587,6 +587,13 @@ def arg_converter(
finally:
yt_dlp.options.create_parser = create_parser
try:
from app.postprocessors.patch_metadata_parser import ensure_patch
ensure_patch()
except Exception as exc:
LOG.debug("Metadata parser patch failed to apply: %s", exc)
default_opts = _default_opts([]).ydl_opts
if args:

View file

@ -1,10 +1,15 @@
# flake8: noqa: F401
from yt_dlp.globals import postprocessors
from .nfo_maker import NFOMakerPP
_ytptube_pps = {name: value for name, value in globals().items() if name.endswith("PP")}
_ytptube_pps: list[str] = {"NFOMakerPP": NFOMakerPP}
postprocessors.value.update(_ytptube_pps)
__all__ = list(_ytptube_pps.values())
__all__: list = list(_ytptube_pps.values())
# Apply patch to fix MetadataParserPP assertion error
from .patch_metadata_parser import ensure_patch
ensure_patch()

View file

@ -121,24 +121,24 @@ class NFOMakerPP(PostProcessor):
def run(self, info: dict | None = None) -> tuple[list, dict]:
if not info:
self.to_screen("No info provided to NFO Maker.")
self.report_warning("No info provided to NFO Maker.")
return [], {}
if self.mode not in self._MODE:
self.to_screen(f"Invalid mode '{self.mode}'.")
self.report_warning(f"Invalid mode '{self.mode}'.")
return [], info
# prefer explicit final path if present, else fall back to filename
base_path = info.get("filename")
if not base_path:
self.to_screen("No 'filename' provided, skipping NFO creation.")
self.report_warning("No 'filename' provided, skipping NFO creation.")
return [], info
base_path = Path(base_path)
nfo_file = base_path.with_suffix(".nfo")
if nfo_file.exists():
self.to_screen(f"NFO file '{nfo_file!s}' already exists, skipping creation.")
self.report_warning(f"NFO file '{nfo_file!s}' already exists, skipping creation.")
return [], info
try:
@ -149,25 +149,25 @@ class NFOMakerPP(PostProcessor):
return [], info
if 1 > len(nfo_data):
self.to_screen("No metadata found to write to NFO file.")
self.report_warning("No metadata found to write to NFO file.")
return [], info
# derive year from any date if missing
if ("year" not in nfo_data) and any(k in nfo_data for k in self._DATE_FIELDS):
try:
first_date = next((str(nfo_data[k]) for k in self._DATE_FIELDS if nfo_data.get(k)), "")
first_date: str = next((str(nfo_data[k]) for k in self._DATE_FIELDS if nfo_data.get(k)), "")
if first_date:
nfo_data["year"] = first_date.split("-")[0]
except Exception as e:
self.to_screen(f"Error extracting year from date: {e}")
self.report_warning(f"Error extracting year from date: {e}")
status = self._write_episode_info(nfo_file, base_path, nfo_data)
status: bool = self._write_episode_info(nfo_file, base_path, nfo_data)
if status and nfo_file.exists() and base_path.exists:
try:
mtime = base_path.stat().st_mtime
mtime: float = base_path.stat().st_mtime
self.try_utime(str(nfo_file), mtime, mtime)
except Exception as e:
self.to_screen(f"Failed to sync NFO mtime: {e}")
self.report_warning(f"Failed to sync NFO mtime: {e}")
return [], info
@ -229,12 +229,12 @@ class NFOMakerPP(PostProcessor):
aired = self._normalize_date(aired) if aired else ""
if not aired or 3 > len(aired.split("-")):
self.to_screen("Invalid aired/premiered date, skipping NFO creation.")
self.report_warning("Invalid aired/premiered date, skipping NFO creation.")
return False
year, month, day = aired.split("-")
if not (year and month and day):
self.to_screen("Invalid aired date parts, skipping NFO creation.")
self.report_warning("Invalid aired date parts, skipping NFO creation.")
return False
self.to_screen(f"Creating {self.mode} NFO file at {nfo_file!s}")
@ -333,7 +333,7 @@ class NFOMakerPP(PostProcessor):
if self.prefix and key in ("episode",):
try:
value = f"1{value}"
value: str = f"1{value}"
except Exception:
pass
@ -342,14 +342,14 @@ class NFOMakerPP(PostProcessor):
# remove any unresolved placeholder lines
mapping = self._MODE[self.mode].get("mapping", {})
unresolved_keys: Iterable[str] = set({*mapping, *safe_repl.keys()})
pattern = re.compile(rf".*{{(?:{'|'.join(map(re.escape, unresolved_keys))})}}.*")
rendered = "\n".join(line for line in rendered.splitlines() if not pattern.fullmatch(line))
pattern: re.Pattern[str] = re.compile(rf".*{{(?:{'|'.join(map(re.escape, unresolved_keys))})}}.*")
rendered: str = "\n".join(line for line in rendered.splitlines() if not pattern.fullmatch(line))
try:
nfo_file.write_text(rendered, encoding="utf-8")
self.to_screen(f"NFO file written successfully at {nfo_file!s}")
except Exception as e:
self.to_screen(f"Error writing NFO file: {e}")
self.report_warning(f"Error writing NFO file: {e}")
@staticmethod
def _escape_text(text: Any) -> Any:

View file

@ -0,0 +1,60 @@
"""
Patches yt_dlp.postprocessor.metadataparser.MetadataParserPP action namespace to handle duplicated class objects.
This patch is necessary due to to how we parse yt-dlp cli options. on top fo that we have pickling issue as well.
So we need to compare callables structurally rather than by identity as the identity may differ across instances.
"""
from __future__ import annotations
import logging
from typing import Any
LOG: logging.Logger = logging.getLogger(__name__)
def _callable_fingerprint(func: object) -> tuple[Any, ...] | None:
if not callable(func):
return None
target = getattr(func, "__func__", func)
code = getattr(target, "__code__", None)
defaults = getattr(target, "__defaults__", None)
kwdefaults = getattr(target, "__kwdefaults__", None)
return (
getattr(target, "__module__", None),
getattr(target, "__qualname__", getattr(target, "__name__", None)),
getattr(code, "co_code", None),
getattr(code, "co_consts", None),
defaults,
kwdefaults,
)
def ensure_patch() -> None:
try:
from yt_dlp.postprocessor.metadataparser import MetadataParserPP
from yt_dlp.utils import Namespace
except Exception as exc:
LOG.warning(f"Unable to import yt_dlp metadata parser for patching: {exc!s}")
return
if getattr(MetadataParserPP.Actions, "_ytptube_patched", False):
return
class _ActionNamespace(Namespace):
def __contains__(self, candidate: object) -> bool:
if candidate in self.__dict__.values():
return True
candidate_fp = _callable_fingerprint(candidate)
if candidate_fp is None:
return False
return any(_callable_fingerprint(value) == candidate_fp for value in self.__dict__.values())
actions_dict = dict(MetadataParserPP.Actions.items_)
MetadataParserPP.Actions = _ActionNamespace(**actions_dict)
MetadataParserPP.Actions._ytptube_patched = True
LOG.debug("MetadataParserPP action namespace patch applied successfully.")

View file

@ -11,7 +11,6 @@ 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.postprocessors.nfo_maker import NFOMakerPP
LOG: logging.Logger = logging.getLogger(__name__)
@ -256,6 +255,8 @@ async def task_metadata(request: Request, config: Config, encoder: Encoder) -> R
from yt_dlp.utils import sanitize_filename
from app.postprocessors.nfo_maker import NFOMakerPP
title = sanitize_filename(info.get("title"))
filename = save_path / f"{title} [{info.get('id')}].info.json"
filename.write_text(encoder.encode(metadata), encoding="utf-8")

View file

@ -1333,6 +1333,27 @@ class TestArgConverter:
except (ModuleNotFoundError, AttributeError, ImportError):
assert True
def test_arg_converter_replace_in_metadata(self):
"""Test arg_converter handles replace-in-metadata without assertions."""
try:
result = arg_converter("--replace-in-metadata title foo bar")
except (ModuleNotFoundError, AttributeError, ImportError):
assert True
return
postprocessors = result.get("postprocessors", [])
assert postprocessors, "Expected metadata parser postprocessor to be present"
metadata_pp = postprocessors[0]
assert metadata_pp.get("key") == "MetadataParser"
actions = metadata_pp.get("actions", [])
assert actions, "Expected metadata parser to include actions"
action_callable = actions[0][0]
assert callable(action_callable)
assert getattr(action_callable, "__name__", "") == "replacer"
class TestGetPossibleImages:
"""Test the get_possible_images function."""