[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", "jsonschema",
"kibibytes", "kibibytes",
"Kodi", "Kodi",
"kwdefaults",
"lastgroup", "lastgroup",
"levelno", "levelno",
"libcurl", "libcurl",
@ -109,6 +110,7 @@
"mebibytes", "mebibytes",
"MEIPASS", "MEIPASS",
"merch", "merch",
"metadataparser",
"Microformat", "Microformat",
"microformats", "microformats",
"mkvtoolsnix", "mkvtoolsnix",

View file

@ -587,6 +587,13 @@ def arg_converter(
finally: finally:
yt_dlp.options.create_parser = create_parser 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 default_opts = _default_opts([]).ydl_opts
if args: if args:

View file

@ -1,10 +1,15 @@
# flake8: noqa: F401
from yt_dlp.globals import postprocessors from yt_dlp.globals import postprocessors
from .nfo_maker import NFOMakerPP 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) 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]: def run(self, info: dict | None = None) -> tuple[list, dict]:
if not info: if not info:
self.to_screen("No info provided to NFO Maker.") self.report_warning("No info provided to NFO Maker.")
return [], {} return [], {}
if self.mode not in self._MODE: 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 return [], info
# prefer explicit final path if present, else fall back to filename # prefer explicit final path if present, else fall back to filename
base_path = info.get("filename") base_path = info.get("filename")
if not base_path: if not base_path:
self.to_screen("No 'filename' provided, skipping NFO creation.") self.report_warning("No 'filename' provided, skipping NFO creation.")
return [], info return [], info
base_path = Path(base_path) base_path = Path(base_path)
nfo_file = base_path.with_suffix(".nfo") nfo_file = base_path.with_suffix(".nfo")
if nfo_file.exists(): 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 return [], info
try: try:
@ -149,25 +149,25 @@ class NFOMakerPP(PostProcessor):
return [], info return [], info
if 1 > len(nfo_data): 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 return [], info
# derive year from any date if missing # 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): if ("year" not in nfo_data) and any(k in nfo_data for k in self._DATE_FIELDS):
try: 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: if first_date:
nfo_data["year"] = first_date.split("-")[0] nfo_data["year"] = first_date.split("-")[0]
except Exception as e: 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: if status and nfo_file.exists() and base_path.exists:
try: try:
mtime = base_path.stat().st_mtime mtime: float = base_path.stat().st_mtime
self.try_utime(str(nfo_file), mtime, mtime) self.try_utime(str(nfo_file), mtime, mtime)
except Exception as e: 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 return [], info
@ -229,12 +229,12 @@ class NFOMakerPP(PostProcessor):
aired = self._normalize_date(aired) if aired else "" aired = self._normalize_date(aired) if aired else ""
if not aired or 3 > len(aired.split("-")): 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 return False
year, month, day = aired.split("-") year, month, day = aired.split("-")
if not (year and month and day): 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 return False
self.to_screen(f"Creating {self.mode} NFO file at {nfo_file!s}") 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",): if self.prefix and key in ("episode",):
try: try:
value = f"1{value}" value: str = f"1{value}"
except Exception: except Exception:
pass pass
@ -342,14 +342,14 @@ class NFOMakerPP(PostProcessor):
# remove any unresolved placeholder lines # remove any unresolved placeholder lines
mapping = self._MODE[self.mode].get("mapping", {}) mapping = self._MODE[self.mode].get("mapping", {})
unresolved_keys: Iterable[str] = set({*mapping, *safe_repl.keys()}) unresolved_keys: Iterable[str] = set({*mapping, *safe_repl.keys()})
pattern = re.compile(rf".*{{(?:{'|'.join(map(re.escape, unresolved_keys))})}}.*") pattern: re.Pattern[str] = re.compile(rf".*{{(?:{'|'.join(map(re.escape, unresolved_keys))})}}.*")
rendered = "\n".join(line for line in rendered.splitlines() if not pattern.fullmatch(line)) rendered: str = "\n".join(line for line in rendered.splitlines() if not pattern.fullmatch(line))
try: try:
nfo_file.write_text(rendered, encoding="utf-8") nfo_file.write_text(rendered, encoding="utf-8")
self.to_screen(f"NFO file written successfully at {nfo_file!s}") self.to_screen(f"NFO file written successfully at {nfo_file!s}")
except Exception as e: except Exception as e:
self.to_screen(f"Error writing NFO file: {e}") self.report_warning(f"Error writing NFO file: {e}")
@staticmethod @staticmethod
def _escape_text(text: Any) -> Any: 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.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, validate_url, validate_uuid
from app.postprocessors.nfo_maker import NFOMakerPP
LOG: logging.Logger = logging.getLogger(__name__) 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 yt_dlp.utils import sanitize_filename
from app.postprocessors.nfo_maker import NFOMakerPP
title = sanitize_filename(info.get("title")) title = sanitize_filename(info.get("title"))
filename = save_path / f"{title} [{info.get('id')}].info.json" filename = save_path / f"{title} [{info.get('id')}].info.json"
filename.write_text(encoder.encode(metadata), encoding="utf-8") filename.write_text(encoder.encode(metadata), encoding="utf-8")

View file

@ -1333,6 +1333,27 @@ class TestArgConverter:
except (ModuleNotFoundError, AttributeError, ImportError): except (ModuleNotFoundError, AttributeError, ImportError):
assert True 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: class TestGetPossibleImages:
"""Test the get_possible_images function.""" """Test the get_possible_images function."""