1723b9fa53
Replace the old domain/media release parser with a full rewrite under
domain/release/:
- ParsedRelease with media_type ("movie" | "tv_show" | "tv_complete" |
"documentary" | "concert" | "other" | "unknown"), site_tag, parse_path,
languages, audio_codec, audio_channels, bit_depth, hdr_format, edition
- Well-formedness check + sanitize pipeline (_is_well_formed, _sanitize,
_strip_site_tag) before token-level parsing
- Multi-token sequence matching for audio (DTS-HD.MA, TrueHD.Atmos…),
HDR (DV.HDR10…) and editions (DIRECTORS.CUT…)
- Knowledge YAML: file_extensions, release_format, languages, audio,
video, editions, sites/c411
New infrastructure:
- ffprobe.py — single-pass probe returning MediaInfo (video, audio
tracks, subtitle tracks)
- find_video.py — locate first video file in a release folder
New application helpers:
- detect_media_type — filesystem-based type refinement
- enrich_from_probe — fill missing ParsedRelease fields from MediaInfo
New agent tools:
- analyze_release — parse + detect type + ffprobe in one call
- probe_media — standalone ffprobe for a specific file
New domain value object:
- MediaInfo + AudioTrack + SubtitleTrack (domain/shared/media_info.py)
Testing CLIs:
- recognize_folders_in_downloads.py — full pipeline with colored output
- probe_video.py — display MediaInfo for a video file
230 lines
8.1 KiB
Python
230 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
parse_release.py — Test ParsedRelease interactively or via CLI args.
|
|
|
|
Usage:
|
|
uv run testing/parse_release.py "Oz.S03.1080p.WEBRip.x265-KONTRAST"
|
|
uv run testing/parse_release.py "Oz.S03.1080p.WEBRip.x265-KONTRAST" --tmdb
|
|
uv run testing/parse_release.py "Inception.2010.1080p.BluRay.x265-GROUP" --tmdb-title "Inception" --tmdb-year 2010
|
|
uv run testing/parse_release.py --interactive
|
|
"""
|
|
|
|
import argparse
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
_PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(_PROJECT_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_PROJECT_ROOT))
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Colours
|
|
# ---------------------------------------------------------------------------
|
|
|
|
RESET = "\033[0m"
|
|
BOLD = "\033[1m"
|
|
DIM = "\033[2m"
|
|
GREEN = "\033[32m"
|
|
YELLOW = "\033[33m"
|
|
RED = "\033[31m"
|
|
CYAN = "\033[36m"
|
|
BLUE = "\033[34m"
|
|
|
|
USE_COLOR = True
|
|
|
|
|
|
def c(text: str, *codes: str) -> str:
|
|
if not USE_COLOR:
|
|
return str(text)
|
|
return "".join(codes) + str(text) + RESET
|
|
|
|
|
|
def kv(key: str, val: str, color: str = CYAN) -> None:
|
|
print(f" {c(key + ':', BOLD)} {c(val, color)}")
|
|
|
|
|
|
def hr() -> None:
|
|
print(c("─" * 64, DIM))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TMDB lookup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _fetch_tmdb(title: str) -> tuple[str | None, int | None]:
|
|
"""
|
|
Call TMDBClient.search_media() and return (canonical_title, year).
|
|
Returns (None, None) on failure.
|
|
"""
|
|
try:
|
|
from alfred.infrastructure.api.tmdb import TMDBClient
|
|
client = TMDBClient()
|
|
result = client.search_media(title)
|
|
year: int | None = None
|
|
if result.release_date:
|
|
try:
|
|
year = int(result.release_date[:4])
|
|
except (ValueError, IndexError):
|
|
pass
|
|
print(c(f" TMDB → {result.title} ({year}) [{result.media_type}] imdb={result.imdb_id}", DIM))
|
|
return result.title, year
|
|
except Exception as e:
|
|
print(c(f" TMDB lookup failed: {e}", YELLOW))
|
|
return None, None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Display
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _show(release_name: str, tmdb_title: str | None, tmdb_year: int | None,
|
|
tmdb_episode_title: str | None, ext: str) -> None:
|
|
from alfred.domain.release import parse_release
|
|
|
|
p = parse_release(release_name)
|
|
|
|
# Auto-fetch TMDB if requested and not already provided
|
|
if not (tmdb_title and tmdb_year):
|
|
fetched_title, fetched_year = _fetch_tmdb(p.title.replace(".", " "))
|
|
tmdb_title = tmdb_title or fetched_title
|
|
tmdb_year = tmdb_year or fetched_year
|
|
|
|
print()
|
|
print(c("━" * 64, BOLD))
|
|
print(c(f" ParsedRelease — {p.raw}", BOLD, CYAN))
|
|
print(c("━" * 64, BOLD))
|
|
|
|
# Core fields
|
|
hr()
|
|
kv("raw", p.raw)
|
|
kv("normalised", p.normalised)
|
|
kv("title", p.title)
|
|
kv("year", str(p.year) if p.year else c("None", DIM))
|
|
kv("season", str(p.season) if p.season is not None else c("None", DIM))
|
|
kv("episode", str(p.episode) if p.episode is not None else c("None", DIM))
|
|
kv("episode_end", str(p.episode_end) if p.episode_end is not None else c("None", DIM))
|
|
kv("quality", p.quality or c("None", DIM))
|
|
kv("source", p.source or c("None", DIM))
|
|
kv("codec", p.codec or c("None", DIM))
|
|
kv("group", p.group, YELLOW if p.group == "UNKNOWN" else GREEN)
|
|
kv("tech_string", p.tech_string or c("(empty)", DIM))
|
|
|
|
# Derived booleans
|
|
hr()
|
|
kv("is_movie", c(str(p.is_movie), GREEN if p.is_movie else DIM))
|
|
kv("is_season_pack", c(str(p.is_season_pack), GREEN if p.is_season_pack else DIM))
|
|
|
|
# Generated names
|
|
hr()
|
|
title_for_names = tmdb_title or p.title.replace(".", " ")
|
|
year_for_names = tmdb_year or p.year or 0
|
|
|
|
if p.is_movie:
|
|
kv("movie_folder_name", p.movie_folder_name(title_for_names, year_for_names))
|
|
kv("movie_filename", p.movie_filename(title_for_names, year_for_names, ext))
|
|
else:
|
|
kv("show_folder_name", p.show_folder_name(title_for_names, year_for_names))
|
|
kv("season_folder_name", p.season_folder_name())
|
|
if not p.is_season_pack:
|
|
kv("episode_filename", p.episode_filename(tmdb_episode_title, ext))
|
|
else:
|
|
kv("episode_filename", c("(season pack — no episode filename)", DIM))
|
|
|
|
if tmdb_title or tmdb_year or tmdb_episode_title:
|
|
hr()
|
|
print(c(" TMDB data used:", DIM))
|
|
if tmdb_title: kv(" tmdb_title", tmdb_title)
|
|
if tmdb_year: kv(" tmdb_year", str(tmdb_year))
|
|
if tmdb_episode_title: kv(" tmdb_episode_title", tmdb_episode_title)
|
|
|
|
print(c("━" * 64, BOLD))
|
|
print()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Interactive mode
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _interactive() -> None:
|
|
print(c("\n Alfred — Release Parser REPL", BOLD, CYAN))
|
|
print(c(" Type a release name, or 'q' to quit.", DIM))
|
|
print(c(" Inline overrides: ::title=Oz ::year=1997 ::ep=The.Routine ::ext=.mkv\n", DIM))
|
|
|
|
while True:
|
|
try:
|
|
raw = input(c(" release> ", BOLD)).strip()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print()
|
|
break
|
|
|
|
if not raw or raw.lower() in ("q", "quit", "exit"):
|
|
break
|
|
|
|
# Parse inline overrides: "Oz.S03E01... ::title=Oz ::year=1997 ::tmdb"
|
|
parts = raw.split("::")
|
|
release = parts[0].strip()
|
|
overrides: dict[str, str] = {}
|
|
for part in parts[1:]:
|
|
part = part.strip()
|
|
if "=" in part:
|
|
k, _, v = part.partition("=")
|
|
overrides[k.strip()] = v.strip()
|
|
else:
|
|
overrides[part] = "1" # flag-style: ::tmdb
|
|
|
|
tmdb_title = overrides.get("title")
|
|
tmdb_year = int(overrides["year"]) if "year" in overrides else None
|
|
tmdb_episode_title = overrides.get("ep")
|
|
ext = overrides.get("ext", ".mkv")
|
|
try:
|
|
_show(release, tmdb_title, tmdb_year, tmdb_episode_title, ext)
|
|
except Exception as e:
|
|
print(c(f" Error: {e}", RED))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main() -> None:
|
|
global USE_COLOR
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Test ParsedRelease from domain/release/release_parser.py",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument("release", nargs="?", help="Release name to parse")
|
|
parser.add_argument("-i", "--interactive", action="store_true",
|
|
help="Interactive REPL mode")
|
|
parser.add_argument("--tmdb-title", metavar="TITLE",
|
|
help="Override TMDB title for name generation")
|
|
parser.add_argument("--tmdb-year", metavar="YEAR", type=int,
|
|
help="Override TMDB year for name generation")
|
|
parser.add_argument("--episode-title", metavar="TITLE",
|
|
help="TMDB episode title for episode_filename()")
|
|
parser.add_argument("--ext", default=".mkv", metavar="EXT",
|
|
help="File extension for filename generation (default: .mkv)")
|
|
parser.add_argument("--no-color", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
if args.no_color or not sys.stdout.isatty():
|
|
USE_COLOR = False
|
|
|
|
if args.interactive:
|
|
_interactive()
|
|
return
|
|
|
|
if not args.release:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
try:
|
|
_show(args.release, args.tmdb_title, args.tmdb_year, args.episode_title, args.ext)
|
|
except Exception as e:
|
|
print(c(f"Error: {e}", RED), file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|