[FIX] MetadataParserPP. Ref #475
This commit is contained in:
parent
357ca323e1
commit
5a393bfd9f
7 changed files with 116 additions and 20 deletions
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -93,6 +93,7 @@
|
|||
"jsonschema",
|
||||
"kibibytes",
|
||||
"Kodi",
|
||||
"kwdefaults",
|
||||
"lastgroup",
|
||||
"levelno",
|
||||
"libcurl",
|
||||
|
|
@ -109,6 +110,7 @@
|
|||
"mebibytes",
|
||||
"MEIPASS",
|
||||
"merch",
|
||||
"metadataparser",
|
||||
"Microformat",
|
||||
"microformats",
|
||||
"mkvtoolsnix",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
60
app/postprocessors/patch_metadata_parser.py
Normal file
60
app/postprocessors/patch_metadata_parser.py
Normal 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.")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue