2026-09-29 12:42:07 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
import re
|
2026-09-29 12:42:07 +02:00
|
|
|
import sys
|
|
|
|
|
import requests
|
2026-10-03 12:55:53 +02:00
|
|
|
import argparse
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from difflib import SequenceMatcher
|
2026-09-29 12:42:07 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
TMDB_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJlOTIwMzJlMDk1NmEzMWEyZjQ4MDA4NGI0MTg4OTIyZCIsIm5iZiI6MTc5MDQzMjc5NS42NjcsInN1YiI6IjZhYjdkNjFiZDY5NmI1ZGFjNGM5NDRkMyIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.IjddKtQ5X62A63kYslH24Pxg_TFCzu-QpGak_Pemgr8"
|
|
|
|
|
|
|
|
|
|
if not TMDB_TOKEN:
|
|
|
|
|
print("ERROR: TMDB_API_TOKEN is not set.")
|
|
|
|
|
print()
|
|
|
|
|
print("Example:")
|
|
|
|
|
print(" export TMDB_API_TOKEN='your_token'")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BASE_URL = "https://api.themoviedb.org/3"
|
|
|
|
|
|
|
|
|
|
HEADERS = {
|
|
|
|
|
"Authorization": f"Bearer {TMDB_TOKEN}",
|
|
|
|
|
"accept": "application/json",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def tmdb_get(endpoint, params=None):
|
|
|
|
|
"""Perform a GET request against TMDb."""
|
|
|
|
|
url = BASE_URL + endpoint
|
2026-10-03 16:40:21 +02:00
|
|
|
print(f"Set of Tmdb query: {url}")
|
2026-09-29 12:42:07 +02:00
|
|
|
|
|
|
|
|
response = requests.get(
|
|
|
|
|
url,
|
|
|
|
|
headers=HEADERS,
|
|
|
|
|
params=params,
|
|
|
|
|
timeout=15,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
|
|
|
|
return response.json()
|
|
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
def find_by_title(title, media_type="tv", year=None, language="de-DE"):
|
2026-09-29 12:42:07 +02:00
|
|
|
"""
|
2026-10-03 12:55:53 +02:00
|
|
|
Search TMDB by title.
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
media_type:
|
|
|
|
|
tv | movie
|
2026-09-29 12:42:07 +02:00
|
|
|
"""
|
|
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
params = {
|
|
|
|
|
"query": title,
|
|
|
|
|
"language": language,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if year:
|
|
|
|
|
if media_type == "tv":
|
|
|
|
|
params["first_air_date_year"] = year
|
|
|
|
|
else:
|
|
|
|
|
params["year"] = year
|
|
|
|
|
|
|
|
|
|
return tmdb_get(
|
|
|
|
|
f"/search/{media_type}",
|
|
|
|
|
params,
|
2026-09-29 12:42:07 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
def find_by_tmdb_id(tmdb_id, language="de-DE"):
|
2026-09-29 12:42:07 +02:00
|
|
|
"""
|
2026-10-03 12:55:53 +02:00
|
|
|
Try to determine whether a TMDB ID is a TV show or movie.
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
Returns:
|
|
|
|
|
("tv", data)
|
|
|
|
|
("movie", data)
|
|
|
|
|
(None, None)
|
2026-09-29 12:42:07 +02:00
|
|
|
"""
|
|
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
try:
|
|
|
|
|
data = get_tv_details(tmdb_id, language)
|
|
|
|
|
return "tv", data
|
|
|
|
|
except requests.HTTPError:
|
|
|
|
|
pass
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
try:
|
|
|
|
|
data = get_movie_details(tmdb_id, language)
|
|
|
|
|
return "movie", data
|
|
|
|
|
except requests.HTTPError:
|
|
|
|
|
pass
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
return None, None
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
def get_tv_details(tmdb_id , language="de-DE"):
|
2026-09-29 12:42:07 +02:00
|
|
|
"""
|
|
|
|
|
Retrieve TV show details in German.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
return tmdb_get(
|
|
|
|
|
f"/tv/{tmdb_id}",
|
|
|
|
|
{
|
2026-10-03 12:55:53 +02:00
|
|
|
"language": language,
|
2026-09-29 12:42:07 +02:00
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
def get_movie_details(tmdb_id, language="de-DE"):
|
2026-09-29 12:42:07 +02:00
|
|
|
"""
|
|
|
|
|
Retrieve movie details in German.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
return tmdb_get(
|
|
|
|
|
f"/movie/{tmdb_id}",
|
2026-10-03 12:55:53 +02:00
|
|
|
{
|
|
|
|
|
"language": language,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def get_season(tmdb_id, season_number):
|
|
|
|
|
return tmdb_get(
|
|
|
|
|
f"/tv/{tmdb_id}/season/{season_number}",
|
2026-09-29 12:42:07 +02:00
|
|
|
{
|
|
|
|
|
"language": "de-DE",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-03 16:40:21 +02:00
|
|
|
def get_episode_list(show,series_path: Path):
|
2026-10-03 12:55:53 +02:00
|
|
|
#_, show = find_by_tmdb_id(tmdb_id)
|
2026-10-03 16:40:21 +02:00
|
|
|
file_seasons = sorted({
|
|
|
|
|
int(m.group(1))
|
|
|
|
|
for d in series_path.iterdir()
|
|
|
|
|
if d.is_dir()
|
|
|
|
|
and (m := re.match(r"^Season\s+0*(\d+)$", d.name, re.I))
|
|
|
|
|
})
|
2026-10-03 12:55:53 +02:00
|
|
|
episodes = []
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
for season in show.get("seasons", []):
|
|
|
|
|
season_number = season["season_number"]
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
# skip specials if desired
|
2026-10-03 16:40:21 +02:00
|
|
|
if season_number == 0 or season_number not in file_seasons:
|
2026-10-03 12:55:53 +02:00
|
|
|
continue
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
season_data = get_season(show.get("id"), season_number)
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
for episode in season_data.get("episodes", []):
|
|
|
|
|
episodes.append({
|
|
|
|
|
"season": season_number,
|
|
|
|
|
"episode": episode["episode_number"],
|
|
|
|
|
"title": episode["name"],
|
|
|
|
|
"air_date": episode["air_date"],
|
|
|
|
|
"runtime": episode.get("runtime"),
|
|
|
|
|
"tmdb_id": episode["id"],
|
|
|
|
|
})
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
return episodes
|
2026-09-29 12:42:07 +02:00
|
|
|
|
|
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
def print_results(result):
|
|
|
|
|
media_type, data = result
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 12:55:53 +02:00
|
|
|
print(f"Type : {media_type}")
|
|
|
|
|
print(f"TMDB ID : {data.get('id')}")
|
|
|
|
|
print(f"Title : {data.get('name') or data.get('title')}")
|
|
|
|
|
print(f"Original : {data.get('original_name') or data.get('original_title')}")
|
|
|
|
|
print(f"Year : {(data.get('first_air_date') or data.get('release_date') or '')[:4]}")
|
|
|
|
|
print(f"Rating : {data.get('vote_average')}")
|
|
|
|
|
print(f"Votes : {data.get('vote_count')}")
|
|
|
|
|
print(f"Status : {data.get('status')}")
|
|
|
|
|
print(f"Tagline : {data.get('tagline')}")
|
|
|
|
|
print(f"Overview : {data.get('overview')}")
|
|
|
|
|
|
|
|
|
|
genres = ", ".join(g["name"] for g in data.get("genres", []))
|
|
|
|
|
print(f"Genres : {genres}")
|
|
|
|
|
|
|
|
|
|
print(f"Poster : https://image.tmdb.org/t/p/original{data.get('poster_path', '')}")
|
|
|
|
|
print(f"Backdrop : https://image.tmdb.org/t/p/original{data.get('backdrop_path', '')}")
|
|
|
|
|
|
|
|
|
|
if media_type == "tv":
|
|
|
|
|
print(f"Seasons : {data.get('number_of_seasons')}")
|
|
|
|
|
print(f"Episodes : {data.get('number_of_episodes')}")
|
|
|
|
|
|
|
|
|
|
def normalize(text, title, year):
|
|
|
|
|
text = text.lower()
|
|
|
|
|
|
|
|
|
|
# remove series title
|
|
|
|
|
text = re.sub(re.escape(title.lower()), " ", text)
|
|
|
|
|
|
|
|
|
|
# remove year
|
|
|
|
|
text = re.sub(rf"\b{year}\b", " ", text)
|
|
|
|
|
|
|
|
|
|
# remove anything in [] including the brackets
|
|
|
|
|
text = re.sub(r"\[[^\]]*\]", " ", text)
|
|
|
|
|
|
|
|
|
|
# remove punctuation
|
|
|
|
|
text = re.sub(r"[^\w\s]", " ", text)
|
|
|
|
|
|
|
|
|
|
# remove episode numbers like S01E01
|
|
|
|
|
text = re.sub(r"\bs\d+e\d+\b", " ", text, flags=re.I)
|
|
|
|
|
|
|
|
|
|
# collapse spaces
|
|
|
|
|
text = re.sub(r"\s+", " ", text).strip()
|
|
|
|
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
def best_episode_match(filename, episodes,title,year):
|
|
|
|
|
filename_norm = normalize(Path(filename).stem, title, year)
|
|
|
|
|
|
|
|
|
|
best = None
|
|
|
|
|
best_score = 0
|
|
|
|
|
|
|
|
|
|
for ep in episodes:
|
|
|
|
|
score = SequenceMatcher(
|
|
|
|
|
None,
|
|
|
|
|
filename_norm,
|
|
|
|
|
normalize(ep["title"], title, year)
|
|
|
|
|
).ratio()
|
|
|
|
|
|
|
|
|
|
if score > best_score:
|
|
|
|
|
best_score = score
|
|
|
|
|
best = ep
|
|
|
|
|
|
|
|
|
|
return best, best_score
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 16:40:21 +02:00
|
|
|
def rename_media(media_data,episodes, series_path,file_title):
|
|
|
|
|
series=series_path.name
|
|
|
|
|
episode_map = {
|
|
|
|
|
(ep["season"], ep["episode"]): ep
|
|
|
|
|
for ep in episodes
|
|
|
|
|
}
|
|
|
|
|
file_list = []
|
|
|
|
|
new_file_list = []
|
|
|
|
|
for file in series_path.rglob("*"):
|
|
|
|
|
match_SE_name = re.search(r"S(\d{2})E(\d{2})", file.name, re.I)
|
|
|
|
|
if not match_SE_name:
|
|
|
|
|
match_season_folder = re.search(r"Season (\d{2})", file.name, re.I)
|
|
|
|
|
if match_season_folder:
|
|
|
|
|
continue # witchout bothering anyone
|
|
|
|
|
print(f"No season/episode number found: {file.name}")
|
|
|
|
|
continue
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 16:40:21 +02:00
|
|
|
season = int(match_SE_name.group(1))
|
|
|
|
|
episode= int(match_SE_name.group(2))
|
2026-10-03 12:55:53 +02:00
|
|
|
|
2026-10-03 16:40:21 +02:00
|
|
|
episode_number = episode_map.get((season, episode))
|
2026-10-03 12:55:53 +02:00
|
|
|
|
2026-10-03 16:40:21 +02:00
|
|
|
if not episode_number:
|
|
|
|
|
print(f"Episode not found: S{season:02d}E{episode:02d}")
|
|
|
|
|
continue
|
|
|
|
|
episode_season_number_match = re.match(
|
|
|
|
|
r"^(.*?S\d{2}E\d{2})\s*-\s*(.*?)\s*(\[[^\]]+\].*)?$",
|
|
|
|
|
file.stem,
|
|
|
|
|
re.I,
|
|
|
|
|
)
|
|
|
|
|
if not episode_season_number_match:
|
|
|
|
|
print(f"S{season:02d}E{episode:02d} not found")
|
|
|
|
|
continue
|
|
|
|
|
prefix = episode_season_number_match.group(1).replace(file_title,(media_data.get('name')
|
|
|
|
|
or media_data.get('title'))) # Andor (2022) - S01E07
|
|
|
|
|
quality = episode_season_number_match.group(3) or "" # [WEBRip-1080p][AAC 5.1][h265]
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 16:40:21 +02:00
|
|
|
new_name = (
|
|
|
|
|
f"{prefix} - {episode_number['title']}"
|
|
|
|
|
f"{(' ' + quality) if quality else ''}"
|
|
|
|
|
f"{file.suffix}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
print(f"{file.name} -> {new_name}")
|
|
|
|
|
|
|
|
|
|
file_list.append(file)
|
|
|
|
|
new_file_list.append(new_name)
|
|
|
|
|
|
|
|
|
|
title=(f"{(media_data.get('name') or media_data.get('title'))} "
|
|
|
|
|
f"({(media_data.get('first_air_date') or media_data.get('release_date') or '')[:4]}) "
|
|
|
|
|
f"[{media_data.get('id')}]")
|
|
|
|
|
print(f"{series} -> {title}")
|
|
|
|
|
|
|
|
|
|
file_list.append(series_path)
|
|
|
|
|
new_file_list.append(title)
|
|
|
|
|
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:
|
|
|
|
|
print("No changes necessary.")
|
|
|
|
|
return
|
|
|
|
|
answer = input("Rename files? [y/N]: ").strip().lower()
|
|
|
|
|
if answer in ("y", "yes"):
|
|
|
|
|
print("Proceeding...")
|
|
|
|
|
for file, new_file in zip(file_list,new_file_list):
|
|
|
|
|
file.rename(file.with_name(new_file))
|
|
|
|
|
print("All files renamed")
|
|
|
|
|
return
|
|
|
|
|
print("Cancelled.")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
def process_series(series_path: Path):
|
|
|
|
|
series = series_path.name
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 16:40:21 +02:00
|
|
|
m = re.match(r"^(.*?)\s*\((\d{4})\)\s*\[(\d+)\]$", series)
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 16:40:21 +02:00
|
|
|
if not m:
|
|
|
|
|
print(f"Skipping invalid folder: {series}")
|
|
|
|
|
return
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 16:40:21 +02:00
|
|
|
file_title = m.group(1).strip()
|
|
|
|
|
#file_year = int(m.group(2))
|
|
|
|
|
file_tmdb_id = int(m.group(3))
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 16:40:21 +02:00
|
|
|
if file_tmdb_id == 0:
|
|
|
|
|
print("TMDB ID required")
|
|
|
|
|
return
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 16:40:21 +02:00
|
|
|
try:
|
|
|
|
|
data = find_by_tmdb_id(file_tmdb_id)
|
2026-09-29 12:42:07 +02:00
|
|
|
|
2026-10-03 16:40:21 +02:00
|
|
|
if not data:
|
|
|
|
|
print("TMDB lookup failed")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
media_type, media_data = data
|
|
|
|
|
|
|
|
|
|
#print_results(data)
|
|
|
|
|
|
|
|
|
|
episodes = get_episode_list(media_data,series_path)
|
|
|
|
|
|
|
|
|
|
rename_media(
|
|
|
|
|
media_data,
|
|
|
|
|
episodes,
|
|
|
|
|
series_path,
|
|
|
|
|
file_title
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error processing {series}: {e}")
|
|
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
input_path = Path(args.path)
|
|
|
|
|
|
|
|
|
|
if not input_path.is_dir():
|
|
|
|
|
parser.error(f"Directory not found: {input_path}")
|
|
|
|
|
|
|
|
|
|
# Input is already a series folder
|
|
|
|
|
if re.match(r"^(.*?)\s*\((\d{4})\)\s*\[(\d+)\]$", input_path.name):
|
|
|
|
|
process_series(input_path)
|
|
|
|
|
|
|
|
|
|
# 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)
|
2026-09-29 12:42:07 +02:00
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|