Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
9f19b5f
added --symlink
zebdo Jan 31, 2023
ff308d8
add symlink import from os
zebdo Jan 31, 2023
490d276
Update target.py
zebdo Jan 31, 2023
8c42383
added an os.path.islink check
zebdo Feb 25, 2023
bfb1d7c
Update target.py
zebdo Feb 25, 2023
4068b34
zz
zebdo Feb 25, 2023
1474f95
Update types-requests requirement from ~=2.28 to ~=2.31
dependabot[bot] Aug 21, 2023
e3d7c4f
Update setuptools requirement from ~=68.1.0 to ~=68.2.2
dependabot[bot] Sep 18, 2023
e41f6cc
Update pylint requirement from ~=2.15.10 to ~=3.0.2
dependabot[bot] Oct 23, 2023
1c581b5
Update mypy requirement from ~=0.991 to ~=1.7
dependabot[bot] Nov 13, 2023
a2dc1d0
Guess text with lingua
Dec 31, 2023
cd05a0a
Add langdetect
Dec 31, 2023
50c4e82
Add fasttext
Dec 31, 2023
acd5890
Linted
Dec 31, 2023
afed587
Add langid, and refactor common code to base class
Dec 31, 2023
2ae96e5
Use py3langid instead of langid
Dec 31, 2023
4ba7985
Rename min_confidence -> min_probability
Dec 31, 2023
bd0c42a
Add docstrings
Dec 31, 2023
5e09244
Get available guessers from text_lang_guesser module
Dec 31, 2023
947de8f
Configure optional dependencies
Dec 31, 2023
3bb6624
Merge branch 'jkwill87:main' into main
zebdo Jan 21, 2024
184ddd2
add symlink checks when attempting to move a file
zebdo Jan 21, 2024
b41177a
Python 3.13 compatibility
cybe Oct 21, 2024
790b23b
original filename
Dec 25, 2024
d1c9b96
allow attributing for shows, such as date.year, date.hour
Feb 5, 2025
1c0d53f
date no years
Feb 7, 2025
0cbb6f5
dating
Feb 8, 2025
af49d96
Merge pull request #1 from xuniversus/main
zebdo Jun 6, 2025
97308df
Merge pull request #3 from jkwill87/dependabot/pip/mypy-approx-eq-1.7
zebdo Jun 6, 2025
3c64901
Merge pull request #4 from jkwill87/dependabot/pip/pylint-approx-eq-3…
zebdo Jun 6, 2025
e9d1371
Merge pull request #5 from jkwill87/dependabot/pip/setuptools-approx-…
zebdo Jun 6, 2025
1d08c99
Merge pull request #6 from jkwill87/dependabot/pip/types-requests-app…
zebdo Jun 6, 2025
55e3a1e
Merge pull request #13 from meitham/main
zebdo Jun 6, 2025
c6007c9
Merge pull request #12 from cybe/patch-1
zebdo Jun 6, 2025
bc9c08f
Merge pull request #10 from big-eater/subtitle-text-guesser
zebdo Jun 6, 2025
3a674f5
fix islink typo (close #313)
maphew Jun 30, 2025
2add6eb
additional date search logic
Jul 8, 2025
c499e02
extra logic to avoid extra year tagging, and finding the right match …
Jul 8, 2025
2b1d702
Merge pull request #15 from Cronvs/main
zebdo Aug 30, 2025
92c164c
Merge pull request #14 from maphew/maphew-patch-1
zebdo Aug 30, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mnamer/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ def tmdb_movies(
def tmdb_search_movies(
api_key: str,
title: str,
year: int | str | None = None,
year: int | None = None,
language: Language | None = None,
region: str | None = None,
adult: bool = False,
Expand Down
10 changes: 10 additions & 0 deletions mnamer/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,13 @@ class MnamerNetworkException(MnamerException):

class MnamerNotFoundException(MnamerException):
"""Raised when a lookup or search works as expected yet yields no results."""


class MnamerFailedLangGuesserInstantiation(MnamerException):
"""
Raised when a requested text language guesser failed to instantiate.
"""


class MnamerNoSuchLangGuesser(MnamerException):
"""Raised when a requested text language guesser name does not match any known guessers."""
34 changes: 28 additions & 6 deletions mnamer/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
str_fix_padding,
str_replace_slashes,
str_title_case,
year_parse,
)


Expand Down Expand Up @@ -44,6 +43,7 @@ class Metadata:
language_sub: Language | None = None
quality: str | None = None
synopsis: str | None = None
original_filename: str | None = None

@classmethod
def to_media_type(cls) -> MediaType:
Expand Down Expand Up @@ -111,21 +111,32 @@ class MetadataMovie(Metadata):
"""

name: str | None = None
year: str | None = None
date: dt.date | None = None
id_imdb: str | None = None
id_tmdb: str | None = None

def __post_init__(self):
if isinstance(self.date, str):
self.date = parse_date(self.date)

def __format__(self, format_spec: str | None):
default = "{name} ({year})"
re_pattern = r"({(\w+)(?:\[[\w:]+\])?(?:\:\d{1,2})?})"
default = "{name} ({date.year})"
re_pattern = r"({(\w+)(?:\[[\w:]+\]|\.\w+)?(?:\:\d{1,2})?})"
tname = ''
if ( format_spec is None or re.search("{(date.year|date)}", format_spec) is not None ) \
and self.name is not None and self.date is not None \
and self.name.endswith(f" ({self.date.year})"):
tname = f" ({self.date.year})"
self.name = self.name[:-len(tname)]
s = re.sub(re_pattern, self._format_repl, format_spec or default)
s = str_fix_padding(s)
self.name+=tname
return s

def __setattr__(self, key: str, value: Any):
converter_map: dict[str, Callable] = {
"name": fn_pipe(str_replace_slashes, str_title_case),
"year": year_parse,
"date": parse_date,
}
converter: Callable | None = converter_map.get(key)
if value is not None and converter:
Expand All @@ -141,6 +152,7 @@ class MetadataEpisode(Metadata):
"""

series: str | None = None
series_date: dt.date | None = None
season: int | None = None
episode: int | None = None
date: dt.date | None = None
Expand All @@ -155,12 +167,21 @@ def __post_init__(self):
self.episode = int(self.episode)
if isinstance(self.date, str):
self.date = parse_date(self.date)
if isinstance(self.series_date, str):
self.series_date = parse_date(self.series_date)

def __format__(self, format_spec: str | None):
default = "{series} - {season:02}x{episode:02} - {title}"
re_pattern = r"({(\w+)(?:\[[\w:]+\])?(?:\:\d{1,2})?})"
re_pattern = r"({(\w+)(?:\[[\w:]+\]|\.\w+)?(?:\:\d{1,2})?})"
tseries = ''
if ( format_spec is None or re.search("{(series_date.year|series_date|date.year|date)}", format_spec) is not None ) \
and self.series is not None and self.series_date is not None \
and self.series.endswith(f" ({self.series_date.year})"):
tseries = f" ({self.series_date.year})"
self.series = self.series[:-len(tseries)]
s = re.sub(re_pattern, self._format_repl, format_spec or default)
s = str_fix_padding(s)
self.series+=tseries
return s

def __setattr__(self, key: str, value: Any):
Expand All @@ -169,6 +190,7 @@ def __setattr__(self, key: str, value: Any):
"episode": int,
"season": int,
"series": fn_pipe(str_replace_slashes, str_title_case),
"series_date": parse_date,
"title": fn_pipe(str_replace_slashes, str_title_case),
}
converter: Callable | None = converter_map.get(key)
Expand Down
70 changes: 50 additions & 20 deletions mnamer/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +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


class Provider(ABC):
Expand Down Expand Up @@ -83,7 +83,7 @@ def search(self, query: MetadataMovie) -> Iterator[MetadataMovie]:
if query.id_imdb:
results = self._lookup_movie(query.id_imdb)
elif query.name:
results = self._search_movie(query.name, query.year)
results = self._search_movie(query.name, None if query.date is None else query.date.year)
else:
raise MnamerNotFoundException
yield from results
Expand All @@ -94,25 +94,27 @@ def _lookup_movie(self, id_imdb: str) -> Iterator[MetadataMovie]:
try:
release_date = dt.datetime.strptime(
response["Released"], "%d %b %Y"
).strftime("%Y-%m-%d")
)
except (KeyError, ValueError):
if response.get("Year") in (None, "N/A"):
release_date = None
else:
release_date = "{}-01-01".format(response["Year"])
release_date = dt.datetime.strptime(
"{}-01-01".format(response["Year"]), "%Y-%m-%d"
)
meta = MetadataMovie(
name=response["Title"],
year=release_date,
date=release_date,
synopsis=response["Plot"],
id_imdb=response["imdbID"],
)
if meta.synopsis == "N/A":
meta.synopsis = None
yield meta

