Skip to content

Commit 66c7127

Browse files
EstrellaXDclaude
andcommitted
feat(offset): add automatic season/episode offset detection
- Add offset detector to identify season mismatches between RSS and TMDB - Only suggest season_offset (user sets episode_offset manually) - Add background scanner for existing bangumi rules - Add detect-offset and dismiss-review API endpoints - Add warning banner in edit dialog with auto-detect button - Add iOS-style notification badge for needs_review items - Yellow badge with "!" for warnings, purple badge for multi-rule count - Combined badge shows "! | 2" when both conditions apply - Yellow glow animation on cards needing review - Highlight warning items in rule selection popup Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9790dce commit 66c7127

20 files changed

Lines changed: 1645 additions & 81 deletions

File tree

backend/src/module/api/bangumi.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,59 @@
1+
from typing import Literal, Optional
2+
13
from fastapi import APIRouter, Depends
24
from fastapi.responses import JSONResponse
35
from pydantic import BaseModel
46

7+
from module.conf import settings
8+
from module.database import Database
59
from module.manager import TorrentManager
610
from module.models import APIResponse, Bangumi, BangumiUpdate
11+
from module.parser.analyser.offset_detector import (
12+
OffsetSuggestion as DetectorSuggestion,
13+
)
14+
from module.parser.analyser.offset_detector import detect_offset_mismatch
15+
from module.parser.analyser.tmdb_parser import tmdb_parser
716
from module.security.api import UNAUTHORIZED, get_current_user
817

918
from .response import u_response
1019

1120

1221
class OffsetSuggestion(BaseModel):
22+
"""Legacy offset suggestion model."""
1323
suggested_offset: int
1424
reason: str
1525

26+
27+
class TMDBSummary(BaseModel):
28+
"""Summary of TMDB data for display."""
29+
title: str
30+
total_seasons: int
31+
season_episode_counts: dict[int, int]
32+
status: Optional[str]
33+
virtual_season_starts: Optional[dict[int, list[int]]] = None # {1: [1, 29], ...}
34+
35+
36+
class OffsetSuggestionDetail(BaseModel):
37+
"""Detailed offset suggestion from detector."""
38+
season_offset: int
39+
episode_offset: int
40+
reason: str
41+
confidence: Literal["high", "medium", "low"]
42+
43+
44+
class DetectOffsetRequest(BaseModel):
45+
"""Request body for detect-offset endpoint."""
46+
title: str
47+
parsed_season: int
48+
parsed_episode: int
49+
50+
51+
class DetectOffsetResponse(BaseModel):
52+
"""Response for detect-offset endpoint."""
53+
has_mismatch: bool
54+
suggestion: Optional[OffsetSuggestionDetail]
55+
tmdb_info: Optional[TMDBSummary]
56+
1657
router = APIRouter(prefix="/bangumi", tags=["bangumi"])
1758

1859

