match series to tvdb episodes py

adapt serverlibrary
This commit is contained in:
lucas committed 2026-10-24 15:19:17 +02:00
1 parent 104b912404
commit d241017c2a
3 files changed
+152 -85

No files matched your search

+70 -36
View File
@@ -1,25 +1,33 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import re
import json
import argparse import argparse
import json
import logging import logging
import re
import sys
from pathlib import Path from pathlib import Path
from typing import Any
weight_jaccard = 0.5 weight_jaccard = 0.5
weight_fuzzy = 1 - weight_jaccard weight_fuzzy = 1 - weight_jaccard
from serverlibrary import (words, word_similarity, score_match_jaccard, score_match_fuzzy, from serverlibrary import (
combined_score, tmdb_get, tvdb_get) combined_score,
score_match_fuzzy,
score_match_jaccard,
score_match_recall_jaccard,
tvdb_get,
words,
)
MATCH_THRESHOLD = 0.85 MATCH_THRESHOLD = 0.85
class CustomLogger(logging.Logger): class CustomLogger(logging.Logger):
def trace(self, msg: str, *args, **kwargs) -> None: def trace(self, msg: str, *args, **kwargs) -> None:
if self.isEnabledFor(TRACE): if self.isEnabledFor(TRACE):
self._log(TRACE, msg, args, **kwargs) self._log(TRACE, msg, args, **kwargs)
TRACE = 5 TRACE = 5
logging.setLoggerClass(CustomLogger) logging.setLoggerClass(CustomLogger)
@@ -34,12 +42,14 @@ def normalize(text: str) -> str:
r"\b(1080p|720p|2160p|x264|x265|h264|h265|bluray|web-dl|webrip|dvdrip)\b", r"\b(1080p|720p|2160p|x264|x265|h264|h265|bluray|web-dl|webrip|dvdrip)\b",
"", "",
text, text,
flags=re.I, flags=re.IGNORECASE,
) )
text = re.sub(r"[._-]", " ", text) text = re.sub(r"[._-]", " ", text)
text = re.sub(r"ß", "ss", text) text = re.sub(r"ß", "ss", text)
text = re.sub(r"\s+", " ", text) text = re.sub(r"\s+", " ", text)
text = re.sub(r"\((\d+)\s+\d+\)", r"(\1)", text)
text = re.sub(r"\((\d+)_+\d+\)", r"(\1)", text)
return text.strip() return text.strip()
@@ -70,6 +80,7 @@ def get_all_episodes_by_tvdbid(tvdb_id: int):
return episodes return episodes
def parse_series_name(series_name: str): def parse_series_name(series_name: str):
""" """
Universe (1987) [66757] Universe (1987) [66757]
@@ -97,7 +108,8 @@ def parse_series_name(series_name: str):
return title, year, tmdb_id return title, year, tmdb_id
def get_tvdbid_by_name(series_name: str) -> tuple[int, Any]:
def get_tvdbid_by_name(series_name: str):
title, year, tmdb_id = parse_series_name(series_name) title, year, tmdb_id = parse_series_name(series_name)
params = { params = {
@@ -133,20 +145,19 @@ def get_tvdbid_by_name(series_name: str) -> tuple[int, Any]:
if filtered: if filtered:
results = filtered results = filtered
return int(results[0]["tvdb_id"]), results[0] return results[0]
def best_episode_match(filename, episodes): def best_episode_match(filename, episodes):
search_words = words(filename) search_words = words(normalize(filename))
logger.debug(f"Search Words: {search_words}")
best_score = 0.0 best_score = 0.0
best_episode = None best_episode = None
for episode in episodes: for episode in episodes:
logger.trace(f" Title: {episode["title"]}")
if not episode["title"] or episode["title"] == "Unkown": if not episode["title"] or episode["title"] == "Unkown":
continue continue
candidate_words = words(episode["title"]) candidate_words = words(normalize(episode["title"]))
fuzzy = score_match_fuzzy( fuzzy = score_match_fuzzy(
search_words, search_words,
candidate_words, candidate_words,
@@ -155,6 +166,14 @@ def best_episode_match(filename, episodes):
search_words, search_words,
candidate_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( score = combined_score(
fuzzy, fuzzy,
@@ -162,8 +181,15 @@ def best_episode_match(filename, episodes):
weight_fuzzy=weight_fuzzy, weight_fuzzy=weight_fuzzy,
weight_jaccard=weight_jaccard, 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: if score > best_score:
logger.trace(f"{search_words}:\t\t{candidate_words},\t\t{score}")
best_score = score best_score = score
best_episode = episode best_episode = episode
@@ -173,45 +199,55 @@ def best_episode_match(filename, episodes):
def process_series(series_path: Path, dry_run: bool = False): def process_series(series_path: Path, dry_run: bool = False):
series_name = series_path.name series_name = series_path.name
logger.debug(f"Pricess series with name : {series_name}") logger.debug(f"Pricess series with name : {series_name}")
#tvdb_id, media_data = get_tvdbid_by_name(series_name) if Path(f"{series_name}_episodes.json").is_file():
# episodes = get_all_episodes_by_tvdbid(tvdb_id) logger.info("load json files")
#with open("media_data.json", "w", encoding="utf-8") as f: with open(f"{series_name}_media_data.json", encoding="utf-8") as f:
# json.dump(media_data, f, ensure_ascii=False, indent=2)
with open("media_data.json", encoding="utf-8") as f:
media_data = json.load(f) media_data = json.load(f)
with open("episodes.json", encoding="utf-8") as f: with open(f"{series_name}_episodes.json", encoding="utf-8") as f:
episodes = json.load(f) episodes = json.load(f)
tvdb_id = int(media_data["tvdb_id"])
for ep in episodes:
logger.info(f"Found series name {media_data["name"]}") logger.debug(f"{ep.get('title')}")
else:
logger.info("QUery from TVDB")
media_data = get_tvdbid_by_name(series_name)
tvdb_id = int(media_data["tvdb_id"])
episodes = get_all_episodes_by_tvdbid(tvdb_id)
logger.info("Dump to json")
with open(f"{series_name}_media_data.json", "w", encoding="utf-8") as f:
json.dump(media_data, f, ensure_ascii=False, indent=2)
with open(f"{series_name}_episodes.json", "w", encoding="utf-8") as f:
json.dump(episodes, f, ensure_ascii=False, indent=2)
logger.info(f"Found series name {media_data['name']}")
extra_episodes_path = series_path / "Extra Episodes" extra_episodes_path = series_path / "Extra Episodes"
for file in extra_episodes_path.iterdir(): for file in extra_episodes_path.iterdir():
if not file.is_file(): if not file.is_file():
continue continue
filename = normalize(re.sub( media_data["name"],"", file.stem)) filename = re.sub(media_data["name"], "", file.stem)
episode, score = best_episode_match( episode, score = best_episode_match(
filename, filename,
episodes, episodes,
) )
if episode is None: if episode is None:
continue continue
if score < MATCH_THRESHOLD: if score < MATCH_THRESHOLD:
logger.warning(f"LOW CONFIDENCE: {filename} {score:.2f}: {episode.get("title")}") logger.warning(
f"LOW CONFIDENCE: {filename} {score:.2f}: {normalize(episode.get('title'))}"
)
continue continue
safe_title = re.sub( safe_title = re.sub(
r'[<>:"/\\|?*]', r'[<>:"/\\|?*]',
'', "",
episode["title"], episode["title"],
) )
new_name = ( new_name = (
f"{media_data.get("name")}" f"{media_data.get('name')}"
f" - S{episode['season']:02d}" f" - S{episode['season']:02d}"
f"E{episode['episode']:02d}" f"E{episode['episode']:02d}"
f" - {safe_title}" f" - {safe_title}"
@@ -219,12 +255,10 @@ def process_series(series_path: Path, dry_run: bool = False):
) )
target = file.with_name(new_name) target = file.with_name(new_name)
if score < 1:
logger.info( logger.debug(f"{file.name} -> {new_name}, score={score:.3f}")
f"{file.name}" elif score == 1:
f" -> {new_name}, " logger.trace(f"{file.name} -> {new_name}, score={score:.3f}")
f" score={score:.3f}"
)
if not dry_run: if not dry_run:
file.rename(target) file.rename(target)
@@ -234,7 +268,7 @@ def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument( parser.add_argument(
"path", "path",
help='Either a series folder "Maya the Bee (1975) [10620]" or a root folder containing such folders' help='Either a series folder "Maya the Bee (1975) [10620]" or a root folder containing such folders',
) )
parser.add_argument( parser.add_argument(
@@ -268,7 +302,7 @@ def main():
format="%(asctime)s %(levelname)s %(message)s", format="%(asctime)s %(levelname)s %(message)s",
) )
input_path = Path(args.path) input_path = Path(args.path).expanduser().resolve()
if not input_path.is_dir(): if not input_path.is_dir():
parser.error(f"Directory not found: {input_path}") parser.error(f"Directory not found: {input_path}")
+14 -11
View File
@@ -1,11 +1,14 @@
from .core import safe_rename from .core import (
from .core import safe_delete combined_score,
from .core import get_audio_tracks get_audio_tracks,
from .core import get_subtitle_tracks get_subtitle_tracks,
from .core import words safe_delete,
from .core import word_similarity safe_rename,
from .core import score_match_jaccard score_match_fuzzy,
from .core import score_match_fuzzy score_match_jaccard,
from .core import combined_score score_match_recall_jaccard,
from .core import tmdb_get tmdb_get,
from .core import tvdb_get tvdb_get,
word_similarity,
words,
)
+68 -38
View File
@@ -1,11 +1,11 @@
from pathlib import Path
import shutil
import subprocess
import json import json
import re import re
import requests import shutil
import subprocess
from difflib import SequenceMatcher from difflib import SequenceMatcher
from pathlib import Path
import requests
# ----------------------------- # -----------------------------
@@ -17,8 +17,15 @@ def safe_delete(path: Path):
else: else:
path.unlink() path.unlink()
def safe_rename(src: Path, dst: Path, dry: bool=False, confirm: bool=False,
silent=False,overwrite=False) -> (str, bool): def safe_rename(
src: Path,
dst: Path,
dry: bool = False,
confirm: bool = False,
silent=False,
overwrite=False,
) -> tuple[str, bool]:
""" """
Safely rename a file or directory without overwriting existing files. Safely rename a file or directory without overwriting existing files.
Automatically appends _1, _2, ... if needed. Automatically appends _1, _2, ... if needed.
@@ -29,7 +36,7 @@ def safe_rename(src: Path, dst: Path, dry: bool=False, confirm: bool=False,
# nothing to do # nothing to do
if src.resolve() == dst.resolve(): if src.resolve() == dst.resolve():
return return "", False
directory = dst.parent directory = dst.parent
base = dst.stem base = dst.stem
@@ -60,7 +67,10 @@ def safe_rename(src: Path, dst: Path, dry: bool=False, confirm: bool=False,
src.rename(dst) src.rename(dst)
return ret, True return ret, True
def get_audio_tracks( source: str, title: int | None = None, handbrake: bool = False) -> list[dict]:
def get_audio_tracks(
source: str, title: int | None = None, handbrake: bool = False
) -> list[dict]:
""" """
Return audio track information from a media file or DVD title. Return audio track information from a media file or DVD title.
@@ -103,8 +113,10 @@ def get_audio_tracks( source: str, title: int | None = None, handbrake: bool = F
result = subprocess.run( result = subprocess.run(
[ [
"HandBrakeCLI", "HandBrakeCLI",
"-i", source, "-i",
"-t", str(title), source,
"-t",
str(title),
"--scan", "--scan",
], ],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
@@ -113,22 +125,24 @@ def get_audio_tracks( source: str, title: int | None = None, handbrake: bool = F
check=True, check=True,
) )
pattern = re.compile( pattern = re.compile(r"\+\s+(\d+),\s+(.+?)\s+\((?:AC3|AAC|MP3|E-AC-3|DTS)")
r"\+\s+(\d+),\s+(.+?)\s+\((?:AC3|AAC|MP3|E-AC-3|DTS)"
)
for match in pattern.finditer(result.stdout): for match in pattern.finditer(result.stdout):
tracks.append({ tracks.append(
{
"number": int(match.group(1)), "number": int(match.group(1)),
"description": match.group(2).strip(), "description": match.group(2).strip(),
}) }
)
else: else:
result = subprocess.run( result = subprocess.run(
[ [
"ffprobe", "ffprobe",
"-v", "quiet", "-v",
"-print_format", "json", "quiet",
"-print_format",
"json",
"-show_streams", "-show_streams",
source, source,
], ],
@@ -143,31 +157,33 @@ def get_audio_tracks( source: str, title: int | None = None, handbrake: bool = F
if stream.get("codec_type") != "audio": if stream.get("codec_type") != "audio":
continue continue
tracks.append({ tracks.append(
{
"number": stream["index"], "number": stream["index"],
"description": ( "description": (
stream.get("tags", {}).get("title") stream.get("tags", {}).get("title")
or stream.get("tags", {}).get("language") or stream.get("tags", {}).get("language")
or f"Track {stream['index']}" or f"Track {stream['index']}"
), ),
"primary": stream.get("codec_type") == "audio" and stream.get("disposition", {}).get("default") == 1 "primary": stream.get("codec_type") == "audio"
}) and stream.get("disposition", {}).get("default") == 1,
}
)
if not tracks: if not tracks:
tracks.append({ tracks.append({"number": 0, "description": "", "primary": False})
"number": 0,
"description": "",
"primary": False
})
return tracks return tracks
def get_subtitle_tracks(source): def get_subtitle_tracks(source):
result = subprocess.run( result = subprocess.run(
[ [
"ffprobe", "ffprobe",
"-v", "quiet", "-v",
"-print_format", "json", "quiet",
"-print_format",
"json",
"-show_streams", "-show_streams",
source, source,
], ],
@@ -184,36 +200,48 @@ def get_subtitle_tracks(source):
if stream.get("codec_type") != "subtitle": if stream.get("codec_type") != "subtitle":
continue continue
tracks.append({ tracks.append(
{
"number": stream["index"], "number": stream["index"],
"description": ( "description": (
stream.get("tags", {}).get("title") stream.get("tags", {}).get("title")
or stream.get("tags", {}).get("language") or stream.get("tags", {}).get("language")
or f"Subtitle {stream['index']}" or f"Subtitle {stream['index']}"
), ),
"primary": ( "primary": (stream.get("disposition", {}).get("default") == 1),
stream.get("disposition", {}).get("default") == 1 }
), )
})
if not tracks: if not tracks:
tracks.append({ tracks.append(
{
"number": 0, "number": 0,
"description": "", "description": "",
"primary": False, "primary": False,
}) }
)
return tracks return tracks
def words(text): def words(text):
return set(filter(None, re.split(r"[^a-z0-9]+", text.lower()))) return set(re.findall(r"[^\W_]+", text.lower()))
def word_similarity(a: str, b: str) -> float: def word_similarity(a: str, b: str) -> float:
return SequenceMatcher(None, a, b).ratio() return SequenceMatcher(None, a, b).ratio()
def score_match_recall_jaccard(
search_words: set[str], candidate_words: set[str]
) -> float:
if not search_words:
return 0.0
intersection = search_words & candidate_words
return len(intersection) / len(search_words)
def score_match_jaccard(search_words, candidate_words): def score_match_jaccard(search_words, candidate_words):
intersection = search_words & candidate_words intersection = search_words & candidate_words
union = search_words | candidate_words union = search_words | candidate_words
@@ -239,8 +267,9 @@ def score_match_fuzzy(search_words, candidate_words):
return total / len(search_words) return total / len(search_words)
def combined_score(score_fuzzy: float, score_jaccard: float, def combined_score(
weight_fuzzy: float, weight_jaccard: float) -> float: score_fuzzy: float, score_jaccard: float, weight_fuzzy: float, weight_jaccard: float
) -> float:
return score_fuzzy * weight_fuzzy + score_jaccard * weight_jaccard return score_fuzzy * weight_fuzzy + score_jaccard * weight_jaccard
@@ -278,6 +307,7 @@ def tmdb_get(endpoint, params=None):
return response.json() return response.json()
def tvdb_get(endpoint, params=None): def tvdb_get(endpoint, params=None):
"""Perform a GET request against TVDB.""" """Perform a GET request against TVDB."""
BASE_URL = "https://api4.thetvdb.com/v4" BASE_URL = "https://api4.thetvdb.com/v4"