[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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
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.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")
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue