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>
This commit is contained in:
Estrella Pan
2026-01-25 20:56:42 +01:00
parent 9790dce06c
commit 66c7127f21
20 changed files with 1645 additions and 81 deletions

View File

@@ -1,18 +1,59 @@
from typing import Literal, Optional
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from module.conf import settings
from module.database import Database
from module.manager import TorrentManager
from module.models import APIResponse, Bangumi, BangumiUpdate
from module.parser.analyser.offset_detector import (
OffsetSuggestion as DetectorSuggestion,
)
from module.parser.analyser.offset_detector import detect_offset_mismatch
from module.parser.analyser.tmdb_parser import tmdb_parser
from module.security.api import UNAUTHORIZED, get_current_user
from .response import u_response
class OffsetSuggestion(BaseModel):
"""Legacy offset suggestion model."""
suggested_offset: int
reason: str
class TMDBSummary(BaseModel):
"""Summary of TMDB data for display."""
title: str
total_seasons: int
season_episode_counts: dict[int, int]
status: Optional[str]
virtual_season_starts: Optional[dict[int, list[int]]] = None # {1: [1, 29], ...}
class OffsetSuggestionDetail(BaseModel):
"""Detailed offset suggestion from detector."""
season_offset: int
episode_offset: int
reason: str
confidence: Literal["high", "medium", "low"]
class DetectOffsetRequest(BaseModel):
"""Request body for detect-offset endpoint."""
title: str
parsed_season: int
parsed_episode: int
class DetectOffsetResponse(BaseModel):
"""Response for detect-offset endpoint."""
has_mismatch: bool
suggestion: Optional[OffsetSuggestionDetail]
tmdb_info: Optional[TMDBSummary]
router = APIRouter(prefix="/bangumi", tags=["bangumi"])
@@ -202,3 +243,99 @@ async def suggest_offset(bangumi_id: int):
with TorrentManager() as manager:
resp = await manager.suggest_offset(bangumi_id)
return resp
@router.post(
path="/detect-offset",
response_model=DetectOffsetResponse,
dependencies=[Depends(get_current_user)],
)
async def detect_offset(request: DetectOffsetRequest):
"""Detect season/episode mismatch with TMDB data.
Called by frontend before adding/subscribing to check if offsets are needed.
"""
language = settings.rss_parser.language
tmdb_info = await tmdb_parser(request.title, language)
if not tmdb_info:
return DetectOffsetResponse(
has_mismatch=False,
suggestion=None,
tmdb_info=None,
)
# Detect mismatch
suggestion = detect_offset_mismatch(
parsed_season=request.parsed_season,
parsed_episode=request.parsed_episode,
tmdb_info=tmdb_info,
)
# Build TMDB summary
tmdb_summary = TMDBSummary(
title=tmdb_info.title,
total_seasons=tmdb_info.last_season,
season_episode_counts=tmdb_info.season_episode_counts or {},
status=tmdb_info.series_status,
virtual_season_starts=tmdb_info.virtual_season_starts,
)
if suggestion:
return DetectOffsetResponse(
has_mismatch=True,
suggestion=OffsetSuggestionDetail(
season_offset=suggestion.season_offset,
episode_offset=suggestion.episode_offset,
reason=suggestion.reason,
confidence=suggestion.confidence,
),
tmdb_info=tmdb_summary,
)
return DetectOffsetResponse(
has_mismatch=False,
suggestion=None,
tmdb_info=tmdb_summary,
)
@router.post(
path="/dismiss-review/{bangumi_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def dismiss_review(bangumi_id: int):
"""Clear the needs_review flag for a bangumi after user reviews."""
with Database() as db:
success = db.bangumi.clear_needs_review(bangumi_id)
if success:
return JSONResponse(
status_code=200,
content={
"status": True,
"msg_en": "Review dismissed.",
"msg_zh": "已取消检查标记。",
},
)
else:
return JSONResponse(
status_code=404,
content={
"status": False,
"msg_en": f"Bangumi {bangumi_id} not found.",
"msg_zh": f"未找到番剧 {bangumi_id}",
},
)
@router.get(
path="/needs-review",
response_model=list[Bangumi],
dependencies=[Depends(get_current_user)],
)
async def get_needs_review():
"""Get all bangumi that need review for offset mismatch."""
with Database() as db:
return db.bangumi.get_needs_review()