Skip to content

Commit 1b52bce

Browse files
authored
Add files via upload
1 parent b8347fb commit 1b52bce

File tree

13 files changed

+2498
-0
lines changed

13 files changed

+2498
-0
lines changed

Requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
numpy
2+
requests

analyzer.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""
2+
High-level video analysis: ties together episode detection, theme selection,
3+
and audio matching to produce an AnalysisResult.
4+
"""
5+
from __future__ import annotations
6+
7+
import os
8+
import threading
9+
from typing import Callable, Optional
10+
11+
from api_animethemes import select_theme_for_episode
12+
from audio_matcher import find_theme_start
13+
from constants import ED_SEARCH_START_SECONDS, OP_SEARCH_END_SECONDS
14+
from episode import extract_episode_number
15+
from ffprobe_utils import get_video_duration_ms
16+
from models import AnalysisResult, MatchSource, Theme
17+
from timestamps import ms_to_display
18+
19+
20+
def analyze_video(
21+
video_path: str,
22+
themes: list[Theme],
23+
log_func: Optional[Callable] = None,
24+
cancel_event: Optional[threading.Event] = None,
25+
) -> AnalysisResult:
26+
"""Analyze a single video file for OP/ED timestamps"""
27+
28+
def log(msg: str, tag: str = "dim"):
29+
if log_func:
30+
log_func(msg, tag)
31+
32+
basename = os.path.basename(video_path)
33+
episode = extract_episode_number(video_path)
34+
35+
log(f"\n{'─' * 54}\n", "dim")
36+
log(f"▶ {basename}\n", "ch")
37+
log(f" Episode: {episode if episode is not None else '?'}\n", "dim")
38+
39+
video_duration_ms = get_video_duration_ms(video_path)
40+
video_duration_s = (video_duration_ms or 1_440_000) / 1000.0
41+
42+
# Select themes with diagnostics
43+
log(" [Theme Selection]\n", "dim")
44+
op_theme = select_theme_for_episode(themes, "OP", episode, log_func)
45+
ed_theme = select_theme_for_episode(themes, "ED", episode, log_func)
46+
47+
# Log theme info
48+
for role, theme in [("OP", op_theme), ("ED", ed_theme)]:
49+
if theme:
50+
eps = sorted(theme.episode_set) if theme.episode_set else []
51+
dur = f"{theme.duration_ms // 1000}s" if theme.duration_ms else "?s"
52+
log(
53+
f" {role}{theme.label} \"{theme.title}\" "
54+
f"dur={dur} eps={eps or 'all'}\n",
55+
"th",
56+
)
57+
else:
58+
log(f" {role} → not found in API\n", "err")
59+
60+
result = AnalysisResult(
61+
video_path=video_path,
62+
basename=basename,
63+
episode=episode,
64+
video_duration_ms=video_duration_ms,
65+
op_theme=op_theme,
66+
ed_theme=ed_theme,
67+
)
68+
69+
# ─── Search for OP (first 5 minutes) ──────────────────────────────────────
70+
if op_theme and op_theme.video_url and op_theme.duration_ms:
71+
if cancel_event and cancel_event.is_set():
72+
return result
73+
74+
op_dur = op_theme.duration_ms
75+
op_end_s = min(video_duration_s, OP_SEARCH_END_SECONDS)
76+
77+
log(f" [OP] Searching 0s → {op_end_s:.0f}s...\n", "dim")
78+
79+
start_ms = find_theme_start(
80+
video_path,
81+
op_theme.video_url,
82+
op_dur,
83+
search_start_seconds=0.0,
84+
search_end_seconds=op_end_s,
85+
log_func=log_func,
86+
cancel_event=cancel_event,
87+
)
88+
89+
if start_ms is not None:
90+
result.op_start_ms = start_ms
91+
result.op_end_ms = start_ms + op_dur
92+
result.op_source = MatchSource.AUDIO
93+
log(f" [OP] Found {ms_to_display(start_ms)}{ms_to_display(result.op_end_ms)}\n", "ok")
94+
else:
95+
result.op_start_ms = 0
96+
result.op_end_ms = op_dur
97+
result.op_source = MatchSource.FALLBACK
98+
log(f" [OP] Fallback: 00:00:00 → {ms_to_display(op_dur)}\n", "err")
99+
100+
# ─── Search for ED (last 8 minutes) ───────────────────────────────────────
101+
if ed_theme and ed_theme.video_url and ed_theme.duration_ms:
102+
if cancel_event and cancel_event.is_set():
103+
return result
104+
105+
ed_dur = ed_theme.duration_ms
106+
ed_start_s = max(0.0, video_duration_s - ED_SEARCH_START_SECONDS)
107+
108+
log(f" [ED] Searching {ed_start_s:.0f}s → {video_duration_s:.0f}s...\n", "dim")
109+
log(
110+
f" [ED] Theme: {ed_theme.label}, Duration: {ed_dur // 1000}s, "
111+
f"URL: {ed_theme.video_url[:50]}...\n",
112+
"dim",
113+
)
114+
115+
start_ms = find_theme_start(
116+
video_path,
117+
ed_theme.video_url,
118+
ed_dur,
119+
search_start_seconds=ed_start_s,
120+
search_end_seconds=video_duration_s,
121+
log_func=log_func,
122+
cancel_event=cancel_event,
123+
)
124+
125+
if start_ms is not None:
126+
result.ed_start_ms = start_ms
127+
result.ed_end_ms = start_ms + ed_dur
128+
result.ed_source = MatchSource.AUDIO
129+
log(f" [ED] Found {ms_to_display(start_ms)}{ms_to_display(result.ed_end_ms)}\n", "ok")
130+
131+
# Only add After Credits if there's meaningful content after ED ends
132+
if video_duration_ms and video_duration_ms - result.ed_end_ms > 5000:
133+
log(
134+
f" [ED] After Credits: {ms_to_display(result.ed_end_ms)} "
135+
f"→ {ms_to_display(video_duration_ms)}\n",
136+
"dim",
137+
)
138+
else:
139+
# ED not found via audio matching - DO NOT add fallback timing
140+
# Let user manually set it in review dialog
141+
result.ed_source = MatchSource.NONE
142+
log(f" [ED] NOT FOUND - audio match failed. Check if correct theme was selected.\n", "err")
143+
log(
144+
f" [ED] Possible issues: wrong theme selected, audio differs from reference, "
145+
f"or ED missing from video\n",
146+
"err",
147+
)
148+
else:
149+
# No ED theme available from API
150+
if ed_theme:
151+
if not ed_theme.video_url:
152+
log(f" [ED] Theme exists but no video URL available\n", "err")
153+
elif not ed_theme.duration_ms:
154+
log(f" [ED] Theme exists but duration unknown\n", "err")
155+
result.ed_source = MatchSource.NONE
156+
157+
return result

api_animethemes.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""
2+
animethemes.moe API client: search, theme fetching, episode-aware theme selection.
3+
"""
4+
from __future__ import annotations
5+
6+
import json
7+
import re
8+
import urllib.parse
9+
import urllib.request
10+
from typing import Callable, Optional
11+
12+
from constants import API_BASE, API_HEADERS, API_TIMEOUT
13+
from models import Theme
14+
15+
16+
def api_request(url: str, timeout: int = API_TIMEOUT) -> dict:
17+
"""Make API request and return JSON response"""
18+
request = urllib.request.Request(url, headers=API_HEADERS)
19+
with urllib.request.urlopen(request, timeout=timeout) as response:
20+
return json.loads(response.read().decode("utf-8"))
21+
22+
23+
def search_anime(query: str) -> list[dict]:
24+
"""Search for anime by name"""
25+
encoded_query = urllib.parse.quote(query)
26+
url = f"{API_BASE}/anime?q={encoded_query}&fields[anime]=name,slug,year&page[size]=10"
27+
data = api_request(url)
28+
return data.get("anime", [])
29+
30+
31+
def parse_episode_set(episodes_str: str) -> set[int]:
32+
"""Parse episode string like '1-3, 5, 7-9' into set of integers"""
33+
if not episodes_str or not episodes_str.strip():
34+
return set()
35+
36+
episodes: set[int] = set()
37+
38+
for part in re.split(r"[,،]", episodes_str):
39+
part = part.strip()
40+
41+
# Range like "1-3" or "1–3"
42+
range_match = re.match(r"^(\d+)\s*[-–]\s*(\d+)$", part)
43+
if range_match:
44+
start, end = int(range_match.group(1)), int(range_match.group(2))
45+
episodes.update(range(start, end + 1))
46+
continue
47+
48+
# Open-ended like "1-"
49+
open_match = re.match(r"^(\d+)\s*[-–]$", part)
50+
if open_match:
51+
start = int(open_match.group(1))
52+
episodes.update(range(start, 1000)) # Arbitrary upper limit
53+
continue
54+
55+
# Single episode
56+
single_match = re.match(r"^(\d+)$", part)
57+
if single_match:
58+
episodes.add(int(single_match.group(1)))
59+
60+
return episodes
61+
62+
63+
def get_anime_themes(slug: str, log_func: Optional[Callable] = None) -> list[Theme]:
64+
"""
65+
Fetch all theme songs for an anime.
66+
67+
Returns list of Theme objects with:
68+
- label: "OP1", "ED1", "ED1v2", etc.
69+
- type: "OP" | "ED"
70+
- title: Song title
71+
- video_url: Direct video link
72+
- duration_ms: Duration (filled later with ffprobe)
73+
- episode_set: Episodes this theme applies to
74+
"""
75+
url = (
76+
f"{API_BASE}/anime/{slug}"
77+
"?include=animethemes.animethemeentries.videos,animethemes.song"
78+
"&fields[animetheme]=type,sequence"
79+
"&fields[animethemeentry]=episodes,version"
80+
"&fields[video]=link"
81+
"&fields[song]=title"
82+
)
83+
84+
data = api_request(url)
85+
themes_data = data.get("anime", {}).get("animethemes", [])
86+
themes: list[Theme] = []
87+
88+
for theme in themes_data:
89+
theme_type = theme.get("type", "OP")
90+
sequence = theme.get("sequence") or 1
91+
song_title = (theme.get("song") or {}).get("title", "Unknown")
92+
93+
for entry in theme.get("animethemeentries", []):
94+
version = entry.get("version") or 1
95+
episode_set = parse_episode_set(entry.get("episodes") or "")
96+
videos = entry.get("videos") or []
97+
98+
if not videos:
99+
continue
100+
101+
video_url = videos[0].get("link")
102+
if not video_url:
103+
continue
104+
105+
theme_obj = Theme(
106+
label=f"{theme_type}{sequence}" + (f"v{version}" if version > 1 else ""),
107+
type=theme_type,
108+
sequence=sequence,
109+
version=version,
110+
title=song_title,
111+
video_url=video_url,
112+
duration_ms=None,
113+
episode_set=episode_set,
114+
)
115+
themes.append(theme_obj)
116+
117+
return themes
118+
119+
120+
def select_theme_for_episode(
121+
themes: list[Theme],
122+
theme_type: str,
123+
episode: Optional[int],
124+
log_func: Optional[Callable] = None
125+
) -> Optional[Theme]:
126+
"""
127+
Select the appropriate theme version for a specific episode.
128+
129+
Selection priority:
130+
1. Theme whose episode_set explicitly contains the episode
131+
2. Theme without episode restrictions (applies to all)
132+
3. Theme with highest sequence whose min_episode <= current episode
133+
4. Fallback to last theme (most recent)
134+
"""
135+
candidates = [t for t in themes if t.type == theme_type]
136+
137+
if not candidates:
138+
return None
139+
140+
if episode is None:
141+
if log_func:
142+
log_func(f" [{theme_type}] No episode number, using first theme: {candidates[0].label}\n", "dim")
143+
return candidates[0]
144+
145+
# First, try to find a theme that specifically includes this episode
146+
for theme in candidates:
147+
if theme.episode_set and episode in theme.episode_set:
148+
if log_func:
149+
eps = sorted(theme.episode_set) if theme.episode_set else []
150+
log_func(f" [{theme_type}] Selected {theme.label}: ep {episode} in {eps}\n", "dim")
151+
return theme
152+
153+
# Then, try themes without episode restrictions (applies to all)
154+
for theme in candidates:
155+
if not theme.episode_set:
156+
if log_func:
157+
log_func(f" [{theme_type}] Selected {theme.label}: no episode restriction\n", "dim")
158+
return theme
159+
160+
# If episode not in any set, pick theme with highest sequence whose min_episode <= episode
161+
# This handles cases like ED2 with eps=[3] meaning "from episode 3 onwards"
162+
best_theme: Optional[Theme] = None
163+
best_min_ep = -1
164+
165+
for theme in candidates:
166+
if theme.episode_set:
167+
min_ep = min(theme.episode_set)
168+
# Theme applies if its range starts at or before current episode
169+
if min_ep <= episode:
170+
# Prefer higher sequence or later starting episode
171+
if best_theme is None or theme.sequence > best_theme.sequence or min_ep > best_min_ep:
172+
best_theme = theme
173+
best_min_ep = min_ep
174+
175+
if best_theme:
176+
if log_func:
177+
eps = sorted(best_theme.episode_set) if best_theme.episode_set else []
178+
log_func(
179+
f" [{theme_type}] Selected {best_theme.label}: "
180+
f"min_ep={best_min_ep} <= ep{episode} (fallback logic)\n",
181+
"dim"
182+
)
183+
return best_theme
184+
185+
# Ultimate fallback: use the last theme (most recent sequence)
186+
result = candidates[-1]
187+
if log_func:
188+
log_func(
189+
f" [{theme_type}] No matching theme found for ep {episode}, "
190+
f"using last: {result.label}\n",
191+
"err"
192+
)
193+
return result

0 commit comments

Comments
 (0)