Files
Server-scripts/media_editing/sonarr_database_tool.py
T
lucas 2d97877d00 feat(sonarr_database_tool): working translation and extra_episodes functions
get last update time of tvdb/tmdb
get_tvdb_by_tmdb_id
add the same dict keys for both databases
lookup episodes in german for foreign language media
2026-10-29 09:02:30 +01:00

771 lines
24 KiB
Python
Executable File

#!/home/lucas/scripts/.venv/bin/python3
import argparse
import json
import logging
import re
from datetime import datetime, timedelta
import sys
from pathlib import Path
import filecmp
import requests
weight_jaccard = 0.5
weight_fuzzy = 1 - weight_jaccard
MATCH_THRESHOLD = 0.85
from serverlibrary import (
VIDEO_SUFFIXES,
CustomLogger,
combined_score,
safe_rename,
score_match_fuzzy,
score_match_jaccard,
score_match_recall_jaccard,
setup_logging,
tmdb_get,
tvdb_get,
words,
)
logging.setLoggerClass(CustomLogger)
logger: CustomLogger = logging.getLogger(__name__)
def normalize(
text: str, title: str | None = None, year: int | str | None = None
) -> str:
text = text.lower()
# remove series/movie title
if title:
text = re.sub(re.escape(title.lower()), " ", text)
# remove release year
if year:
text = re.sub(rf"\b{re.escape(str(year))}\b", " ", text)
# remove file extension
text = re.sub(r"\.[a-z0-9]{2,4}$", " ", text)
# remove text inside square brackets
text = re.sub(r"\[[^\]]*\]", " ", text)
# remove episode numbers (S01E01)
text = re.sub(r"\bs\d+e\d+\b", " ", text, flags=re.IGNORECASE)
# remove common release tags
text = re.sub(
r"\b(?:1080p|720p|2160p|x264|x265|h264|h265|bluray|web[- ]?dl|webrip|dvdrip)\b",
" ",
text,
flags=re.IGNORECASE,
)
# normalize separators
text = re.sub(r"[._-]", " ", text)
# German normalization
text = text.replace("ß", "ss")
# "(123 456)" -> "(123)"
text = re.sub(r"\((\d+)\s+\d+\)", r"(\1)", text)
# "(123___456)" -> "(123)"
text = re.sub(r"\((\d+)_+\d+\)", r"(\1)", text)
# remove remaining punctuation
text = re.sub(r"[^\w\s]", " ", text)
# collapse whitespace
text = re.sub(r"\s+", " ", text).strip()
return text
def files_equal(file1: Path, file2: Path) -> bool:
if filecmp.cmp(file1, file2, shallow=False):
return True
logger.debug(f"Files {file1.name} and {file2.name} are not equal")
return False
def parse_series_name(series_name: str) -> tuple[str, str, str]:
"""
Universe (1987) [66757]
-> ("Universe", 1987, 66757)
Universe (1987)
-> ("Universe", 1987, None)
Universe
-> ("Universe", None, None)
"""
tmdb_id = ""
year = ""
id_match = re.search(r"\[(\d+)]", series_name)
if id_match:
tmdb_id = id_match.group(1)
year_match = re.search(r"\((\d{4})\)", series_name)
if year_match:
year = year_match.group(1)
title = re.sub(r"\[\d+]", "", series_name)
title = re.sub(r"\(\d{4}\)", "", title)
title = title.strip()
return title, year, tmdb_id
def find_by_tvdb_id(tvdb_id: str, language="deu"):
"""
Try to determine whether a TMDB ID is a TV show or movie.
Returns:
("tv", data)
("movie", data)
(None, None)
"""
return tvdb_get(
f"/series/{tvdb_id}/extended",
{"language": language},
)["data"]
def get_tmdb_last_updated(tmdb_id: str) -> str:
latest = None
data = tmdb_get(f"/tv/{tmdb_id}/changes")
for change in data["changes"]:
for item in change["items"]:
ts = datetime.strptime(
item["time"],
"%Y-%m-%d %H:%M:%S UTC",
)
if latest is None or ts > latest:
latest = ts
if latest is None:
logger.debug("Use default lastChange of 1 week")
latest = datetime.now() - timedelta(days=7)
return latest.strftime("%Y-%m-%d %H:%M:%S")
def find_by_tmdb_id(tmdb_id: str, language="de-DE", type="tv"):
"""
Try to determine whether a TMDB ID is a TV show or movie.
Returns:
("tv", data)
("movie", data)
(None, None)
"""
logger.trace(f"Find entry to id {tmdb_id}")
result = tmdb_get(
f"/{type}/{tmdb_id}",
{
"language": language,
},
)
result["type"] = type
result["year"] = (
f"{(result.get('first_air_date') or result.get('release_date') or '')[:4]}"
)
result["tmdb_id"] = result["id"]
result["tvdb_id"] = None
result["origin_country"] = result["origin_country"][0]
result["name"] = result.get("name") or result.get("title")
return result
def get_all_episodes_by_tvdbid(tvdb_id: str, language: str = "deu"):
series_data = find_by_tvdb_id(tvdb_id)
episodes_data = []
for season in series_data.get("seasons", []):
logger.info(f"Requesting Season: {season.get('number')}")
season_data = {}
try:
season_data = tvdb_get(
f"/seasons/{season['id']}/extended",
{"language": language},
)["data"]
except requests.exceptions.RequestException:
logger.error(
"Failed to fetch season %s",
season["id"],
)
for episode in season_data.get("episodes", []):
if language in episode["nameTranslations"] and (
not episode["name"] or series_data["originalLanguage"] != language
):
episode["name"] = tvdb_get(
f"/episodes/{episode['id']}/translations/{language}"
)["data"]["name"]
episodes_data.append(
{
"id": episode["id"],
"season": episode["seasonNumber"],
"episode": episode["number"],
"name": episode["name"],
}
)
series_data["number_of_seasons"] = len(series_data.get("seasons"))
series_data["number_of_episodes"] = len(episodes_data)
network = series_data.get("originalNetwork") or series_data.get("latestNetwork")
if isinstance(network, dict):
network = network.get("name")
series_data["network"] = network
return series_data, episodes_data
def get_all_episodes_by_tmdbid(tmdb_id, language: str = "de-DE"):
def get_season(tmdb_id, season_number):
return tmdb_get(
f"/tv/{tmdb_id}/season/{season_number}",
{
"language": language,
},
)
series_data = find_by_tmdb_id(tmdb_id)
episodes_data = []
for season in series_data.get("seasons", []):
season_number = season["season_number"]
season_data = get_season(series_data.get("id"), season_number)
for episode in season_data.get("episodes", []):
episodes_data.append(
{
"id": episode["id"],
"season": season_number,
"episode": episode["episode_number"],
"name": episode["name"],
}
)
network = series_data["networks"]
if isinstance(network, list):
network = network[0].get("name")
series_data["network"] = network
return series_data, episodes_data
def get_tvdbid_by_tmdb(series_path: Path, language: str = "deu"):
title, year, tmdb_id = parse_series_name(series_path.name)
if tmdb_id:
results = tvdb_get(
f"/search/remoteid/{tmdb_id}",
{"language": language},
)["data"]
else:
return get_tvdbid_by_name(series_path)
result = None
for res in results:
if "series" not in res:
continue
if normalize(title) in res["series"].get("name", "").casefold() and str(
res["series"].get("year", "")
) == str(year):
result = res["series"]
logger.info(result)
if not result:
logger.error(f"Results: {results}")
raise Exception(f"Couldnt find results for Series {title} ")
result["tvdb_id"] = result["id"]
result["type"] = "tv"
if result.get("translations"):
result["original_name"] = result.get("translations").get(
result.get("primary_language")
)
result["tmdb_id"] = tmdb_id
result["origin_country"] = result.get("originalCountry")
status = result.get("status")
result["name"] = result.get("name") or result.get("title")
if isinstance(status, dict):
result["status"] = status.get("name")
return result
def databes_entry_is_newer(last_updated: str, file: Path) -> bool:
tvdb_time = datetime.strptime(
last_updated,
"%Y-%m-%d %H:%M:%S",
)
file_time = datetime.fromtimestamp(file.stat().st_mtime)
if tvdb_time > file_time:
logger.debug(
f"Database entry is newer than {file.name}: {file_time} > {tvdb_time}"
)
return tvdb_time > file_time
def get_tvdbid_by_name(series_path: Path, language: str = "deu"):
title, year, tmdb_id = parse_series_name(series_path.name)
logger.trace(f"Find series {title} by tvdb name search")
params = {
"query": title,
"type": "series",
"language": language,
}
if year:
params["year"] = str(year)
results = tvdb_get(
"/search",
params,
)["data"]
if not results:
raise RuntimeError(f"Series not found: {series_path}")
# Filter by year if available
filtered = []
for result in results:
result_tmdb_id = result.get("remote_ids")
result["tmdb_id"] = None
if not result_tmdb_id:
continue
for id in result_tmdb_id:
if id.get("sourceName") == "TheMovieDB.com":
result["tmdb_id"] = id.get("id")
if tmdb_id and result["tmdb_id"] and result["tmdb_id"] == tmdb_id:
filtered.append(result)
if filtered:
results = filtered
result = results[0]
result["lastUpdated"] = tvdb_get(f"/series/{result['tvdb_id']}")["data"][
"lastUpdated"
]
result["id"] = result["tvdb_id"]
result["type"] = "tv"
if result.get("translations"):
result["original_name"] = result.get("translations").get(
result.get("primary_language")
)
result["origin_country"] = ""
if result.get("country"):
result["origin_country"] = result.get("country")[0]
status = result.get("status")
result["name"] = result.get("name") or result.get("title")
if isinstance(status, dict):
result["status"] = status.get("name")
if result["primary_language"] != language:
result["name"] = result["translations"][language]
result["overview"] = result["overviews"][language]
return result
def get_tmdbid_by_name(series_path: Path, type="tv", language="de-DE"):
"""
Search TMDB by title.
media_type:
tv | movie
"""
title, year, _ = parse_series_name(series_path.name)
logger.trace(f"Find series {title} by tmdb name search")
params = {
"query": title,
"language": "en-EN",
}
if year:
if type == "tv":
params["first_air_date_year"] = year
else:
params["year"] = year
results = tmdb_get(
f"/search/{type}",
params,
)
if not results:
raise RuntimeError(f"Series not found: {series_path}")
result = results["results"][0]
result["type"] = type
result["year"] = (
f"{(result.get('first_air_date') or result.get('release_date') or '')[:4]}"
)
result["tvdb_id"] = None
result["origin_country"] = result["origin_country"][0]
result["name"] = result.get("name") or result.get("title")
return result
def print_results(
media_data: dict, series_data: dict, episodes: list, extended: bool = False
):
logger.info(f"TVDB ID : {media_data.get('tvdb_id')}")
logger.info(f"TMDB ID : {media_data.get('tmdb_id')}")
logger.info(f"Title {media_data.get('name')}")
logger.info(
f"Original : {media_data.get('original_name') or media_data.get('original_title')}"
)
logger.info(f"Country : {media_data['origin_country']}")
logger.info(f"Network : {series_data['network']}")
if extended:
logger.info(f"Year : {media_data['year']}")
logger.info(f"Rating : {media_data.get('vote_average')}")
logger.info(f"Votes : {media_data.get('vote_count')}")
logger.info(f"Status : {media_data.get('status')}")
logger.info(f"Overview : {media_data.get('overview')}")
genres = ", ".join(g["name"] for g in series_data.get("genres", []))
logger.info(f"Genres : {genres}")
logger.info(
f"Poster : https://image.tmdb.org/t/p/original{media_data.get('poster_path', '')}"
)
logger.info(
f"Backdrop : https://image.tmdb.org/t/p/original{media_data.get('backdrop_path', '')}"
)
if media_data.get("type") == "tv":
logger.info(f"Seasons : {series_data.get('number_of_seasons')}")
logger.info(f"Episodes : {series_data.get('number_of_episodes')}")
def best_episode_title_match(filename, episodes):
search_words = words(normalize(filename))
logger.debug(f"Search Words: {search_words}")
best_score = 0.0
best_episode = None
for episode in episodes:
if not episode["name"] or episode["name"] == "Unkown":
continue
candidate_words = words(normalize(episode["name"]))
fuzzy = score_match_fuzzy(
search_words,
candidate_words,
)
jaccard = score_match_jaccard(
search_words,
candidate_words,
)
recall_jaccard1 = score_match_recall_jaccard(
search_words,
candidate_words,
)
recall_jaccard2 = score_match_recall_jaccard(
candidate_words,
search_words,
)
score = combined_score(
fuzzy,
jaccard,
weight_fuzzy=weight_fuzzy,
weight_jaccard=weight_jaccard,
)
score = max(score, recall_jaccard1, recall_jaccard2)
# if "hyänen" in search_words and score > 0.6:
# logger.warning(
# f"SEARCH WORDS: {search_words}, CANDIDATE WORDS: {candidate_words}"
# )
if score > best_score:
logger.trace(f"{search_words}:\t\t{candidate_words},\t\t{score}")
best_score = score
best_episode = episode
return best_episode, best_score
def translate_series_folder(episodes, series_path, media_data: dict, args):
series = series_path.name
logger.info(f"Execute translate_series for {media_data['name']}")
episode_map = {(ep["season"], ep["episode"]): ep for ep in episodes}
file_list = []
new_file_list = []
for file in series_path.rglob("*"):
if (
not file.is_file()
or file.suffix.lower() not in VIDEO_SUFFIXES
or "Season" not in file.parent.name
):
continue
logger.trace(f"Translate file: {file.name}")
match_SE_name = re.search(r"S(\d{2}|\d{4})E(\d{2,3})", file.name, re.IGNORECASE)
if not match_SE_name:
logger.error(f"No season/episode number found: {file.name}")
continue
season = int(match_SE_name.group(1))
episode = int(match_SE_name.group(2))
episode_number = episode_map.get((season, episode))
if not episode_number:
logger.warning(f"Episode not found: S{season:02d}E{episode:02d}")
continue
episode_season_number_match = re.match(
r"^(.*?S(\d{2}|\d{4})E\d{2,3})\s*-\s*.*?\s*((?:\[[^\]]+\])+)?$",
file.stem,
re.IGNORECASE,
)
if not episode_season_number_match:
logger.warning(f"S{season:02d}E{episode:02d} not found")
continue
prefix = f"{media_data['name']} ({media_data['year']}) - S{season:02d}E{episode:02d}" # Andor (2022) - S01E07
quality = (
episode_season_number_match.group(3) or ""
) # [WEBRip-1080p][AAC 5.1][h265]
title = episode_number["name"].replace("/", "_")
if title == "_":
title = "slash"
new_name = (
f"{prefix} - {title}{(' ' + quality) if quality else ''}{file.suffix}"
)
logger.trace(f"Prefix: {prefix}, Title: {title}, Quality: {quality}")
logger.trace(f"{file.name} -> {new_name}")
file_list.append(file)
new_file_list.append(new_name)
new_folder_name = (
f"{media_data['name']} ({media_data['year']}) [{media_data['tmdb_id']}]"
)
if args.translate_series_folder:
logger.trace(f"{series} -> {new_folder_name}")
file_list.append(series_path)
new_file_list.append(new_folder_name)
else:
logger.info(f"Dont translate Series folder: {series}")
has_changes = False
for file, new_file in zip(file_list, new_file_list):
if file.name != new_file:
has_changes = True
break
if not has_changes:
logger.info("No changes necessary.")
return
for file, new_file in zip(file_list, new_file_list):
if args.dry_run:
logger.debug(f"{file.name} -> {new_file}")
continue
safe_rename(file, new_file, logger=logger)
logger.info("All files renamed")
return
def reference_extra_episodes(episodes, series_path: Path, media_data: dict, args):
logger.info(f"Execute reference_extra_episodes for {media_data['name']}")
extra_episodes_path = series_path / "Extra Episodes"
for file in extra_episodes_path.glob("*"):
if not file.is_file() or file.suffix.lower() not in VIDEO_SUFFIXES:
continue
logger.trace(f"Reference file: {file.name}")
filename = re.sub(media_data["name"], "", file.stem)
episode, score = best_episode_title_match(
filename,
episodes,
)
if episode is None:
continue
if score < MATCH_THRESHOLD:
logger.warning(
f"LOW CONFIDENCE: {filename} {score:.2f}: {normalize(episode.get('name'))}"
)
continue
safe_title = re.sub(
r'[<>:"/\\|?*]',
"",
episode["name"],
)
new_name = (
f"{media_data['name']}"
f" - S{episode['season']:02d}"
f"E{episode['episode']:02d}"
f" - {safe_title}"
f"{file.suffix}"
)
target = file.parent.parent / f"Season {episode['season']:02d}" / new_name
if score < 1:
logger.debug(f"{file.name} -> {new_name}, score={score:.3f}")
elif score == 1:
logger.trace(f"{file.name} -> {new_name}, score={score:.3f}")
if args.dry_run:
logger.debug(f"{file.name} -> {target}")
continue
# logger.info(f"{file.name} -> {target}, score={score:.3f}")
safe_rename(file, target, logger=logger)
def get_series_data(series_path: Path, database: str = "tvdb"):
series_name, _, tmdb_id = parse_series_name(series_path.name)
if database == "tvdb":
# if tmdb_id:
# media_data = get_tvdbid_by_tmdb(series_path)
# else:
media_data = get_tvdbid_by_name(series_path)
elif database == "tmdb":
if tmdb_id:
media_data = find_by_tmdb_id(tmdb_id)
else:
media_data = get_tmdbid_by_name(series_path)
media_data["lastUpdated"] = get_tmdb_last_updated(tmdb_id)
else:
raise ValueError(f"Wrong database name: {database}")
buffer_file = series_path / f"__{series_name}_{database}_media_data.json"
with open(
buffer_file,
"w",
encoding="utf-8",
) as f:
json.dump(media_data, f, ensure_ascii=False, indent=2)
if (
(series_path / f"{series_name}_{database}_media_data.json").is_file()
and files_equal(
series_path / f"__{series_name}_{database}_media_data.json",
series_path / f"{series_name}_{database}_media_data.json",
)
and not databes_entry_is_newer(
media_data["lastUpdated"],
series_path / f"{series_name}_{database}_media_data.json",
)
):
buffer_file.unlink()
logger.info("Load from json:")
with open(
series_path / f"{series_name}_{database}_episodes.json",
encoding="utf-8",
) as f:
episodes = json.load(f)
with open(
series_path / f"{series_name}_{database}_media_data.json",
encoding="utf-8",
) as f:
media_data = json.load(f)
with open(
series_path / f"{series_name}_{database}_series_data.json",
encoding="utf-8",
) as f:
series_data = json.load(f)
else:
if database == "tvdb":
logger.info("Query from TVDB")
series_data, episodes = get_all_episodes_by_tvdbid(media_data["id"])
elif database == "tmdb":
logger.info("Query from TMDB")
series_data, episodes = get_all_episodes_by_tmdbid(media_data["id"])
logger.info("Dump to json")
safe_rename(
buffer_file,
Path(f"{series_name}_{database}_media_data.json"),
overwrite=True,
logger=logger,
)
with open(
series_path / f"{series_name}_{database}_series_data.json",
"w",
encoding="utf-8",
) as f:
json.dump(series_data, f, ensure_ascii=False, indent=2)
with open(
series_path / f"{series_name}_{database}_episodes.json",
"w",
encoding="utf-8",
) as f:
json.dump(episodes, f, ensure_ascii=False, indent=2)
return series_data, episodes, media_data
def process_series(series_path: Path, args):
series_name = series_path.name
logger.debug(f"Pricess series with name : {series_name}")
series_data, episodes, media_data = get_series_data(series_path, args.database)
logger.info(
f"Found series name from TVDB: {media_data['name']}, ID: {media_data['id']}, Year: {media_data.get('year')}"
)
print_results(media_data, series_data, episodes, extended=args.extended)
reference_extra_episodes(episodes, series_path, media_data, args)
translate_series_folder(episodes, series_path, media_data, args)
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"path",
help='Either a series folder "Maya the Bee (1975) [10620]" or a root folder containing such folders',
)
parser.add_argument(
"-ll",
"--log-level",
type=str.upper,
default="INFO",
choices=["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Set log level",
)
parser.add_argument(
"-db",
"--database",
type=str,
default="tvdb",
help="Set which database to use for queries. dafault: TvDB",
)
parser.add_argument(
"-lsd--leave-series-dir",
dest="translate_series_folder",
action="store_false",
help="Dont Translate Series Folder",
)
parser.add_argument(
"-e",
"--extended",
action="store_true",
help="Print Extended Infos",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Dry run only",
)
args = parser.parse_args()
setup_logging(args.log_level, "sonarr_translation_tool")
input_path = Path(args.path).expanduser().resolve()
if not input_path.is_dir():
parser.error(f"Directory not found: {input_path}")
logger.info(f"Pricess folder: {input_path}")
# Input is already a series folder
if re.match(r"^(.*?)\s*\((\d{4})\)(?:\s*\[(\d*)])?$", input_path.name):
logger.info(f"Process series: {input_path.name}")
process_series(input_path, args)
# Input is a root folder containing series folders
else:
for series_path in sorted(input_path.iterdir()):
if series_path.is_dir():
process_series(series_path, args)
sys.exit(0)
if __name__ == "__main__":
main()