def _search_movie(self, name: str, year: str | None) -> Iterator[MetadataMovie]:
def _search_movie(self, name: str, year: int | None) -> Iterator[MetadataMovie]:
assert self.api_key
year_from, year_to = year_range_parse(year, 5)
year_from, year_to = year - 5, year + 5
found = False
page = 1
page_max = 10 # each page yields a maximum of 10 results
Expand Down Expand Up @@ -153,7 +155,7 @@ def search(self, query: MetadataMovie) -> Iterator[MetadataMovie]:
if query.id_tmdb:
results = self._search_id(query.id_tmdb, query.language)
elif query.name:
results = self._search_name(query.name, query.year, query.language)
results = self._search_name(query.name, None if query.date is None else query.date.year, query.language)
else:
raise MnamerNotFoundException
yield from results
Expand All @@ -166,13 +168,13 @@ def _search_id(
yield MetadataMovie(
name=response["title"],
language=language,
year=response["release_date"],
date=response["release_date"],
synopsis=response["overview"],
id_tmdb=response["id"],
id_imdb=response["imdb_id"],
)

def _search_name(self, name: str, year: str | None, language: Language | None):
def _search_name(self, name: str, year: int | None, language: Language | None):
assert self.api_key
page = 1
page_max = 5 # each page yields a maximum of 20 results
Expand All @@ -193,9 +195,9 @@ def _search_name(self, name: str, year: str | None, language: Language | None):
name=entry["title"],
language=language,
synopsis=entry["overview"],
year=entry["release_date"],
date=entry["release_date"],
)
if not meta.year:
if not meta.date:
continue
yield meta
found = True
Expand Down Expand Up @@ -229,13 +231,17 @@ def search(self, query: MetadataEpisode) -> Iterator[MetadataEpisode]:
if not self.token:
self.token = self._login()
if query.id_tvdb and query.date:
results = self._search_tvdb_date(query.id_tvdb, query.date, query.language)
results = self._search_tvdb_date(
query.id_tvdb, query.date, query.language, query.season, query.episode
)
elif query.id_tvdb:
results = self._search_id(
query.id_tvdb, query.season, query.episode, query.language
)
elif query.series and query.date:
results = self._search_series_date(query.series, query.date, query.language)
results = self._search_series_date(
query.series, query.date, query.language, query.season, query.episode
)
elif query.series:
results = self._search_series(
query.series, query.season, query.episode, query.language
Expand Down Expand Up @@ -275,6 +281,7 @@ def _search_id(
id_tvdb=id_tvdb,
season=entry["airedSeason"],
series=series_data["data"]["seriesName"],
series_date=series_data["data"]["firstAired"],
language=language,
synopsis=(entry["overview"] or "")
.replace("\r\n", "")
Expand Down Expand Up @@ -316,19 +323,41 @@ def _search_series(
raise MnamerNotFoundException

def _search_tvdb_date(
self, id_tvdb: str, release_date: dt.date, language: Language | None
self,
id_tvdb: str,
release_date: dt.date,
language: Language | None,
season: int | None = None,
episode: int | None = None
):
release_date = parse_date(release_date)
found = False
for meta in self._search_id(id_tvdb, language=language):
if meta.date and meta.date == release_date:
found = True
yield meta
if meta.date:
if season is not None and season == meta.season \
and episode is not None and episode == meta.episode:
if meta.date == release_date:
found = True
yield meta
elif release_date.month == 1 and release_date.month == 1 and \
( meta.date.year == release_date.year or \
meta.series_date.year == release_date.year ):
found = True
yield meta
else:
if meta.date == release_date:
found = True
yield meta
if not found:
raise MnamerNotFoundException

def _search_series_date(
self, series: str, release_date: dt.date, language: Language | None
self,
series: str,
release_date: dt.date,
language: Language | None,
season: int | None = None,
episode: int | None = None
):
release_date = parse_date(release_date)
series_data = tvdb_search_series(
Expand All @@ -338,7 +367,7 @@ def _search_series_date(
found = False
for tvdb_id in tvdb_ids:
try:
yield from self._search_tvdb_date(tvdb_id, release_date, language)
yield from self._search_tvdb_date(tvdb_id, release_date, language, season, episode)
found = True
except MnamerNotFoundException:
continue
Expand Down Expand Up @@ -480,6 +509,7 @@ def _transform_meta(
id_tvmaze=id_tvmaze or None,
season=episode_entry["season"],
series=series_entry["name"],
series_date=series_entry["premiered"],
synopsis=episode_entry["summary"] or None,
title=episode_entry["name"] or None,
)
30 changes: 28 additions & 2 deletions mnamer/setting_store.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import dataclasses
import json
from functools import cached_property
from pathlib import Path
from typing import Any, Callable

Expand All @@ -11,6 +12,7 @@
from mnamer.setting_spec import SettingSpec
from mnamer.types import MediaType, ProviderType, SettingType
from mnamer.utils import crawl_out, json_loads, normalize_containers
from mnamer import text_lang_guesser


@dataclasses.dataclass
Expand Down Expand Up @@ -106,6 +108,15 @@ class SettingStore:
help="--language=<LANG>: specify the search language",
).as_dict(),
)
subtitle_lang_guesser: Language | None = dataclasses.field(
default=None,
metadata=SettingSpec(
flags=["--subtitle-lang-guesser"],
group=SettingType.PARAMETER,
choices=list(text_lang_guesser.available_guessers),
help="--subtitle-lang-guesser=<GUESSER>: subtitle file text language guesser (must be installed)",
).as_dict(),
)
mask: list[str] = dataclasses.field(
default_factory=lambda: [
"avi",
Expand Down Expand Up @@ -177,7 +188,7 @@ class SettingStore:
).as_dict(),
)
movie_format: str = dataclasses.field(
default="{name} ({year}).{extension}",
default="{name} ({date.year}).{extension}",
metadata=SettingSpec(
dest="movie_format",
flags=["--movie_format", "--movie-format", "--movieformat"],
Expand Down Expand Up @@ -217,6 +228,15 @@ class SettingStore:
help="--episode-format: set episode renaming format specification",
).as_dict(),
)
symlink: bool = dataclasses.field(
default=False,
metadata=SettingSpec(
action="store_true",
flags=["--symlink"],
group=SettingType.PARAMETER,
help="--symlink: leaves a trailing symlink",
).as_dict(),
)

# directive attributes -----------------------------------------------------

Expand Down Expand Up @@ -322,7 +342,7 @@ class SettingStore:
default=False,
metadata=SettingSpec(
action="store_true",
flags=["--test"],
flags=["--test", "--dry-run", "--dryrun"],
group=SettingType.DIRECTIVE,
help="--test: mocks the renaming and moving of files",
).as_dict(),
Expand Down Expand Up @@ -367,6 +387,12 @@ def specifications(cls) -> list[SettingSpec]:
def _resolve_path(path: str | Path) -> Path:
return Path(path).resolve()

@cached_property
def text_lang_guesser(self):
if not self.subtitle_lang_guesser:
return None
return text_lang_guesser.guesser(self.subtitle_lang_guesser, Language.all())

def __setattr__(self, key: str, value: Any):
converter_map: dict[str, Callable] = {
"episode_api": ProviderType,
Expand Down
Loading