@@ -202,3 +243,99 @@ async def suggest_offset(bangumi_id: int):
202243
with TorrentManager() as manager:
203244
resp = await manager.suggest_offset(bangumi_id)
204245
return resp
246+
247+
248+
@router.post(
249+
path="/detect-offset",
250+
response_model=DetectOffsetResponse,
251+
dependencies=[Depends(get_current_user)],
252+
)
253+
async def detect_offset(request: DetectOffsetRequest):
254+
"""Detect season/episode mismatch with TMDB data.
255+
256+
Called by frontend before adding/subscribing to check if offsets are needed.
257+
"""
258+
language = settings.rss_parser.language
259+
tmdb_info = await tmdb_parser(request.title, language)
260+
261+
if not tmdb_info:
262+
return DetectOffsetResponse(
263+
has_mismatch=False,
264+
suggestion=None,
265+
tmdb_info=None,
266+
)
267+
268+
# Detect mismatch
269+
suggestion = detect_offset_mismatch(
270+
parsed_season=request.parsed_season,
271+
parsed_episode=request.parsed_episode,
272+
tmdb_info=tmdb_info,
273+
)
274+
275+
# Build TMDB summary
276+
tmdb_summary = TMDBSummary(
277+
title=tmdb_info.title,
278+
total_seasons=tmdb_info.last_season,
279+
season_episode_counts=tmdb_info.season_episode_counts or {},
280+
status=tmdb_info.series_status,
281+
virtual_season_starts=tmdb_info.virtual_season_starts,
282+
)
283+
284+
if suggestion:
285+
return DetectOffsetResponse(
286+
has_mismatch=True,
287+
suggestion=OffsetSuggestionDetail(
288+
season_offset=suggestion.season_offset,
289+
episode_offset=suggestion.episode_offset,
290+
reason=suggestion.reason,
291+
confidence=suggestion.confidence,
292+
),
293+
tmdb_info=tmdb_summary,
294+
)
295+
296+
return DetectOffsetResponse(
297+
has_mismatch=False,
298+
suggestion=None,
299+
tmdb_info=tmdb_summary,
300+
)
301+
302+
303+
@router.post(
304+
path="/dismiss-review/{bangumi_id}",
305+
response_model=APIResponse,
306+
dependencies=[Depends(get_current_user)],
307+
)
308+
async def dismiss_review(bangumi_id: int):
309+
"""Clear the needs_review flag for a bangumi after user reviews."""
310+
with Database() as db:
311+
success = db.bangumi.clear_needs_review(bangumi_id)
312+
313+
if success:
314+
return JSONResponse(
315+
status_code=200,
316+
content={
317+
"status": True,
318+
"msg_en": "Review dismissed.",
319+
"msg_zh": "已取消检查标记。",
320+
},
321+
)
322+
else:
323+
return JSONResponse(
324+
status_code=404,
325+
content={
326+
"status": False,
327+
"msg_en": f"Bangumi {bangumi_id} not found.",
328+
"msg_zh": f"未找到番剧 {bangumi_id}。",
329+
},
330+
)
331+
332+
333+
@router.get(
334+
path="/needs-review",
335+
response_model=list[Bangumi],
336+
dependencies=[Depends(get_current_user)],
337+
)
338+
async def get_needs_review():
339+
"""Get all bangumi that need review for offset mismatch."""
340+
with Database() as db:
341+
return db.bangumi.get_needs_review()
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Background scanner for detecting season/episode offset mismatches."""
2+
3+
import logging
4+
5+
from module.conf import settings
6+
from module.database import Database
7+
from module.models import Bangumi
8+
from module.parser.analyser.offset_detector import detect_offset_mismatch
9+
from module.parser.analyser.tmdb_parser import tmdb_parser
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class OffsetScanner:
15+
"""Periodically scan bangumi for season/episode mismatches with TMDB."""
16+
17+
async def scan_all(self) -> int:
18+
"""Scan all active bangumi for offset mismatches.
19+
20+
Returns:
21+
Number of bangumi flagged for review.
22+
"""
23+
logger.info("[OffsetScanner] Starting offset scan...")
24+
25+
with Database() as db:
26+
bangumi_list = db.bangumi.get_active_for_scan()
27+
28+
if not bangumi_list:
29+
logger.debug("[OffsetScanner] No active bangumi to scan.")
30+
return 0
31+
32+
flagged_count = 0
33+
for bangumi in bangumi_list:
34+
try:
35+
if await self._check_bangumi(bangumi):
36+
flagged_count += 1
37+
except Exception as e:
38+
logger.warning(
39+
f"[OffsetScanner] Error checking {bangumi.official_title}: {e}"
40+
)
41+
42+
logger.info(
43+
f"[OffsetScanner] Scan complete. Flagged {flagged_count} bangumi for review."
44+
)
45+
return flagged_count
46+
47+
async def _check_bangumi(self, bangumi: Bangumi) -> bool:
48+
"""Check a single bangumi for offset mismatch.
49+
50+
Args:
51+
bangumi: The bangumi to check.
52+
53+
Returns:
54+
True if flagged for review, False otherwise.
55+
"""
56+
# Skip if already needs review
57+
if bangumi.needs_review:
58+
logger.debug(
59+
f"[OffsetScanner] Skipping {bangumi.official_title}: already needs review"
60+
)
61+
return False
62+
63+
# Skip if user has already configured offsets
64+
if bangumi.season_offset != 0 or bangumi.episode_offset != 0:
65+
logger.debug(
66+
f"[OffsetScanner] Skipping {bangumi.official_title}: has configured offsets"
67+
)
68+
return False
69+
70+
# Get TMDB info
71+
language = settings.rss_parser.language
72+
tmdb_info = await tmdb_parser(bangumi.official_title, language)
73+
74+
if not tmdb_info:
75+
logger.debug(
76+
f"[OffsetScanner] Skipping {bangumi.official_title}: no TMDB info"
77+
)
78+
return False
79+
80+
# Get latest episode for this bangumi (use season as proxy since we don't track episodes)
81+
# For now, we'll check based on the bangumi's season
82+
parsed_episode = 1 # Default to episode 1 for season-based detection
83+
84+
# Detect mismatch
85+
suggestion = detect_offset_mismatch(
86+
parsed_season=bangumi.season,
87+
parsed_episode=parsed_episode,
88+
tmdb_info=tmdb_info,
89+
)
90+
91+
if suggestion and suggestion.confidence in ("high", "medium"):
92+
with Database() as db:
93+
db.bangumi.set_needs_review(bangumi.id, suggestion.reason)
94+
logger.info(
95+
f"[OffsetScanner] Flagged {bangumi.official_title} for review: {suggestion.reason}"
96+
)
97+
return True
98+
99+
return False
100+
101+
async def check_single(self, bangumi_id: int) -> bool:
102+
"""Check a single bangumi by ID.
103+
104+
Args:
105+
bangumi_id: The ID of the bangumi to check.
106+
107+
Returns:
108+
True if flagged for review, False otherwise.
109+
"""
110+
with Database() as db:
111+
bangumi = db.bangumi.search_id(bangumi_id)
112+
113+
if not bangumi:
114+
logger.warning(f"[OffsetScanner] Bangumi {bangumi_id} not found")
115+
return False
116+
117+
return await self._check_bangumi(bangumi)

backend/src/module/core/program.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
start_up,
1414
)
1515

16-
from .sub_thread import RenameThread, RSSThread
16+
from .sub_thread import OffsetScanThread, RenameThread, RSSThread
1717

1818
logger = logging.getLogger(__name__)
1919

@@ -29,7 +29,7 @@
2929
"""
3030

3131

32-
class Program(RenameThread, RSSThread):
32+
class Program(RenameThread, RSSThread, OffsetScanThread):
3333
@staticmethod
3434
def __start_info():
3535
for line in figlet.splitlines():
@@ -91,6 +91,8 @@ async def start(self):
9191
self.rename_start()
9292
if self.enable_rss:
9393
self.rss_start()
94+
# Start offset scanner for background mismatch detection
95+
self.scan_start()
9496
logger.info("Program running.")
9597
return ResponseModel(
9698
status=True,
@@ -104,6 +106,7 @@ async def stop(self):
104106
self.stop_event.set()
105107
await self.rename_stop()
106108
await self.rss_stop()
109+
await self.scan_stop()
107110
return ResponseModel(
108111
status=True,
109112
status_code=200,

backend/src/module/core/sub_thread.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import asyncio
2+
import logging
23

34
from module.conf import settings
45
from module.downloader import DownloadClient
56
from module.manager import Renamer, eps_complete
67
from module.notification import PostNotification
78
from module.rss import RSSAnalyser, RSSEngine
89

10+
from .offset_scanner import OffsetScanner
911
from .status import ProgramStatus
1012

13+
logger = logging.getLogger(__name__)
14+
1115

1216
class RSSThread(ProgramStatus):
1317
def __init__(self):
@@ -83,3 +87,50 @@ async def rename_stop(self):
8387
except asyncio.CancelledError:
8488
pass
8589
self._rename_task = None
90+
91+
92+
# Offset scan interval in seconds (6 hours)
93+
OFFSET_SCAN_INTERVAL = 6 * 60 * 60
94+
95+
96+
class OffsetScanThread(ProgramStatus):
97+
"""Background thread for scanning bangumi offset mismatches."""
98+
99+
def __init__(self):
100+
super().__init__()
101+
self._scan_task: asyncio.Task | None = None
102+
self._scanner = OffsetScanner()
103+
104+
async def scan_loop(self):
105+
# Initial delay to let the system stabilize
106+
await asyncio.sleep(60)
107+
108+
while not self.stop_event.is_set():
109+
try:
110+
flagged = await self._scanner.scan_all()
111+
logger.info(f"[OffsetScanThread] Scan complete, flagged {flagged} bangumi")
112+
except Exception as e:
113+
logger.error(f"[OffsetScanThread] Error during scan: {e}")
114+
115+
try:
116+
await asyncio.wait_for(
117+
self.stop_event.wait(),
118+
timeout=OFFSET_SCAN_INTERVAL,
119+
)
120+
except asyncio.TimeoutError:
121+
pass
122+
123+
def scan_start(self):
124+
self._scan_task = asyncio.create_task(self.scan_loop())
125+
logger.info("[OffsetScanThread] Started offset scanner")
126+
127+
async def scan_stop(self):
128+
if self._scan_task and not self._scan_task.done():
129+
self.stop_event.set()
130+
self._scan_task.cancel()
131+
try:
132+
await self._scan_task
133+
except asyncio.CancelledError:
134+
pass
135+
self._scan_task = None
136+
logger.info("[OffsetScanThread] Stopped offset scanner")

0 commit comments

Comments
 (0)