From 15ac3125143068d808203a341431db2d7e3f287d Mon Sep 17 00:00:00 2001 From: queeup Date: Fri, 20 Nov 2020 23:17:04 +0300 Subject: [PATCH 01/15] Adds support for moving with original filename (#43) --- mnamer/metadata.py | 1 + mnamer/target.py | 1 + tests/e2e/test_moving.py | 12 ++++++++++++ 3 files changed, 14 insertions(+) diff --git a/mnamer/metadata.py b/mnamer/metadata.py index f035da9b..ba9637c6 100644 --- a/mnamer/metadata.py +++ b/mnamer/metadata.py @@ -45,6 +45,7 @@ class Metadata: quality: Optional[str] = None synopsis: Optional[str] = None media: Union[MediaType, str, None] = None + original: Optional[str] = None def __setattr__(self, key: str, value: Any): converter_map: Dict[str, Callable] = { diff --git a/mnamer/target.py b/mnamer/target.py index 9f072892..fb6b64d8 100644 --- a/mnamer/target.py +++ b/mnamer/target.py @@ -143,6 +143,7 @@ def _parse(self, file_path: Path): None: Metadata, }[media_type] self.metadata = meta_cls(language=self._settings.language) + self.metadata.original = self.source.name self.metadata.quality = ( " ".join( path_data[key] diff --git a/tests/e2e/test_moving.py b/tests/e2e/test_moving.py index 51c3658f..6d192913 100644 --- a/tests/e2e/test_moving.py +++ b/tests/e2e/test_moving.py @@ -217,3 +217,15 @@ def test_ambiguous_language_deletction(e2e_run, setup_test_files): ) result = e2e_run("--batch", ".") assert result.code == 0 + + +@pytest.mark.usefixtures("setup_test_dir") +def test_original_filename(e2e_run, setup_test_files): + setup_test_files("archer.2009.s10e07.webrip.x264-lucidtv.mp4") + result = e2e_run( + "--batch", + "--episode-format='{original}'", + ".", + ) + assert result.code == 0 + assert "archer.2009.s10e07.webrip.x264-lucidtv.mp4" in result.out From 208877490871e637736c960437712b7f7aad81a8 Mon Sep 17 00:00:00 2001 From: asfixia Date: Mon, 29 Sep 2025 00:20:53 -0300 Subject: [PATCH 02/15] Fix: Update tvdb to use v4 api and accept absolute episode number --- mnamer/endpoints.py | 95 ++++++++++++++++++++++++++++++--------------- mnamer/providers.py | 18 ++++----- 2 files changed, 73 insertions(+), 40 deletions(-) diff --git a/mnamer/endpoints.py b/mnamer/endpoints.py index 16b5047d..3f206bb6 100644 --- a/mnamer/endpoints.py +++ b/mnamer/endpoints.py @@ -204,27 +204,27 @@ def tvdb_login(api_key: str | None) -> str: Note: You can register for a free TVDb key at thetvdb.com/?tab=apiregister Online docs: api.thetvdb.com/swagger#!/Authentication/post_login. """ - url = "https://api.thetvdb.com/login" + url = "https://api4.thetvdb.com/v4/login" body = {"apikey": api_key} status, content = request_json(url, body=body, cache=False) if status == 401: raise MnamerException("invalid api key") - elif status != 200 or not content.get("token"): # pragma: no cover + elif status != 200 or not content.get("data") or not content.get("data").get("token"): # pragma: no cover raise MnamerNetworkException("TVDb down or unavailable?") - return content["token"] + return content.get("data").get("token") +#Nao é usado mais def tvdb_refresh_token(token: str) -> str: - """ - Refreshes JWT token. - - Online docs: api.thetvdb.com/swagger#!/Authentication/get_refresh_token. - """ url = "https://api.thetvdb.com/refresh_token" headers = {"Authorization": f"Bearer {token}"} status, content = request_json(url, headers=headers, cache=False) if status == 401: raise MnamerException("invalid token") + elif status == 405: + raise MnamerException( + "series, id_imdb, id_zap2it parameters are mutually exclusive" + ) elif status != 200 or not content.get("token"): # pragma: no cover raise MnamerNetworkException("TVDb down or unavailable?") return content["token"] @@ -242,7 +242,7 @@ def tvdb_episodes_id( Online docs: https://api.thetvdb.com/swagger#!/Episodes. """ Language.ensure_valid_for_tvdb(language) - url = f"https://api.thetvdb.com/episodes/{id_tvdb}" + url = f"https://api4.thetvdb.com/v4/episodes/{id_tvdb}" headers = {"Authorization": f"Bearer {token}"} if language: headers["Accept-Language"] = language.a2 @@ -273,13 +273,23 @@ def tvdb_series_id( Online docs: api.thetvdb.com/swagger#!/Series/get_series_id. """ Language.ensure_valid_for_tvdb(language) - url = f"https://api.thetvdb.com/series/{id_tvdb}" + url = f"https://api4.thetvdb.com/v4/series/{id_tvdb}" headers = {"Authorization": f"Bearer {token}"} if language: headers["Accept-Language"] = language.a2 status, content = request_json( url, headers=headers, cache=cache is True and language is None ) + if language: + urlTranslated = f"https://api4.thetvdb.com/v4/series/{id_tvdb}/translations/{language.a3}" + statusTranslated, contentTranslated = request_json( + urlTranslated, headers=headers, cache=cache is True and language is None + ) + if statusTranslated == 200 and contentTranslated.get("data"): + for key in contentTranslated.get("data").keys(): + content["data"][key] = contentTranslated["data"][key] + else: + raise MnamerNotFoundException if status == 401: raise MnamerException("invalid token") elif status == 404 or not content.get("data"): @@ -305,7 +315,7 @@ def tvdb_series_id_episodes( Online docs: api.thetvdb.com/swagger#!/Series/get_series_id_episodes. """ Language.ensure_valid_for_tvdb(language) - url = f"https://api.thetvdb.com/series/{id_tvdb}/episodes" + url = f"https://api4.thetvdb.com/v4/series/{id_tvdb}/episodes/default" headers = {"Authorization": f"Bearer {token}"} if language: headers["Accept-Language"] = language.a2 @@ -321,43 +331,56 @@ def tvdb_series_id_episodes( raise MnamerNetworkException("TVDb down or unavailable?") return content - def tvdb_series_id_episodes_query( token: str, id_tvdb: str, episode: int | None = None, season: int | None = None, - page: int = 1, + page: int = 0, language: Language | None = None, cache: bool = True, ) -> dict: """ - Allows the user to query against episodes for the given series. + Query episodes for a given series in TVDB v4 by filtering client-side. - Note: Paginated with 100 results per page; omitted imdbId-- when would you - ever need to query against both tvdb and imdb series ids? - Online docs: api.thetvdb.com/swagger#!/Series/get_series_id_episodes_query. + Online docs: https://thetvdb.github.io/v4-api/ """ Language.ensure_valid_for_tvdb(language) - url = f"https://api.thetvdb.com/series/{id_tvdb}/episodes/query" + if episode is None and season is None: + raise MnamerException("at least one of season or episode must be provided") headers = {"Authorization": f"Bearer {token}"} + + url = f"https://api4.thetvdb.com/v4/series/{id_tvdb}/episodes/default" if language: headers["Accept-Language"] = language.a2 - parameters = {"airedSeason": season, "airedEpisode": episode, "page": page} + url = f"{url}/{language.a3}" + current_page = max(page or 0, 0) + matches: list[dict] = [] + + + parameters = {"page": current_page} status, content = request_json( - url, - parameters, - headers=headers, - cache=cache is True and language is None, + url, parameters, headers=headers, cache=cache is True and language is None ) if status == 401: raise MnamerException("invalid token") - elif status == 404 or not content.get("data"): + elif status == 404 or not content or not content.get("data"): raise MnamerNotFoundException - elif status != 200: # pragma: no cover + elif status != 200: raise MnamerNetworkException("TVDb down or unavailable?") - return content + items = content.get('data', {}).get('episodes', []) + for item in items: + sn_ok = True if season is None else item.get("seasonNumber") == season + if season is None and episode is not None: + ep_ok = item.get("absoluteNumber") == episode + else: + ep_ok = True if episode is None else item.get("number") == episode + if sn_ok and ep_ok: + matches.append(item) + if "data" in content and "episodes" and content["data"]: + content["data"]["episodes"] = matches + return content def tvdb_search_series( token: str, @@ -370,15 +393,25 @@ def tvdb_search_series( """ Allows the user to search for a series based on the following parameters. - Online docs: https://api.thetvdb.com/swagger#!/Search/get_search_series - Note: results a maximum of 100 entries per page, no option for pagination. + Online swagger docs: https://thetvdb.github.io/v4-api/ """ Language.ensure_valid_for_tvdb(language) - url = "https://api.thetvdb.com/search/series" - parameters = {"name": series, "imdbId": id_imdb, "zap2itId": id_zap2it} headers = {"Authorization": f"Bearer {token}"} if language: headers["Accept-Language"] = language.a2 + + provided = [p is not None for p in (series, id_imdb, id_zap2it)] + if sum(provided) != 1: + raise MnamerException("series, id_imdb, id_zap2it parameters are mutually exclusive") + + if series is not None: + url = "https://api4.thetvdb.com/v4/search" + parameters = {"q": series, "type": "series"} + else: + remote_id = id_imdb or id_zap2it + url = f"https://api4.thetvdb.com/v4/search/remoteid/{remote_id}" + parameters = None + status, content = request_json( url, parameters, headers=headers, cache=cache is True and language is None ) @@ -388,7 +421,7 @@ def tvdb_search_series( raise MnamerException( "series, id_imdb, id_zap2it parameters are mutually exclusive" ) - elif status == 404 or not content.get("data"): + elif status == 404 or not content or not content.get("data"): raise MnamerNotFoundException elif status != 200: # pragma: no cover raise MnamerNetworkException("TVDb down or unavailable?") diff --git a/mnamer/providers.py b/mnamer/providers.py index 3b03703d..1da575ec 100644 --- a/mnamer/providers.py +++ b/mnamer/providers.py @@ -255,7 +255,7 @@ def _search_id( series_data = tvdb_series_id( self.token, id_tvdb, language=language, cache=self.cache ) - page = 1 + page = 0 while True: episode_data = tvdb_series_id_episodes_query( self.token, @@ -266,25 +266,25 @@ def _search_id( page=page, cache=self.cache, ) - for entry in episode_data["data"]: + for entry in episode_data.get("data", {}).get("episodes", []): try: yield MetadataEpisode( - date=entry["firstAired"], - episode=entry["airedEpisodeNumber"], + date=entry["aired"], + episode=entry["number"], id_tvdb=id_tvdb, - season=entry["airedSeason"], - series=series_data["data"]["seriesName"], + season=entry["seasonNumber"], + series=series_data["data"]["name"], language=language, synopsis=(entry["overview"] or "") .replace("\r\n", "") .replace(" ", "") .strip(), - title=entry["episodeName"].split(";", 1)[0], + title=entry["name"].split(";", 1)[0], ) found = True except (AttributeError, KeyError, ValueError): continue - if page == episode_data["links"]["last"]: + if episode_data["links"]["next"] is None: break page += 1 if not found: @@ -302,7 +302,7 @@ def _search_series( self.token, series, language=language, cache=self.cache ) - for series_id in [entry["id"] for entry in series_data["data"][:5]]: + for series_id in [entry["tvdb_id"] for entry in series_data["data"][:5]]: try: for data in self._search_id(series_id, season, episode, language): if not data.series or not data.season: From af3f65881e90f55ee569df20d62137cdfbb11f89 Mon Sep 17 00:00:00 2001 From: asfixia Date: Wed, 1 Oct 2025 02:39:35 -0300 Subject: [PATCH 03/15] fix: The configure settings is applied at start --- mnamer/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mnamer/__main__.py b/mnamer/__main__.py index 259f711c..5de30c8c 100644 --- a/mnamer/__main__.py +++ b/mnamer/__main__.py @@ -19,6 +19,7 @@ def main(): # pragma: no cover tty.error(e) raise SystemExit(2) from None try: + tty.configure(settings) frontend = Cli(settings) frontend.launch() except SystemExit: From 703bd294675b6acbd632ee736398a41815025c0f Mon Sep 17 00:00:00 2001 From: asfixia Date: Wed, 1 Oct 2025 02:40:25 -0300 Subject: [PATCH 04/15] fix: Language sub is causing the program to close --- mnamer/metadata.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mnamer/metadata.py b/mnamer/metadata.py index 5a6988a8..ba2e734e 100644 --- a/mnamer/metadata.py +++ b/mnamer/metadata.py @@ -67,7 +67,10 @@ def __setattr__(self, key: str, value: Any): } converter: Callable | None = converter_map.get(key) if value is not None and converter: - value = converter(value) + try: + value = converter(value) + except: + value = None super().__setattr__(key, value) def __format__(self, format_spec: str | None): From 96eca402b55644a2bed75a630a6c39b672e1b0c5 Mon Sep 17 00:00:00 2001 From: asfixia Date: Wed, 1 Oct 2025 02:41:20 -0300 Subject: [PATCH 05/15] fix: Improve Serie query and subtitle matching --- mnamer/endpoints.py | 2 +- mnamer/language.py | 2 +- mnamer/target.py | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/mnamer/endpoints.py b/mnamer/endpoints.py index 3f206bb6..9f4a8199 100644 --- a/mnamer/endpoints.py +++ b/mnamer/endpoints.py @@ -406,7 +406,7 @@ def tvdb_search_series( if series is not None: url = "https://api4.thetvdb.com/v4/search" - parameters = {"q": series, "type": "series"} + parameters = {"query": series, "type": "series"} else: remote_id = id_imdb or id_zap2it url = f"https://api4.thetvdb.com/v4/search/remoteid/{remote_id}" diff --git a/mnamer/language.py b/mnamer/language.py index e5df124b..f9ac2bea 100644 --- a/mnamer/language.py +++ b/mnamer/language.py @@ -58,7 +58,7 @@ def parse(cls, value: Any) -> Language | None: value = value.lower() for row in KNOWN_LANGUAGES: for item in row: - if value == item: + if value == item or (isinstance(value, str) and value[-2:] == item): return cls(row[0].capitalize(), row[1], row[2]) raise MnamerException("Could not determine language") diff --git a/mnamer/target.py b/mnamer/target.py index 1f601a9c..04928901 100644 --- a/mnamer/target.py +++ b/mnamer/target.py @@ -14,6 +14,7 @@ from mnamer.providers import Provider from mnamer.setting_store import SettingStore from mnamer.types import MediaType, ProviderType +from mnamer import tty from mnamer.utils import ( crawl_in, filename_replace, @@ -50,6 +51,7 @@ def __init__(self, file_path: Path, settings: SettingStore | None = None): self._replace_before() self._override_metadata_ids() self._register_provider() + tty.msg("Parsed filename: " + str(file_path) + " as:\n" + str(self.metadata.as_dict()), debug=True) def __str__(self) -> str: if isinstance(self.source, Path): @@ -118,7 +120,7 @@ def _parse(self, file_path: Path): path_data: dict[str, Any] = {"language": self._settings.language} if is_subtitle(self.source): try: - path_data["language"] = Language.parse(self.source.stem[-2:]) + path_data["language"] = Language.parse(self.source.stem[-3:]) file_path = Path(self.source.parent, self.source.stem[:-2]) except MnamerException: pass From d4fd65c40bcf818cdd0139b25c26133e17a7da46 Mon Sep 17 00:00:00 2001 From: asfixia Date: Thu, 2 Oct 2025 00:33:50 -0300 Subject: [PATCH 06/15] fix: Getting the wrong eposide number --- mnamer/endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mnamer/endpoints.py b/mnamer/endpoints.py index 9f4a8199..3344e3ef 100644 --- a/mnamer/endpoints.py +++ b/mnamer/endpoints.py @@ -378,7 +378,7 @@ def tvdb_series_id_episodes_query( ep_ok = True if episode is None else item.get("number") == episode if sn_ok and ep_ok: matches.append(item) - if "data" in content and "episodes" and content["data"]: + if "data" in content and "episodes" in content["data"]: content["data"]["episodes"] = matches return content From 7ae9abf718cb456b82842d83ef0ea96f7db3ce07 Mon Sep 17 00:00:00 2001 From: asfixia Date: Thu, 2 Oct 2025 02:59:35 -0300 Subject: [PATCH 07/15] fix: Improve the str language detection and fix the missing format. --- mnamer/language.py | 31 +++++++++++++++++++++++++++++++ mnamer/target.py | 7 +++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/mnamer/language.py b/mnamer/language.py index f9ac2bea..498ea675 100644 --- a/mnamer/language.py +++ b/mnamer/language.py @@ -5,6 +5,9 @@ from mnamer.exceptions import MnamerException +from pathlib import WindowsPath, Path +import re + KNOWN_LANGUAGES = ( ("arabic", "ar", "ara"), ("chinese", "zh", "zho"), @@ -31,6 +34,32 @@ ("ukrainian", "uk", "ukr"), ) +def _coerce_lang_from_windows_path(p: WindowsPath) -> Any | None: + if p.suffix.lower() != ".srt": + return None + try: + from guessit import guessit + g = guessit(p.name, {"type": "subtitle"}) + lang = g.get("subtitle_language") or g.get("language") + if isinstance(lang, list) and lang: + lang = lang[0] + if isinstance(lang, str) and lang: + return lang.lower() + except Exception: + pass + + _LANG_BASE = r"[a-z]{2,3}" + _LANG_VARIANT = r"(?:[-_][a-z0-9]{2,4})?" + _BOUNDARY_LEFT = r"(?:^|[.\-_ \[(])" + _BOUNDARY_RIGHT = r"(?=\.srt$)" + _LANG_NEAR_END = re.compile( + _BOUNDARY_LEFT + r"(" + _LANG_BASE + _LANG_VARIANT + r")" + _BOUNDARY_RIGHT, + re.IGNORECASE, + ) + m = _LANG_NEAR_END.search(p.name) + if m: + return m.group(1).lower() + return p.stem[-3:].lower() @dataclasses.dataclass class Language: @@ -44,6 +73,8 @@ class Language: def parse(cls, value: Any) -> Language | None: if not value: return None + if isinstance(value, WindowsPath): + value = _coerce_lang_from_windows_path(value) if isinstance(value, cls): return value if isinstance(value, dict): diff --git a/mnamer/target.py b/mnamer/target.py index 04928901..c3b4345a 100644 --- a/mnamer/target.py +++ b/mnamer/target.py @@ -120,8 +120,8 @@ def _parse(self, file_path: Path): path_data: dict[str, Any] = {"language": self._settings.language} if is_subtitle(self.source): try: - path_data["language"] = Language.parse(self.source.stem[-3:]) - file_path = Path(self.source.parent, self.source.stem[:-2]) + path_data["language"] = Language.parse(self.source) + #file_path = Path(self.source.parent, self.source.stem[:-2]) except MnamerException: pass options = {"type": self._settings.media, "language": path_data["language"]} @@ -221,8 +221,7 @@ def _replace_before(self) -> None: continue if attr.startswith("_"): continue - value = str_replace(value, self._settings.replace_before) - setattr(self.metadata, attr, value) + setattr(self.metadata, attr, str_replace(value, self._settings.replace_before)) def query(self) -> list[Metadata]: """Queries the target's respective media provider for metadata.""" From 8ce3cfebe91d065beb9af8b193e28574dc1b14f3 Mon Sep 17 00:00:00 2001 From: asfixia Date: Wed, 8 Oct 2025 23:43:08 -0300 Subject: [PATCH 08/15] fix: Improve subtitle language detection Read the legend to discover the language contents --- mnamer/target.py | 90 +++++++++++++++++++++++++++++------------------- 1 file changed, 55 insertions(+), 35 deletions(-) diff --git a/mnamer/target.py b/mnamer/target.py index c3b4345a..7bb6b287 100644 --- a/mnamer/target.py +++ b/mnamer/target.py @@ -8,6 +8,9 @@ from guessit import guessit # type: ignore +from charset_normalizer import from_path +from langdetect import detect + from mnamer.exceptions import MnamerException from mnamer.language import Language from mnamer.metadata import Metadata, MetadataEpisode, MetadataMovie @@ -117,27 +120,7 @@ def destination(self) -> Path: return Path(directory, filename) def _parse(self, file_path: Path): - path_data: dict[str, Any] = {"language": self._settings.language} - if is_subtitle(self.source): - try: - path_data["language"] = Language.parse(self.source) - #file_path = Path(self.source.parent, self.source.stem[:-2]) - except MnamerException: - pass - options = {"type": self._settings.media, "language": path_data["language"]} - raw_data = dict(guessit(str(file_path), options)) - if isinstance(raw_data.get("season"), list): - raw_data = dict(guessit(str(file_path.parts[-1]), options)) - for k, v in raw_data.items(): - if hasattr(v, "alpha3"): - try: - path_data[k] = Language.parse(v) - except MnamerException: - continue - elif isinstance(v, int | str | dt.date): - path_data[k] = v - elif isinstance(v, list) and all(isinstance(_, int | str) for _ in v): - path_data[k] = v[0] + path_data = self._path_metadata(file_path) if self._settings.media: media_type = self._settings.media elif path_data.get("type"): @@ -168,16 +151,8 @@ def _parse(self, file_path: Path): ) self.metadata.language = path_data.get("language") self.metadata.group = path_data.get("release_group") - self.metadata.container = file_path.suffix or None - if not self.metadata.language: - try: - self.metadata.language = path_data.get("language") - except MnamerException: - pass - try: - self.metadata.language_sub = path_data.get("subtitle_language") - except MnamerException: - pass + self.metadata.container = path_data.get("container") + self.metadata.language_sub = path_data.get("subtitle_language") if isinstance(self.metadata, MetadataMovie): self.metadata.name = path_data.get("title") self.metadata.year = path_data.get("year") @@ -189,10 +164,55 @@ def _parse(self, file_path: Path): alternative_title = path_data.get("alternative_title") if alternative_title: self.metadata.series = f"{self.metadata.series} {alternative_title}" - # adding year to title can reduce false positives - # year = path_data.get("year") - # if year: - # self.metadata.series = f"{self.metadata.series} {year}" + #adding year to title can reduce false positives + #if path_data.get("year"): + # self.metadata.series = f"{self.metadata.series} ({path_data.get("year")})" + + @staticmethod + def _detect_subtitle_language(file_path: str): + # Detect and decode using best guess for encoding + result = from_path(file_path).best() + if not result: + return None + + text = str(result) + try: + return Language.parse(detect(text)) + except: + return None + + + + def _path_metadata(self, file_path): + path_data: dict[str, Any] = { + "language": self._settings.language, + "container": file_path.suffix or None, + "type": self._settings.media + } + raw_data = dict(guessit(str_replace(str(file_path), self._settings.replace_before), path_data)) + if isinstance(raw_data.get("season"), list): + raw_data = dict(guessit(str(file_path.parts[-1]), path_data)) + for k, v in raw_data.items(): + if hasattr(v, "alpha3"): + try: + path_data[k] = Language.parse(v) + except MnamerException: + continue + elif isinstance(v, int | str | dt.date): + path_data[k] = v + elif isinstance(v, list) and all(isinstance(_, int | str) for _ in v): + path_data[k] = v[0] + if is_subtitle(self.source): + try: + path_data["subtitle_language"] = self._detect_subtitle_language(str(file_path)) or Language.parse(raw_data.get("subtitle_language") or self.source.stem[-3:]) + path_data["language"] = self._settings.language + except MnamerException: + pass + try: + Language.ensure_valid_for_tvdb(path_data["language"]) + except MnamerException: + path_data["language"] = self._settings.language + return path_data def _override_metadata_ids(self): id_types = {"imdb", "tmdb", "tvdb", "tvmaze"} From eb91d474b927b7ee6d21a0a4c79ddfc87b855f02 Mon Sep 17 00:00:00 2001 From: asfixia Date: Wed, 8 Oct 2025 23:47:50 -0300 Subject: [PATCH 09/15] fix: Accept season and episode number 0 and detect invalid season value to treat it as year Season 0 (as specials of spartacus gods of arena) were beign discarded. Allows discovering episode without season by search by series name + episode number + air year. --- mnamer/metadata.py | 9 +++++ mnamer/providers.py | 83 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/mnamer/metadata.py b/mnamer/metadata.py index ba2e734e..8a1f64d0 100644 --- a/mnamer/metadata.py +++ b/mnamer/metadata.py @@ -179,3 +179,12 @@ def __setattr__(self, key: str, value: Any): if value is not None and converter: value = converter(value) super().__setattr__(key, value) + + def is_invalid_season(self): + return not isinstance(self.season, int) or self.season > 1500 + + @property + def year(self) -> int | None: + if self.is_invalid_season(): + return self.season + return None diff --git a/mnamer/providers.py b/mnamer/providers.py index 1da575ec..3bac2691 100644 --- a/mnamer/providers.py +++ b/mnamer/providers.py @@ -28,8 +28,7 @@ from mnamer.metadata import Metadata, MetadataEpisode, MetadataMovie from mnamer.setting_store import SettingStore from mnamer.types import MediaType, ProviderType -from mnamer.utils import parse_date, year_range_parse - +from mnamer.utils import parse_date, year_range_parse, request_json class Provider(ABC): """ABC for Providers, high-level interfaces for metadata media providers.""" @@ -228,15 +227,24 @@ def search(self, query: MetadataEpisode) -> Iterator[MetadataEpisode]: assert query if not self.token: self.token = self._login() - if query.id_tvdb and query.date: + if query.id_tvdb is not None and query.date is not None: results = self._search_tvdb_date(query.id_tvdb, query.date, query.language) - elif query.id_tvdb: + elif query.id_tvdb is not None: results = self._search_id( query.id_tvdb, query.season, query.episode, query.language ) - elif query.series and query.date: + elif query.series is not None and query.date is not None: results = self._search_series_date(query.series, query.date, query.language) - elif query.series: + elif query.series is not None and query.year is not None and query.episode is not None: + episode = self._get_episode_by_serie_episode_year( + f"{query.series} ({query.year})", query.year, query.episode, query.language + ) + results = self._search_id( + episode.get('seriesId'), episode.get('seasonNumber'), episode.get('number'), query.language + ) if episode else self._search_series( + query.series, query.season, query.episode, query.language + ) + elif query.series is not None: results = self._search_series( query.series, query.season, query.episode, query.language ) @@ -290,6 +298,67 @@ def _search_id( if not found: raise MnamerNotFoundException + def _get_episode_by_serie_episode_year( + self, + series: str, + year: int, + episode: int | None = None, + language: Language | None = None, + cache: bool = False + ) -> int | None: + """ + Search a series on TVDB and return the season number aired in the given year. + If `episode` is provided, tries to match that episode number. + """ + + # 1️⃣ Search for the series + url = "https://api4.thetvdb.com/v4/search" + parameters = {"query": series, "type": "series"} + + headers = {"Authorization": f"Bearer {self.token}"} + if language: + headers["Accept-Language"] = language.a2 + + status, content = request_json( + url, parameters, headers=headers, cache=cache is True and language is None + ) + if status != 200 or "data" not in content or not content["data"]: + return None + + series_id = content["data"][0]["tvdb_id"] + + # 2️⃣ Get all episodes of this series + url = f"https://api4.thetvdb.com/v4/series/{series_id}/episodes/default" + status, content = request_json( + url, None, headers=headers, cache=cache is True and language is None + ) + if status != 200 or "data" not in content: + return None + + episodes = content.get("data", {}).get("episodes", []) + + # 3️⃣ Filter by year and optionally episode + matched = [ + ep + for ep in episodes + if ep.get("aired", "").startswith(str(year)) + and (episode is None or ep.get("number") == episode) + ] + + if not matched: + matched = [ + ep + for ep in episodes + if ep.get("aired", "").startswith(str(year)) + and (episode is None or ep.get("absoluteNumber") == episode) + ] + + if not matched: + return None + + # 4️⃣ Return the first matching season number + return matched[0] + def _search_series( self, series: str, @@ -305,7 +374,7 @@ def _search_series( for series_id in [entry["tvdb_id"] for entry in series_data["data"][:5]]: try: for data in self._search_id(series_id, season, episode, language): - if not data.series or not data.season: + if data.series is None or data.season is None: continue found = True yield data From 1d774e6a1fb644104634680b4fa6e21bc15be4f3 Mon Sep 17 00:00:00 2001 From: asfixia Date: Wed, 8 Oct 2025 23:49:46 -0300 Subject: [PATCH 10/15] feat: Improve subtitle detection by analyzing its contents Reads the subtitle contents to identify its idiom. --- mnamer/language.py | 42 ++++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/mnamer/language.py b/mnamer/language.py index 498ea675..ad3222b9 100644 --- a/mnamer/language.py +++ b/mnamer/language.py @@ -5,7 +5,7 @@ from mnamer.exceptions import MnamerException -from pathlib import WindowsPath, Path +from pathlib import PurePath import re KNOWN_LANGUAGES = ( @@ -34,32 +34,34 @@ ("ukrainian", "uk", "ukr"), ) -def _coerce_lang_from_windows_path(p: WindowsPath) -> Any | None: - if p.suffix.lower() != ".srt": - return None +def _guess_lang_from_windows_path(filePath: PurePath) -> str | None: try: from guessit import guessit - g = guessit(p.name, {"type": "subtitle"}) + g = guessit(filePath.name, {"type": "subtitle"}) lang = g.get("subtitle_language") or g.get("language") if isinstance(lang, list) and lang: - lang = lang[0] + lang = str(lang[0]) if isinstance(lang, str) and lang: return lang.lower() except Exception: pass - _LANG_BASE = r"[a-z]{2,3}" - _LANG_VARIANT = r"(?:[-_][a-z0-9]{2,4})?" - _BOUNDARY_LEFT = r"(?:^|[.\-_ \[(])" - _BOUNDARY_RIGHT = r"(?=\.srt$)" - _LANG_NEAR_END = re.compile( - _BOUNDARY_LEFT + r"(" + _LANG_BASE + _LANG_VARIANT + r")" + _BOUNDARY_RIGHT, - re.IGNORECASE, - ) - m = _LANG_NEAR_END.search(p.name) - if m: - return m.group(1).lower() - return p.stem[-3:].lower() + def force_guess_directly_from_path(p): + _LANG_BASE = r"[a-z]{2,3}" + _LANG_VARIANT = r"(?:[-_][a-z0-9]{2,4})?" + _BOUNDARY_LEFT = r"(?:^|[.\-_ \[(])" + _BOUNDARY_RIGHT = r"(?=\.srt$)" + _LANG_NEAR_END = re.compile( + _BOUNDARY_LEFT + r"(" + _LANG_BASE + _LANG_VARIANT + r")" + _BOUNDARY_RIGHT, + re.IGNORECASE, + ) + m = _LANG_NEAR_END.search(p.name) + if m: + return m.group(1).lower() + return p.stem[-3:].lower() + + return force_guess_directly_from_path(filePath) + @dataclasses.dataclass class Language: @@ -73,8 +75,8 @@ class Language: def parse(cls, value: Any) -> Language | None: if not value: return None - if isinstance(value, WindowsPath): - value = _coerce_lang_from_windows_path(value) + if isinstance(value, PurePath): + return cls.parse(_guess_lang_from_windows_path(value)) if isinstance(value, cls): return value if isinstance(value, dict): From 43c00a01777525bfa6b5e77e05a5ef353bb19fb9 Mon Sep 17 00:00:00 2001 From: asfixia Date: Thu, 9 Oct 2025 03:44:02 -0300 Subject: [PATCH 11/15] fix: Pass on all tests for the new api TVDB Api v4 --- mnamer/endpoints.py | 51 ++++---- mnamer/providers.py | 4 +- mnamer/target.py | 10 +- tests/__init__.py | 6 +- tests/network/test_endpoints__tvdb.py | 169 ++++++++++---------------- tests/network/test_providers__tvdb.py | 2 +- 6 files changed, 106 insertions(+), 136 deletions(-) diff --git a/mnamer/endpoints.py b/mnamer/endpoints.py index 3344e3ef..f8e07268 100644 --- a/mnamer/endpoints.py +++ b/mnamer/endpoints.py @@ -214,22 +214,6 @@ def tvdb_login(api_key: str | None) -> str: return content.get("data").get("token") -#Nao é usado mais -def tvdb_refresh_token(token: str) -> str: - url = "https://api.thetvdb.com/refresh_token" - headers = {"Authorization": f"Bearer {token}"} - status, content = request_json(url, headers=headers, cache=False) - if status == 401: - raise MnamerException("invalid token") - elif status == 405: - raise MnamerException( - "series, id_imdb, id_zap2it parameters are mutually exclusive" - ) - elif status != 200 or not content.get("token"): # pragma: no cover - raise MnamerNetworkException("TVDb down or unavailable?") - return content["token"] - - def tvdb_episodes_id( token: str, id_tvdb: str, @@ -257,6 +241,16 @@ def tvdb_episodes_id( raise MnamerNetworkException("TVDb down or unavailable?") elif content["data"]["id"] == 0: raise MnamerNotFoundException + + if language: + url_trans = f"https://api4.thetvdb.com/v4/episodes/{id_tvdb}/translations/{language.a3}" + trans_status, trans_content = request_json(url_trans, headers=headers, cache=cache) + + if trans_status == 200 and trans_content.get("data"): + trans_data = trans_content["data"] + for key, value in trans_data.items(): + if value: + content["data"][key] = value return content @@ -304,7 +298,7 @@ def tvdb_series_id( def tvdb_series_id_episodes( token: str, id_tvdb: str, - page: int = 1, + page: int = 0, language: Language | None = None, cache: bool = True, ) -> dict: @@ -329,6 +323,18 @@ def tvdb_series_id_episodes( raise MnamerNotFoundException elif status != 200: # pragma: no cover raise MnamerNetworkException("TVDb down or unavailable?") + + + if language: + url_trans = f"https://api4.thetvdb.com/v4/series/{id_tvdb}/episodes/default/{language.a3}" + trans_status, trans_content = request_json(url_trans, headers=headers, cache=cache) + + if trans_status == 200 and trans_content.get("data"): + trans_data = trans_content["data"] + for key, value in trans_data.items(): + if value: + content["data"][key] = value + content.get("data", {}).get("episodes", []).sort(key=lambda e: e['id']) return content def tvdb_series_id_episodes_query( @@ -346,8 +352,6 @@ def tvdb_series_id_episodes_query( Online docs: https://thetvdb.github.io/v4-api/ """ Language.ensure_valid_for_tvdb(language) - if episode is None and season is None: - raise MnamerException("at least one of season or episode must be provided") headers = {"Authorization": f"Bearer {token}"} url = f"https://api4.thetvdb.com/v4/series/{id_tvdb}/episodes/default" @@ -368,17 +372,20 @@ def tvdb_series_id_episodes_query( raise MnamerNotFoundException elif status != 200: raise MnamerNetworkException("TVDb down or unavailable?") - items = content.get('data', {}).get('episodes', []) + hasValidAbsoluteNumbers = (len(items) >= 1 and items[0].get("absoluteNumber") != 0) or (len(items) > 2 and items[0].get("absoluteNumber") != 0) + if not len(items): + raise MnamerNotFoundException for item in items: sn_ok = True if season is None else item.get("seasonNumber") == season - if season is None and episode is not None: + if season is None and episode is not None and hasValidAbsoluteNumbers: ep_ok = item.get("absoluteNumber") == episode else: ep_ok = True if episode is None else item.get("number") == episode if sn_ok and ep_ok: matches.append(item) if "data" in content and "episodes" in content["data"]: + matches.sort(key=lambda e: e["id"]) content["data"]["episodes"] = matches return content @@ -407,6 +414,8 @@ def tvdb_search_series( if series is not None: url = "https://api4.thetvdb.com/v4/search" parameters = {"query": series, "type": "series"} + if language: + parameters["language"] = language.a3 else: remote_id = id_imdb or id_zap2it url = f"https://api4.thetvdb.com/v4/search/remoteid/{remote_id}" diff --git a/mnamer/providers.py b/mnamer/providers.py index 3bac2691..4d13822a 100644 --- a/mnamer/providers.py +++ b/mnamer/providers.py @@ -212,7 +212,7 @@ def _search_name(self, name: str, year: str | None, language: Language | None): class Tvdb(Provider): """Queries the TVDb API.""" - api_key: str = environ.get("API_KEY_TVDB", "E69C7A2CEF2F3152") + api_key: str = environ.get("API_KEY_TVDB", "7eef8b26-3af2-4431-9c4e-8547e98efff4") def __init__(self, api_key: str | None = None, cache: bool = True): super().__init__(api_key, cache) @@ -402,7 +402,7 @@ def _search_series_date( series_data = tvdb_search_series( self.token, series, language=language, cache=self.cache ) - tvdb_ids = [entry["id"] for entry in series_data["data"]][:5] + tvdb_ids = [entry["tvdb_id"] for entry in series_data["data"]][:5] found = False for tvdb_id in tvdb_ids: try: diff --git a/mnamer/target.py b/mnamer/target.py index 7bb6b287..357de9b3 100644 --- a/mnamer/target.py +++ b/mnamer/target.py @@ -171,12 +171,12 @@ def _parse(self, file_path: Path): @staticmethod def _detect_subtitle_language(file_path: str): # Detect and decode using best guess for encoding - result = from_path(file_path).best() - if not result: - return None - - text = str(result) try: + result = from_path(file_path).best() + if not result: + return None + + text = str(result) return Language.parse(detect(text)) except: return None diff --git a/tests/__init__.py b/tests/__init__.py index f340d06d..7c9a7ade 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -46,7 +46,7 @@ "date": dt.date(2015, 2, 22), "episode": 11, "id_imdb": "tt1520211", - "id_tvdb": 153021, + "id_tvdb": "153021", "id_tvmaze": 73, "media": "television", "season": 5, @@ -57,7 +57,7 @@ "date": dt.date(1999, 11, 8), "episode": 13, "id_imdb": "tt0208616", - "id_tvdb": 78342, + "id_tvdb": "78342", "id_tvmaze": 30436, "media": "television", "season": 1, @@ -68,7 +68,7 @@ "date": dt.date(2015, 10, 19), "episode": 2, "id_imdb": "tt2802850", - "id_tvdb": 269613, + "id_tvdb": "269613", "id_tvmaze": 32, "media": "television", "season": 2, diff --git a/tests/network/test_endpoints__tvdb.py b/tests/network/test_endpoints__tvdb.py index 87d5c905..0950b746 100644 --- a/tests/network/test_endpoints__tvdb.py +++ b/tests/network/test_endpoints__tvdb.py @@ -3,7 +3,6 @@ from mnamer.endpoints import ( tvdb_episodes_id, tvdb_login, - tvdb_refresh_token, tvdb_search_series, tvdb_series_id, tvdb_series_id_episodes, @@ -21,43 +20,26 @@ ] EXPECTED_TOP_LEVEL_SHOW_KEYS = { - "absoluteNumber", - "airedEpisodeNumber", - "airedSeason", - "airedSeasonID", - "airsAfterSeason", - "airsBeforeEpisode", - "airsBeforeSeason", - "contentRating", - "directors", - "dvdChapter", - "dvdDiscid", - "dvdEpisodeNumber", - "dvdSeason", - "episodeName", - "filename", - "firstAired", - "guestStars", - "id", - "imdbId", - "isMovie", - "language", - "lastUpdated", - "lastUpdatedBy", - "overview", - "productionCode", - "seriesId", - "showUrl", - "siteRating", - "siteRatingCount", - "thumbAdded", - "thumbAuthor", - "thumbHeight", - "thumbWidth", - "writers", + 'absoluteNumber', + 'aired', + 'finaleType', + 'id', + 'image', + 'imageType', + 'isMovie', + 'lastUpdated', + 'name', + 'nameTranslations', + 'number', + 'overview', + 'overviewTranslations', + 'runtime', + 'seasonNumber', + 'seasons', + 'seriesId', + 'year' } - LOST_TVDB_ID_EPISODE = "127131" LOST_TVDB_ID_SERIES = "73739" THE_WITCHER_ID_SERIES = "362696" @@ -82,16 +64,6 @@ def test_tvdb_login__login_fail(): tvdb_login(JUNK_TEXT) -def test_tvdb_refresh_token__refresh_success(): - token = tvdb_login(Tvdb.api_key) - assert tvdb_refresh_token(token) is not None - - -def test_tvdb_refresh_token__refresh_fail(): - with pytest.raises(MnamerException): - tvdb_refresh_token(JUNK_TEXT) - - @pytest.mark.xfail(strict=False) def test_tvdb_episodes_id__invalid_token(): with pytest.raises(MnamerException): @@ -129,7 +101,7 @@ def test_tvdb_episodes_id__success(tvdb_token): def test_tvdb_episodes_id__language(tvdb_token): result = tvdb_episodes_id(tvdb_token, LOST_TVDB_ID_EPISODE, RUSSIAN_LANG) - assert result["data"]["episodeName"] == "Пилот (1)" + assert result["data"]["name"] == "Пилот (1)" def test_tvdb_episodes_id__language__invalid(tvdb_token): @@ -166,33 +138,27 @@ def test_tvdb_series_id__no_hits(tvdb_token): def test_tvdb_series_id__success(tvdb_token): expected_top_level_keys = { - "added", - "addedBy", - "airsDayOfWeek", - "airsTime", - "aliases", - "banner", - "fanart", - "firstAired", - "genre", - "id", - "imdbId", - "language", - "lastUpdated", - "network", - "networkId", - "overview", - "poster", - "rating", - "runtime", - "season", - "seriesId", - "seriesName", - "siteRating", - "siteRatingCount", - "slug", - "status", - "zap2itId", + 'aliases', + 'averageRuntime', + 'defaultSeasonType', + 'episodes', + 'firstAired', + 'id', + 'image', + 'isOrderRandomized', + 'lastAired', + 'lastUpdated', + 'name', + 'nameTranslations', + 'nextAired', + 'originalCountry', + 'originalLanguage', + 'overview', + 'overviewTranslations', + 'score', + 'slug', + 'status', + 'year' } result = tvdb_series_id(tvdb_token, LOST_TVDB_ID_SERIES) @@ -200,12 +166,12 @@ def test_tvdb_series_id__success(tvdb_token): assert "data" in result assert set(result["data"].keys()) == expected_top_level_keys assert str(result["data"]["id"]) == LOST_TVDB_ID_SERIES - assert result["data"]["seriesName"] == "Lost" + assert result["data"]["name"] == "Lost" def test_tvdb_series_id__language(tvdb_token): result = tvdb_series_id(tvdb_token, THE_WITCHER_ID_SERIES, RUSSIAN_LANG) - assert result["data"]["seriesName"] == "Ведьмак" + assert result["data"]["name"] == "Ведьмак" @pytest.mark.xfail(strict=False) @@ -238,7 +204,7 @@ def test_tvdb_series_id_episodes__success(tvdb_token): result = tvdb_series_id_episodes(tvdb_token, LOST_TVDB_ID_SERIES) assert isinstance(result, dict) assert "data" in result - entry = result["data"][0] + entry = result["data"]["episodes"][0] assert set(entry.keys()) == EXPECTED_TOP_LEVEL_SHOW_KEYS assert str(entry["id"]) == LOST_TVDB_ID_EPISODE @@ -247,7 +213,7 @@ def test_tvdb_series_id_episodes__language(tvdb_token): result = tvdb_series_id_episodes( tvdb_token, THE_WITCHER_ID_SERIES, language=RUSSIAN_LANG ) - assert result["data"][0]["episodeName"] == "Начало конца" + assert result["data"]["episodes"][0]["name"] == "Начало конца" @pytest.mark.xfail(strict=False) @@ -272,24 +238,24 @@ def test_tvdb_series_id_episodes_query__invalid_id_tvdb(tvdb_token): def test_tvdb_series_id_episodes_query__page_valid(tvdb_token): - tvdb_series_id_episodes_query(tvdb_token, LOST_TVDB_ID_SERIES, page=1) - tvdb_series_id_episodes_query(tvdb_token, LOST_TVDB_ID_SERIES, page=1, season=1) + tvdb_series_id_episodes_query(tvdb_token, LOST_TVDB_ID_SERIES, page=0) + tvdb_series_id_episodes_query(tvdb_token, LOST_TVDB_ID_SERIES, page=0, season=1) tvdb_series_id_episodes_query( - tvdb_token, LOST_TVDB_ID_SERIES, page=1, season=1, episode=1 + tvdb_token, LOST_TVDB_ID_SERIES, page=0, season=1, episode=1 ) with pytest.raises(MnamerNotFoundException): tvdb_series_id_episodes_query( - tvdb_token, LOST_TVDB_ID_SERIES, page=11, cache=False + tvdb_token, LOST_TVDB_ID_SERIES, page=10, cache=False ) with pytest.raises(MnamerNotFoundException): tvdb_series_id_episodes_query( - tvdb_token, LOST_TVDB_ID_SERIES, page=2, season=1, cache=False + tvdb_token, LOST_TVDB_ID_SERIES, page=1, season=0, cache=False ) with pytest.raises(MnamerNotFoundException): tvdb_series_id_episodes_query( tvdb_token, LOST_TVDB_ID_SERIES, - page=2, + page=1, season=1, episode=1, cache=False, @@ -300,10 +266,9 @@ def test_tvdb_series_id_episodes_query__success_id_tvdb(tvdb_token): result = tvdb_series_id_episodes_query(tvdb_token, LOST_TVDB_ID_SERIES) assert isinstance(result, dict) assert "data" in result - data = result["data"] - assert len(data) == 100 - actual_top_level_keys = set(data[0].keys()) - assert actual_top_level_keys == EXPECTED_TOP_LEVEL_SHOW_KEYS + data = result["data"]["episodes"] + assert len(data) == result["links"]["total_items"] and len(data) >= 100 + assert set(data[0].keys()) >= EXPECTED_TOP_LEVEL_SHOW_KEYS assert str(data[0]["id"]) == LOST_TVDB_ID_EPISODE @@ -311,9 +276,8 @@ def test_tvdb_series_id_episodes_query__success_id_tvdb_season(tvdb_token): result = tvdb_series_id_episodes_query(tvdb_token, LOST_TVDB_ID_SERIES, season=1) assert isinstance(result, dict) assert "data" in result - data = result["data"] - actual_top_level_keys = set(data[0].keys()) - assert actual_top_level_keys == EXPECTED_TOP_LEVEL_SHOW_KEYS + data = result["data"]["episodes"] + assert set(data[0].keys()) >= EXPECTED_TOP_LEVEL_SHOW_KEYS assert str(data[0]["id"]) == LOST_TVDB_ID_EPISODE assert result["links"]["prev"] is None assert result["links"]["next"] is None @@ -327,9 +291,8 @@ def test_tvdb_series_id_episodes_query__success_id_tvdb_season_episode( ) assert isinstance(result, dict) assert "data" in result - data = result["data"] - actual_top_level_keys = set(data[0].keys()) - assert actual_top_level_keys == EXPECTED_TOP_LEVEL_SHOW_KEYS + data = result["data"]["episodes"] + assert set(data[0].keys()) >= EXPECTED_TOP_LEVEL_SHOW_KEYS assert str(data[0]["id"]) == LOST_TVDB_ID_EPISODE assert result["links"]["prev"] is None assert result["links"]["next"] is None @@ -343,7 +306,7 @@ def test_tvdb_series_id_episodes_query(tvdb_token): episode=1, language=RUSSIAN_LANG, ) - assert result["data"][0]["episodeName"] == "Начало конца" + assert result["data"]["episodes"][0]["name"] == "Начало конца" def test_tvdb_search_series__invalid_token(): @@ -369,14 +332,13 @@ def test_tvdb_search_series__invalid_id_imdb(tvdb_token): def test_tvdb_search_series__success(tvdb_token): expected_top_level_keys = { "aliases", - "banner", - "firstAired", + "first_air_time", "id", - "image", + "image_url", "network", "overview", - "poster", - "seriesName", + "thumbnail", + "name", "slug", "status", } @@ -384,11 +346,10 @@ def test_tvdb_search_series__success(tvdb_token): assert isinstance(result, dict) assert "data" in result data = result["data"] - assert len(data) == 100 - actual_top_level_keys = set(data[0].keys()) - assert actual_top_level_keys == expected_top_level_keys + assert len(data) == result["links"]["page_size"] + assert set(data[0].keys()) >= expected_top_level_keys def test_tvdb_search_series__language(tvdb_token): results = tvdb_search_series(tvdb_token, "Witcher", language=RUSSIAN_LANG) - assert any(result["seriesName"] for result in results["data"]) + assert any(result["name"] for result in results["data"]) diff --git a/tests/network/test_providers__tvdb.py b/tests/network/test_providers__tvdb.py index 14f1fb8c..e9f9221d 100644 --- a/tests/network/test_providers__tvdb.py +++ b/tests/network/test_providers__tvdb.py @@ -80,7 +80,7 @@ def test_tvdb_provider__search__series(meta: dict, provider: Tvdb): def test_tvdb_provider__search__series_deep(provider: Tvdb): query = MetadataEpisode(series="House Rules (au)", season=6, episode=6) results = provider.search(query) - assert any(result.id_tvdb == 269795 for result in results) + assert any(result.id_tvdb == "269795" for result in results) @pytest.mark.parametrize("meta", EPISODE_META.values(), ids=list(EPISODE_META)) From 4ba67efce7880911a79adb358cfc8d372e5d44b5 Mon Sep 17 00:00:00 2001 From: asfixia Date: Fri, 10 Oct 2025 01:17:32 -0300 Subject: [PATCH 12/15] fix: Linting with ruff --- mnamer/endpoints.py | 36 ++++++++---- mnamer/language.py | 7 ++- mnamer/metadata.py | 2 +- mnamer/providers.py | 45 ++++++++++----- mnamer/target.py | 39 ++++++++----- tests/network/test_endpoints__tvdb.py | 80 +++++++++++++-------------- 6 files changed, 126 insertions(+), 83 deletions(-) diff --git a/mnamer/endpoints.py b/mnamer/endpoints.py index f8e07268..030ac611 100644 --- a/mnamer/endpoints.py +++ b/mnamer/endpoints.py @@ -209,7 +209,9 @@ def tvdb_login(api_key: str | None) -> str: status, content = request_json(url, body=body, cache=False) if status == 401: raise MnamerException("invalid api key") - elif status != 200 or not content.get("data") or not content.get("data").get("token"): # pragma: no cover + elif ( + status != 200 or not content.get("data") or not content.get("data").get("token") + ): # pragma: no cover raise MnamerNetworkException("TVDb down or unavailable?") return content.get("data").get("token") @@ -243,8 +245,12 @@ def tvdb_episodes_id( raise MnamerNotFoundException if language: - url_trans = f"https://api4.thetvdb.com/v4/episodes/{id_tvdb}/translations/{language.a3}" - trans_status, trans_content = request_json(url_trans, headers=headers, cache=cache) + url_trans = ( + f"https://api4.thetvdb.com/v4/episodes/{id_tvdb}/translations/{language.a3}" + ) + trans_status, trans_content = request_json( + url_trans, headers=headers, cache=cache + ) if trans_status == 200 and trans_content.get("data"): trans_data = trans_content["data"] @@ -275,7 +281,9 @@ def tvdb_series_id( url, headers=headers, cache=cache is True and language is None ) if language: - urlTranslated = f"https://api4.thetvdb.com/v4/series/{id_tvdb}/translations/{language.a3}" + urlTranslated = ( + f"https://api4.thetvdb.com/v4/series/{id_tvdb}/translations/{language.a3}" + ) statusTranslated, contentTranslated = request_json( urlTranslated, headers=headers, cache=cache is True and language is None ) @@ -324,19 +332,21 @@ def tvdb_series_id_episodes( elif status != 200: # pragma: no cover raise MnamerNetworkException("TVDb down or unavailable?") - if language: url_trans = f"https://api4.thetvdb.com/v4/series/{id_tvdb}/episodes/default/{language.a3}" - trans_status, trans_content = request_json(url_trans, headers=headers, cache=cache) + trans_status, trans_content = request_json( + url_trans, headers=headers, cache=cache + ) if trans_status == 200 and trans_content.get("data"): trans_data = trans_content["data"] for key, value in trans_data.items(): if value: content["data"][key] = value - content.get("data", {}).get("episodes", []).sort(key=lambda e: e['id']) + content.get("data", {}).get("episodes", []).sort(key=lambda e: e["id"]) return content + def tvdb_series_id_episodes_query( token: str, id_tvdb: str, @@ -361,7 +371,6 @@ def tvdb_series_id_episodes_query( current_page = max(page or 0, 0) matches: list[dict] = [] - parameters = {"page": current_page} status, content = request_json( url, parameters, headers=headers, cache=cache is True and language is None @@ -372,8 +381,10 @@ def tvdb_series_id_episodes_query( raise MnamerNotFoundException elif status != 200: raise MnamerNetworkException("TVDb down or unavailable?") - items = content.get('data', {}).get('episodes', []) - hasValidAbsoluteNumbers = (len(items) >= 1 and items[0].get("absoluteNumber") != 0) or (len(items) > 2 and items[0].get("absoluteNumber") != 0) + items = content.get("data", {}).get("episodes", []) + hasValidAbsoluteNumbers = ( + len(items) >= 1 and items[0].get("absoluteNumber") != 0 + ) or (len(items) > 2 and items[0].get("absoluteNumber") != 0) if not len(items): raise MnamerNotFoundException for item in items: @@ -389,6 +400,7 @@ def tvdb_series_id_episodes_query( content["data"]["episodes"] = matches return content + def tvdb_search_series( token: str, series: str | None = None, @@ -409,7 +421,9 @@ def tvdb_search_series( provided = [p is not None for p in (series, id_imdb, id_zap2it)] if sum(provided) != 1: - raise MnamerException("series, id_imdb, id_zap2it parameters are mutually exclusive") + raise MnamerException( + "series, id_imdb, id_zap2it parameters are mutually exclusive" + ) if series is not None: url = "https://api4.thetvdb.com/v4/search" diff --git a/mnamer/language.py b/mnamer/language.py index ad3222b9..21e33162 100644 --- a/mnamer/language.py +++ b/mnamer/language.py @@ -1,13 +1,12 @@ from __future__ import annotations import dataclasses +import re +from pathlib import PurePath from typing import Any from mnamer.exceptions import MnamerException -from pathlib import PurePath -import re - KNOWN_LANGUAGES = ( ("arabic", "ar", "ara"), ("chinese", "zh", "zho"), @@ -34,9 +33,11 @@ ("ukrainian", "uk", "ukr"), ) + def _guess_lang_from_windows_path(filePath: PurePath) -> str | None: try: from guessit import guessit + g = guessit(filePath.name, {"type": "subtitle"}) lang = g.get("subtitle_language") or g.get("language") if isinstance(lang, list) and lang: diff --git a/mnamer/metadata.py b/mnamer/metadata.py index 8a1f64d0..f43e3686 100644 --- a/mnamer/metadata.py +++ b/mnamer/metadata.py @@ -69,7 +69,7 @@ def __setattr__(self, key: str, value: Any): if value is not None and converter: try: value = converter(value) - except: + except Exception: value = None super().__setattr__(key, value) diff --git a/mnamer/providers.py b/mnamer/providers.py index 4d13822a..c9444401 100644 --- a/mnamer/providers.py +++ b/mnamer/providers.py @@ -28,7 +28,8 @@ from mnamer.metadata import Metadata, MetadataEpisode, MetadataMovie from mnamer.setting_store import SettingStore from mnamer.types import MediaType, ProviderType -from mnamer.utils import parse_date, year_range_parse, request_json +from mnamer.utils import parse_date, request_json, year_range_parse + class Provider(ABC): """ABC for Providers, high-level interfaces for metadata media providers.""" @@ -235,14 +236,28 @@ def search(self, query: MetadataEpisode) -> Iterator[MetadataEpisode]: ) elif query.series is not None and query.date is not None: results = self._search_series_date(query.series, query.date, query.language) - elif query.series is not None and query.year is not None and query.episode is not None: + elif ( + query.series is not None + and query.year is not None + and query.episode is not None + ): episode = self._get_episode_by_serie_episode_year( - f"{query.series} ({query.year})", query.year, query.episode, query.language + f"{query.series} ({query.year})", + query.year, + query.episode, + query.language, ) - results = self._search_id( - episode.get('seriesId'), episode.get('seasonNumber'), episode.get('number'), query.language - ) if episode else self._search_series( - query.series, query.season, query.episode, query.language + results = ( + self._search_id( + episode.get("seriesId"), + episode.get("seasonNumber"), + episode.get("number"), + query.language, + ) + if episode + else self._search_series( + query.series, query.season, query.episode, query.language + ) ) elif query.series is not None: results = self._search_series( @@ -299,12 +314,12 @@ def _search_id( raise MnamerNotFoundException def _get_episode_by_serie_episode_year( - self, - series: str, - year: int, - episode: int | None = None, - language: Language | None = None, - cache: bool = False + self, + series: str, + year: int, + episode: int | None = None, + language: Language | None = None, + cache: bool = False, ) -> int | None: """ Search a series on TVDB and return the season number aired in the given year. @@ -342,7 +357,7 @@ def _get_episode_by_serie_episode_year( ep for ep in episodes if ep.get("aired", "").startswith(str(year)) - and (episode is None or ep.get("number") == episode) + and (episode is None or ep.get("number") == episode) ] if not matched: @@ -350,7 +365,7 @@ def _get_episode_by_serie_episode_year( ep for ep in episodes if ep.get("aired", "").startswith(str(year)) - and (episode is None or ep.get("absoluteNumber") == episode) + and (episode is None or ep.get("absoluteNumber") == episode) ] if not matched: diff --git a/mnamer/target.py b/mnamer/target.py index 357de9b3..ea82dcc7 100644 --- a/mnamer/target.py +++ b/mnamer/target.py @@ -6,18 +6,17 @@ from shutil import move from typing import Any, ClassVar -from guessit import guessit # type: ignore - from charset_normalizer import from_path +from guessit import guessit # type: ignore from langdetect import detect +from mnamer import tty from mnamer.exceptions import MnamerException from mnamer.language import Language from mnamer.metadata import Metadata, MetadataEpisode, MetadataMovie from mnamer.providers import Provider from mnamer.setting_store import SettingStore from mnamer.types import MediaType, ProviderType -from mnamer import tty from mnamer.utils import ( crawl_in, filename_replace, @@ -54,7 +53,13 @@ def __init__(self, file_path: Path, settings: SettingStore | None = None): self._replace_before() self._override_metadata_ids() self._register_provider() - tty.msg("Parsed filename: " + str(file_path) + " as:\n" + str(self.metadata.as_dict()), debug=True) + tty.msg( + "Parsed filename: " + + str(file_path) + + " as:\n" + + str(self.metadata.as_dict()), + debug=True, + ) def __str__(self) -> str: if isinstance(self.source, Path): @@ -164,8 +169,8 @@ def _parse(self, file_path: Path): alternative_title = path_data.get("alternative_title") if alternative_title: self.metadata.series = f"{self.metadata.series} {alternative_title}" - #adding year to title can reduce false positives - #if path_data.get("year"): + # adding year to title can reduce false positives + # if path_data.get("year"): # self.metadata.series = f"{self.metadata.series} ({path_data.get("year")})" @staticmethod @@ -178,18 +183,20 @@ def _detect_subtitle_language(file_path: str): text = str(result) return Language.parse(detect(text)) - except: + except Exception: return None - - def _path_metadata(self, file_path): path_data: dict[str, Any] = { "language": self._settings.language, "container": file_path.suffix or None, - "type": self._settings.media + "type": self._settings.media, } - raw_data = dict(guessit(str_replace(str(file_path), self._settings.replace_before), path_data)) + raw_data = dict( + guessit( + str_replace(str(file_path), self._settings.replace_before), path_data + ) + ) if isinstance(raw_data.get("season"), list): raw_data = dict(guessit(str(file_path.parts[-1]), path_data)) for k, v in raw_data.items(): @@ -204,7 +211,11 @@ def _path_metadata(self, file_path): path_data[k] = v[0] if is_subtitle(self.source): try: - path_data["subtitle_language"] = self._detect_subtitle_language(str(file_path)) or Language.parse(raw_data.get("subtitle_language") or self.source.stem[-3:]) + path_data["subtitle_language"] = self._detect_subtitle_language( + str(file_path) + ) or Language.parse( + raw_data.get("subtitle_language") or self.source.stem[-3:] + ) path_data["language"] = self._settings.language except MnamerException: pass @@ -241,7 +252,9 @@ def _replace_before(self) -> None: continue if attr.startswith("_"): continue - setattr(self.metadata, attr, str_replace(value, self._settings.replace_before)) + setattr( + self.metadata, attr, str_replace(value, self._settings.replace_before) + ) def query(self) -> list[Metadata]: """Queries the target's respective media provider for metadata.""" diff --git a/tests/network/test_endpoints__tvdb.py b/tests/network/test_endpoints__tvdb.py index 0950b746..83f3b7dc 100644 --- a/tests/network/test_endpoints__tvdb.py +++ b/tests/network/test_endpoints__tvdb.py @@ -20,24 +20,24 @@ ] EXPECTED_TOP_LEVEL_SHOW_KEYS = { - 'absoluteNumber', - 'aired', - 'finaleType', - 'id', - 'image', - 'imageType', - 'isMovie', - 'lastUpdated', - 'name', - 'nameTranslations', - 'number', - 'overview', - 'overviewTranslations', - 'runtime', - 'seasonNumber', - 'seasons', - 'seriesId', - 'year' + "absoluteNumber", + "aired", + "finaleType", + "id", + "image", + "imageType", + "isMovie", + "lastUpdated", + "name", + "nameTranslations", + "number", + "overview", + "overviewTranslations", + "runtime", + "seasonNumber", + "seasons", + "seriesId", + "year", } LOST_TVDB_ID_EPISODE = "127131" @@ -138,27 +138,27 @@ def test_tvdb_series_id__no_hits(tvdb_token): def test_tvdb_series_id__success(tvdb_token): expected_top_level_keys = { - 'aliases', - 'averageRuntime', - 'defaultSeasonType', - 'episodes', - 'firstAired', - 'id', - 'image', - 'isOrderRandomized', - 'lastAired', - 'lastUpdated', - 'name', - 'nameTranslations', - 'nextAired', - 'originalCountry', - 'originalLanguage', - 'overview', - 'overviewTranslations', - 'score', - 'slug', - 'status', - 'year' + "aliases", + "averageRuntime", + "defaultSeasonType", + "episodes", + "firstAired", + "id", + "image", + "isOrderRandomized", + "lastAired", + "lastUpdated", + "name", + "nameTranslations", + "nextAired", + "originalCountry", + "originalLanguage", + "overview", + "overviewTranslations", + "score", + "slug", + "status", + "year", } result = tvdb_series_id(tvdb_token, LOST_TVDB_ID_SERIES) @@ -346,7 +346,7 @@ def test_tvdb_search_series__success(tvdb_token): assert isinstance(result, dict) assert "data" in result data = result["data"] - assert len(data) == result["links"]["page_size"] + assert len(data) == result["links"]["page_size"] assert set(data[0].keys()) >= expected_top_level_keys From f17ee8d00b74ca4e413b33485bcc55ad01296a47 Mon Sep 17 00:00:00 2001 From: asfixia Date: Sat, 11 Oct 2025 23:43:14 -0300 Subject: [PATCH 13/15] feat: Allows other relocation methods --relocation-operation=(copy | copy-with-metadata | move | hardlink | symlink ) Relocation Methods: Copy: Creates a copy of the file. copy-with-metadata: Copy preserving the metadata. move: The default relocation, move the file to the destination folder. hardlink: Creates a hardlink to the file ( OS limitations still applies, for example: only between folders at the same driver) symlink: Creates a symlink to the file ( PS: OS limitations still applies, windows depends of having admin rights ) Merge branch 'pr/150' into Adding-relocation-options-as-one-of-symlink-hardlink-move-copy fix: Language parsing for undefined, we set it as default "english" chore: Refactoring code and adding tests for absolute ordering and accept utf-8 characters --- mnamer/endpoints.py | 31 ++++++- mnamer/setting_store.py | 12 ++- mnamer/target.py | 19 ++++- mnamer/types.py | 8 ++ tests/e2e/conftest.py | 2 +- tests/e2e/test_directives.py | 17 ++++ .../{test_moving.py => test_relocation.py} | 84 ++++++++++++++++++- 7 files changed, 162 insertions(+), 11 deletions(-) rename tests/e2e/{test_moving.py => test_relocation.py} (61%) diff --git a/mnamer/endpoints.py b/mnamer/endpoints.py index 030ac611..2db06c62 100644 --- a/mnamer/endpoints.py +++ b/mnamer/endpoints.py @@ -3,6 +3,7 @@ import datetime from re import match from time import sleep +import Levenshtein from mnamer.exceptions import ( MnamerException, @@ -382,14 +383,12 @@ def tvdb_series_id_episodes_query( elif status != 200: raise MnamerNetworkException("TVDb down or unavailable?") items = content.get("data", {}).get("episodes", []) - hasValidAbsoluteNumbers = ( - len(items) >= 1 and items[0].get("absoluteNumber") != 0 - ) or (len(items) > 2 and items[0].get("absoluteNumber") != 0) + hasValidAbsoluteEpisode = episode != 0 and len(items) > 0 and max([i.get("absoluteNumber", 0) for i in items]) > 0 if not len(items): raise MnamerNotFoundException for item in items: sn_ok = True if season is None else item.get("seasonNumber") == season - if season is None and episode is not None and hasValidAbsoluteNumbers: + if season is None and episode is not None and hasValidAbsoluteEpisode: ep_ok = item.get("absoluteNumber") == episode else: ep_ok = True if episode is None else item.get("number") == episode @@ -448,6 +447,30 @@ def tvdb_search_series( raise MnamerNotFoundException elif status != 200: # pragma: no cover raise MnamerNetworkException("TVDb down or unavailable?") + + def get_titles(serie_entry): + """Return all possible title variations (name, aliases, translations).""" + titles = [] + if name := serie_entry.get("name"): + titles.append(name) + titles += serie_entry.get("aliases", []) + titles += list(serie_entry.get("translations", {}).values()) + return [t.lower().strip() for t in titles if t] + + def sort_by_similarity(matched_series, target_name): + if target_name is None: + return + target_name = target_name.lower().strip() + return sorted( + matched_series, + key=lambda s: min( + Levenshtein.distance(title, target_name.lower().strip()) + for title in get_titles(s) + if title + ) + ) + + #content["data"] = sort_by_similarity(content["data"], series) return content diff --git a/mnamer/setting_store.py b/mnamer/setting_store.py index 7b2e60b1..34db952c 100644 --- a/mnamer/setting_store.py +++ b/mnamer/setting_store.py @@ -10,7 +10,7 @@ from mnamer.language import Language from mnamer.metadata import Metadata from mnamer.setting_spec import SettingSpec -from mnamer.types import MediaType, ProviderType, SettingType +from mnamer.types import MediaType, ProviderType, SettingType, RelocateType from mnamer.utils import crawl_out, json_loads, normalize_containers @@ -218,6 +218,16 @@ class SettingStore: help="--episode-format: set episode renaming format specification", ).as_dict(), ) + relocation_strategy: str = dataclasses.field( + default=RelocateType.MOVE.value, + metadata=SettingSpec( + dest="relocation_strategy", + choices=[ix.value for ix in RelocateType], + flags=["--relocation-operation"], + group=SettingType.PARAMETER, + help=f"--relocation-operation={'|'.join([ix.value for ix in RelocateType])}: when given, link, copy or move files. Default move. (PS1: Symlink doesnt works on windows) (PS2: Hardlinks can only be created between folders on the same drive.)", + )(), + ) # directive attributes ----------------------------------------------------- diff --git a/mnamer/target.py b/mnamer/target.py index ea82dcc7..13ef0c0e 100644 --- a/mnamer/target.py +++ b/mnamer/target.py @@ -3,7 +3,6 @@ import datetime as dt from os import path from pathlib import Path -from shutil import move from typing import Any, ClassVar from charset_normalizer import from_path @@ -16,7 +15,7 @@ from mnamer.metadata import Metadata, MetadataEpisode, MetadataMovie from mnamer.providers import Provider from mnamer.setting_store import SettingStore -from mnamer.types import MediaType, ProviderType +from mnamer.types import MediaType, ProviderType, RelocateType from mnamer.utils import ( crawl_in, filename_replace, @@ -220,9 +219,10 @@ def _path_metadata(self, file_path): except MnamerException: pass try: + path_data["language"] = Language.parse(path_data["language"]) Language.ensure_valid_for_tvdb(path_data["language"]) except MnamerException: - path_data["language"] = self._settings.language + path_data["language"] = self._settings.language or Language.parse("eng") return path_data def _override_metadata_ids(self): @@ -274,9 +274,20 @@ def query(self) -> list[Metadata]: def relocate(self) -> None: """Performs the action of renaming and/or moving a file.""" + def get_method(relocation_strategy: RelocateType): + from shutil import move, copy, copy2 + from os import link, symlink + strategies = { + RelocateType.MOVE: move, + RelocateType.HARDLINK: link, + RelocateType.SYMBOLICLINK: symlink, + RelocateType.COPY: copy, + RelocateType.COPY2: copy2, + } + return strategies[RelocateType(relocation_strategy)] destination_path = Path(self.destination).resolve() destination_path.parent.mkdir(parents=True, exist_ok=True) try: - move(str(self.source), destination_path) + get_method(self._settings.relocation_strategy)(str(self.source), destination_path) except OSError as e: # pragma: no cover raise MnamerException from e diff --git a/mnamer/types.py b/mnamer/types.py index 6f10d21f..b9771e0e 100644 --- a/mnamer/types.py +++ b/mnamer/types.py @@ -29,6 +29,14 @@ class ProviderType(Enum): OMDB = "omdb" +class RelocateType(Enum): + MOVE = "move" + HARDLINK = "hardlink" + SYMBOLICLINK = "symlink" + COPY = "copy" + COPY2 = "copy-with-metadata" + + class SettingType(Enum): DIRECTIVE = "directive" PARAMETER = "parameter" diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 5cead780..f4b7beb6 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -51,7 +51,7 @@ def fn(*args): code = e.code out += strip_format(capsys.readouterr().out.strip()) out += strip_format(capsys.readouterr().err.strip()) - with open(E2E_LOG, "a+") as fp: + with open(E2E_LOG, "a+", encoding="utf-8") as fp: fp.write("=" * 10 + "\n") fp.write(request.node.name + "\n") fp.write("-" * 10 + "\n") diff --git a/tests/e2e/test_directives.py b/tests/e2e/test_directives.py index 391d23e4..3ae9e353 100644 --- a/tests/e2e/test_directives.py +++ b/tests/e2e/test_directives.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from unittest.mock import MagicMock, patch import pytest @@ -84,3 +85,19 @@ def test_test(e2e_run): result = e2e_run("--batch", "--test", ".") assert result.code == 0 assert "testing mode" in result.out + + +@pytest.mark.usefixtures("setup_test_dir") +def test_series_jp_absolute_numbering(e2e_run, setup_test_files): + setup_test_files("Attack on Titan - Episode 80 - 1080p BDRip x265 FLAC 2.0 Kira [SEV].mkv") + result = e2e_run("--batch", "--lower", "--episode-api=tvdb", ".") + assert result.code == 0 + assert '\u9032\u6483\u306e\u5de8\u4eba - s04e21 - \u4e8c\u5343\u5e74\u524d\u306e\u541b\u304b\u3089.mkv' in result.out #Attack on titan in jp escaped name + + +@pytest.mark.usefixtures("setup_test_dir") +def test_series_absolute_numbering(e2e_run, setup_test_files): + setup_test_files("Attack on Titan - Episode 80 - 1080p BDRip x265 FLAC 2.0 Kira [SEV].mkv") + result = e2e_run("--batch", "--lower", "--episode-api=tvdb", "--language=eng", ".") + assert result.code == 0 + assert "attack on titan - s04e21 - from you, 2,000 years ago.mkv" in result.out diff --git a/tests/e2e/test_moving.py b/tests/e2e/test_relocation.py similarity index 61% rename from tests/e2e/test_moving.py rename to tests/e2e/test_relocation.py index 7b8ccfb6..a9e3a551 100644 --- a/tests/e2e/test_moving.py +++ b/tests/e2e/test_relocation.py @@ -1,6 +1,7 @@ from pathlib import Path import pytest +import platform from mnamer.const import SUBTITLE_CONTAINERS @@ -9,6 +10,8 @@ pytest.mark.flaky(reruns=2, reruns_delay=5), ] +def files_in_cwd(): + return [f for f in Path.cwd().iterdir() if f.is_file()] @pytest.mark.usefixtures("setup_test_dir") def test_absolute_path(e2e_run, setup_test_files): @@ -158,7 +161,6 @@ def test_format_id(e2e_run, setup_test_files): @pytest.mark.tvdb -@pytest.mark.xfail(strict=False) @pytest.mark.usefixtures("setup_test_dir") def test_format_id__tvdb(e2e_run, setup_test_files): setup_test_files("archer.2009.s10e07.webrip.x264-lucidtv.mp4") @@ -211,3 +213,83 @@ def test_ambiguous_language_deletction(e2e_run, setup_test_files): ) result = e2e_run("--batch", ".") assert result.code == 0 + + +@pytest.mark.usefixtures("setup_test_dir") +def test_relocation_operation_copy(e2e_run, setup_test_files): + setup_test_files("aladdin.2019.avi") + files_before = files_in_cwd() + result = e2e_run("--relocation-operation=copy", "--batch", "--lower", ".") + assert result.code == 0 + assert "aladdin (2019).avi" in result.out + files_after = [f for f in files_in_cwd() if f not in files_before] + assert len(files_after) == len(files_before) and len(files_after) == 1 + assert files_before[0].exists() and files_after[0].exists() + + +@pytest.mark.usefixtures("setup_test_dir") +def test_relocation_operation_copy2(e2e_run, setup_test_files): + setup_test_files("aladdin.2019.avi") + files_before = files_in_cwd() + result = e2e_run("--relocation-operation=copy-with-metadata", "--batch", "--lower", ".") + assert result.code == 0 + assert "aladdin (2019).avi" in result.out + files_after = [f for f in files_in_cwd() if f not in files_before] + assert len(files_after) == len(files_before) and len(files_after) == 1 + assert files_before[0].stat().st_size == files_after[0].stat().st_size + assert files_before[0].stat().st_mode == files_after[0].stat().st_mode + assert int(files_before[0].stat().st_mtime) == int(files_after[0].stat().st_mtime) + assert files_before[0].stat().st_uid == files_after[0].stat().st_uid + assert files_before[0].stat().st_gid == files_after[0].stat().st_gid + + +@pytest.mark.usefixtures("setup_test_dir") +def test_relocation_operation_symlink(e2e_run, setup_test_files): + if platform.system() == "Windows": + return + setup_test_files("aladdin.2019.avi") + files_before = files_in_cwd() + result = e2e_run("--relocation-operation=symlink", "--batch", "--lower", ".") + assert result.code == 0 + assert "aladdin (2019).avi" in result.out + files_after = [f for f in files_in_cwd() if f not in files_before] + assert len(files_after) == len(files_before) and len(files_after) == 1 + assert files_after[0].is_symlink() and not files_before[0].is_symlink() + assert files_after[0].resolve() == files_before[0].resolve() + txt_example = "Test content for hardlink" + files_before[0].write_text(txt_example, encoding="utf-8") + assert files_after[0].read_text(encoding="utf-8") == txt_example + files_before[0].unlink() + assert not files_before[0].exists() + assert files_before[0].read_text(encoding="utf-8") != txt_example + + +@pytest.mark.usefixtures("setup_test_dir") +def test_relocation_operation_hardlink(e2e_run, setup_test_files): + setup_test_files("aladdin.2019.avi") + files_before = files_in_cwd() + result = e2e_run("--relocation-operation=hardlink", "--batch", "--lower", ".") + assert result.code == 0 + assert "aladdin (2019).avi" in result.out + files_after = [f for f in files_in_cwd() if f not in files_before] + assert len(files_after) == len(files_before) and len(files_after) == 1 + txt_example = "Test content for hardlink" + files_before[0].write_text(txt_example, encoding="utf-8") + assert files_after[0].read_text(encoding="utf-8") == txt_example + assert (files_before[0].stat().st_ino == files_after[0].stat().st_ino) and (files_before[0].stat().st_dev == files_after[0].stat().st_dev) + assert files_before[0].stat().st_nlink == 2 and files_after[0].stat().st_nlink == 2 + files_before[0].unlink() + assert files_after[0].stat().st_nlink == 1 and files_after[0].exists() and not files_before[0].exists() + assert files_after[0].read_text(encoding="utf-8") == txt_example + + +@pytest.mark.usefixtures("setup_test_dir") +def test_relocation_operation_move(e2e_run, setup_test_files): + setup_test_files("aladdin.2019.avi") + files_before = files_in_cwd() + result = e2e_run("--relocation-operation=move", "--batch", "--lower", ".") + assert result.code == 0 + assert "aladdin (2019).avi" in result.out + files_after = [f for f in files_in_cwd() if f not in files_before] + assert len(files_after) == len(files_before) and len(files_after) == 1 + assert not files_before[0].exists() and files_after[0].exists() From 5c8dbc4cf13bb45aa9931273a2544312a5f29e30 Mon Sep 17 00:00:00 2001 From: asfixia Date: Sun, 12 Oct 2025 00:21:52 -0300 Subject: [PATCH 14/15] fix: Fixed the test with serie on different language --- tests/e2e/test_relocation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/test_relocation.py b/tests/e2e/test_relocation.py index 72256d36..1a33344c 100644 --- a/tests/e2e/test_relocation.py +++ b/tests/e2e/test_relocation.py @@ -15,11 +15,11 @@ def files_in_cwd(): @pytest.mark.usefixtures("setup_test_dir") -def test_complex_metadata(e2e_run, setup_test_files): +def test_matching_different_language(e2e_run, setup_test_files): setup_test_files( "Quien a hierro mata [MicroHD 1080p][DTS 5.1-Castellano-AC3 5.1-Castellano+Subs][ES-EN].mkv" ) - result = e2e_run("--batch", "--media=movie", ".") + result = e2e_run("--batch", "--language=spa", "--media=movie", ".") assert result.code == 0 assert "Quien a Hierro Mata (2019).mkv" in result.out assert "1 out of 1 files processed successfully" in result.out From 6c8331f115dd2331c03441446cee861d4a81e0a5 Mon Sep 17 00:00:00 2001 From: asfixia Date: Sun, 12 Oct 2025 00:33:27 -0300 Subject: [PATCH 15/15] fix: Refactoring with ruff tool --- mnamer/endpoints.py | 11 ++++++++--- mnamer/setting_store.py | 2 +- mnamer/target.py | 9 +++++++-- tests/e2e/test_directives.py | 14 ++++++++++---- tests/e2e/test_relocation.py | 17 +++++++++++++---- 5 files changed, 39 insertions(+), 14 deletions(-) diff --git a/mnamer/endpoints.py b/mnamer/endpoints.py index 2db06c62..967c63fb 100644 --- a/mnamer/endpoints.py +++ b/mnamer/endpoints.py @@ -3,6 +3,7 @@ import datetime from re import match from time import sleep + import Levenshtein from mnamer.exceptions import ( @@ -383,7 +384,11 @@ def tvdb_series_id_episodes_query( elif status != 200: raise MnamerNetworkException("TVDb down or unavailable?") items = content.get("data", {}).get("episodes", []) - hasValidAbsoluteEpisode = episode != 0 and len(items) > 0 and max([i.get("absoluteNumber", 0) for i in items]) > 0 + hasValidAbsoluteEpisode = ( + episode != 0 + and len(items) > 0 + and max([i.get("absoluteNumber", 0) for i in items]) > 0 + ) if not len(items): raise MnamerNotFoundException for item in items: @@ -467,10 +472,10 @@ def sort_by_similarity(matched_series, target_name): Levenshtein.distance(title, target_name.lower().strip()) for title in get_titles(s) if title - ) + ), ) - #content["data"] = sort_by_similarity(content["data"], series) + # content["data"] = sort_by_similarity(content["data"], series) return content diff --git a/mnamer/setting_store.py b/mnamer/setting_store.py index 34db952c..b6dba27d 100644 --- a/mnamer/setting_store.py +++ b/mnamer/setting_store.py @@ -10,7 +10,7 @@ from mnamer.language import Language from mnamer.metadata import Metadata from mnamer.setting_spec import SettingSpec -from mnamer.types import MediaType, ProviderType, SettingType, RelocateType +from mnamer.types import MediaType, ProviderType, RelocateType, SettingType from mnamer.utils import crawl_out, json_loads, normalize_containers diff --git a/mnamer/target.py b/mnamer/target.py index 3e47c53e..635d2b0b 100644 --- a/mnamer/target.py +++ b/mnamer/target.py @@ -275,9 +275,11 @@ def query(self) -> list[Metadata]: def relocate(self) -> None: """Performs the action of renaming and/or moving a file.""" + def get_method(relocation_strategy: RelocateType): - from shutil import move, copy, copy2 from os import link, symlink + from shutil import copy, copy2, move + strategies = { RelocateType.MOVE: move, RelocateType.HARDLINK: link, @@ -286,9 +288,12 @@ def get_method(relocation_strategy: RelocateType): RelocateType.COPY2: copy2, } return strategies[RelocateType(relocation_strategy)] + destination_path = Path(self.destination).resolve() destination_path.parent.mkdir(parents=True, exist_ok=True) try: - get_method(self._settings.relocation_strategy)(str(self.source), destination_path) + get_method(self._settings.relocation_strategy)( + str(self.source), destination_path + ) except OSError as e: # pragma: no cover raise MnamerException from e diff --git a/tests/e2e/test_directives.py b/tests/e2e/test_directives.py index 3ae9e353..6d374e51 100644 --- a/tests/e2e/test_directives.py +++ b/tests/e2e/test_directives.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from unittest.mock import MagicMock, patch import pytest @@ -89,15 +88,22 @@ def test_test(e2e_run): @pytest.mark.usefixtures("setup_test_dir") def test_series_jp_absolute_numbering(e2e_run, setup_test_files): - setup_test_files("Attack on Titan - Episode 80 - 1080p BDRip x265 FLAC 2.0 Kira [SEV].mkv") + setup_test_files( + "Attack on Titan - Episode 80 - 1080p BDRip x265 FLAC 2.0 Kira [SEV].mkv" + ) result = e2e_run("--batch", "--lower", "--episode-api=tvdb", ".") assert result.code == 0 - assert '\u9032\u6483\u306e\u5de8\u4eba - s04e21 - \u4e8c\u5343\u5e74\u524d\u306e\u541b\u304b\u3089.mkv' in result.out #Attack on titan in jp escaped name + assert ( + "\u9032\u6483\u306e\u5de8\u4eba - s04e21 - \u4e8c\u5343\u5e74\u524d\u306e\u541b\u304b\u3089.mkv" + in result.out + ) # Attack on titan in jp escaped name @pytest.mark.usefixtures("setup_test_dir") def test_series_absolute_numbering(e2e_run, setup_test_files): - setup_test_files("Attack on Titan - Episode 80 - 1080p BDRip x265 FLAC 2.0 Kira [SEV].mkv") + setup_test_files( + "Attack on Titan - Episode 80 - 1080p BDRip x265 FLAC 2.0 Kira [SEV].mkv" + ) result = e2e_run("--batch", "--lower", "--episode-api=tvdb", "--language=eng", ".") assert result.code == 0 assert "attack on titan - s04e21 - from you, 2,000 years ago.mkv" in result.out diff --git a/tests/e2e/test_relocation.py b/tests/e2e/test_relocation.py index 1a33344c..65d23eaa 100644 --- a/tests/e2e/test_relocation.py +++ b/tests/e2e/test_relocation.py @@ -1,7 +1,7 @@ +import platform from pathlib import Path import pytest -import platform from mnamer.const import SUBTITLE_CONTAINERS @@ -10,6 +10,7 @@ pytest.mark.flaky(reruns=2, reruns_delay=5), ] + def files_in_cwd(): return [f for f in Path.cwd().iterdir() if f.is_file()] @@ -242,7 +243,9 @@ def test_relocation_operation_copy(e2e_run, setup_test_files): def test_relocation_operation_copy2(e2e_run, setup_test_files): setup_test_files("aladdin.2019.avi") files_before = files_in_cwd() - result = e2e_run("--relocation-operation=copy-with-metadata", "--batch", "--lower", ".") + result = e2e_run( + "--relocation-operation=copy-with-metadata", "--batch", "--lower", "." + ) assert result.code == 0 assert "aladdin (2019).avi" in result.out files_after = [f for f in files_in_cwd() if f not in files_before] @@ -287,10 +290,16 @@ def test_relocation_operation_hardlink(e2e_run, setup_test_files): txt_example = "Test content for hardlink" files_before[0].write_text(txt_example, encoding="utf-8") assert files_after[0].read_text(encoding="utf-8") == txt_example - assert (files_before[0].stat().st_ino == files_after[0].stat().st_ino) and (files_before[0].stat().st_dev == files_after[0].stat().st_dev) + assert (files_before[0].stat().st_ino == files_after[0].stat().st_ino) and ( + files_before[0].stat().st_dev == files_after[0].stat().st_dev + ) assert files_before[0].stat().st_nlink == 2 and files_after[0].stat().st_nlink == 2 files_before[0].unlink() - assert files_after[0].stat().st_nlink == 1 and files_after[0].exists() and not files_before[0].exists() + assert ( + files_after[0].stat().st_nlink == 1 + and files_after[0].exists() + and not files_before[0].exists() + ) assert files_after[0].read_text(encoding="utf-8") == txt_example