diff --git a/.vscode/settings.json b/.vscode/settings.json index 2400c3e0..f31bb646 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -93,6 +93,7 @@ "jsonschema", "kibibytes", "Kodi", + "kwdefaults", "lastgroup", "levelno", "libcurl", @@ -109,6 +110,7 @@ "mebibytes", "MEIPASS", "merch", + "metadataparser", "Microformat", "microformats", "mkvtoolsnix", diff --git a/app/library/Utils.py b/app/library/Utils.py index a09095db..efd77d19 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -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: diff --git a/app/postprocessors/__init__.py b/app/postprocessors/__init__.py index 80c2e23d..d04c97c3 100644 --- a/app/postprocessors/__init__.py +++ b/app/postprocessors/__init__.py @@ -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() diff --git a/app/postprocessors/nfo_maker.py b/app/postprocessors/nfo_maker.py index ac345fec..caa60ff6 100644 --- a/app/postprocessors/nfo_maker.py +++ b/app/postprocessors/nfo_maker.py @@ -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: diff --git a/app/postprocessors/patch_metadata_parser.py b/app/postprocessors/patch_metadata_parser.py new file mode 100644 index 00000000..05a59537 --- /dev/null +++ b/app/postprocessors/patch_metadata_parser.py @@ -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.") diff --git a/app/routes/api/tasks.py b/app/routes/api/tasks.py index 3499ccb1..35c28fb8 100644 --- a/app/routes/api/tasks.py +++ b/app/routes/api/tasks.py @@ -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") diff --git a/app/tests/test_utils.py b/app/tests/test_utils.py index 2897777a..536b8ca8 100644 --- a/app/tests/test_utils.py +++ b/app/tests/test_utils.py @@ -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."""