From 66c7127f21bb86d11acd70622edab4666003b3b2 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Sun, 25 Jan 2026 20:56:42 +0100 Subject: [PATCH] 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 --- backend/src/module/api/bangumi.py | 137 +++++ backend/src/module/core/offset_scanner.py | 117 +++++ backend/src/module/core/program.py | 7 +- backend/src/module/core/sub_thread.py | 51 ++ backend/src/module/database/bangumi.py | 48 ++ backend/src/module/database/combine.py | 16 +- backend/src/module/manager/renamer.py | 61 ++- backend/src/module/models/bangumi.py | 13 +- .../module/parser/analyser/offset_detector.py | 115 +++++ .../src/module/parser/analyser/tmdb_parser.py | 131 ++++- webui/src/api/bangumi.ts | 46 +- webui/src/components/ab-bangumi-card.vue | 16 +- webui/src/components/ab-edit-rule.vue | 207 +++++++- .../basic/ab-offset-mismatch-dialog.vue | 471 ++++++++++++++++++ .../components/search/ab-search-confirm.vue | 86 +++- webui/src/i18n/en.json | 28 +- webui/src/i18n/zh-CN.json | 28 +- webui/src/pages/index/bangumi.vue | 106 +++- webui/types/bangumi.ts | 41 +- webui/types/dts/components.d.ts | 1 + 20 files changed, 1645 insertions(+), 81 deletions(-) create mode 100644 backend/src/module/core/offset_scanner.py create mode 100644 backend/src/module/parser/analyser/offset_detector.py create mode 100644 webui/src/components/basic/ab-offset-mismatch-dialog.vue diff --git a/backend/src/module/api/bangumi.py b/backend/src/module/api/bangumi.py index b9a69c1d..3c2aa7d3 100644 --- a/backend/src/module/api/bangumi.py +++ b/backend/src/module/api/bangumi.py @@ -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() diff --git a/backend/src/module/core/offset_scanner.py b/backend/src/module/core/offset_scanner.py new file mode 100644 index 00000000..02f42128 --- /dev/null +++ b/backend/src/module/core/offset_scanner.py @@ -0,0 +1,117 @@ +"""Background scanner for detecting season/episode offset mismatches.""" + +import logging + +from module.conf import settings +from module.database import Database +from module.models import Bangumi +from module.parser.analyser.offset_detector import detect_offset_mismatch +from module.parser.analyser.tmdb_parser import tmdb_parser + +logger = logging.getLogger(__name__) + + +class OffsetScanner: + """Periodically scan bangumi for season/episode mismatches with TMDB.""" + + async def scan_all(self) -> int: + """Scan all active bangumi for offset mismatches. + + Returns: + Number of bangumi flagged for review. + """ + logger.info("[OffsetScanner] Starting offset scan...") + + with Database() as db: + bangumi_list = db.bangumi.get_active_for_scan() + + if not bangumi_list: + logger.debug("[OffsetScanner] No active bangumi to scan.") + return 0 + + flagged_count = 0 + for bangumi in bangumi_list: + try: + if await self._check_bangumi(bangumi): + flagged_count += 1 + except Exception as e: + logger.warning( + f"[OffsetScanner] Error checking {bangumi.official_title}: {e}" + ) + + logger.info( + f"[OffsetScanner] Scan complete. Flagged {flagged_count} bangumi for review." + ) + return flagged_count + + async def _check_bangumi(self, bangumi: Bangumi) -> bool: + """Check a single bangumi for offset mismatch. + + Args: + bangumi: The bangumi to check. + + Returns: + True if flagged for review, False otherwise. + """ + # Skip if already needs review + if bangumi.needs_review: + logger.debug( + f"[OffsetScanner] Skipping {bangumi.official_title}: already needs review" + ) + return False + + # Skip if user has already configured offsets + if bangumi.season_offset != 0 or bangumi.episode_offset != 0: + logger.debug( + f"[OffsetScanner] Skipping {bangumi.official_title}: has configured offsets" + ) + return False + + # Get TMDB info + language = settings.rss_parser.language + tmdb_info = await tmdb_parser(bangumi.official_title, language) + + if not tmdb_info: + logger.debug( + f"[OffsetScanner] Skipping {bangumi.official_title}: no TMDB info" + ) + return False + + # Get latest episode for this bangumi (use season as proxy since we don't track episodes) + # For now, we'll check based on the bangumi's season + parsed_episode = 1 # Default to episode 1 for season-based detection + + # Detect mismatch + suggestion = detect_offset_mismatch( + parsed_season=bangumi.season, + parsed_episode=parsed_episode, + tmdb_info=tmdb_info, + ) + + if suggestion and suggestion.confidence in ("high", "medium"): + with Database() as db: + db.bangumi.set_needs_review(bangumi.id, suggestion.reason) + logger.info( + f"[OffsetScanner] Flagged {bangumi.official_title} for review: {suggestion.reason}" + ) + return True + + return False + + async def check_single(self, bangumi_id: int) -> bool: + """Check a single bangumi by ID. + + Args: + bangumi_id: The ID of the bangumi to check. + + Returns: + True if flagged for review, False otherwise. + """ + with Database() as db: + bangumi = db.bangumi.search_id(bangumi_id) + + if not bangumi: + logger.warning(f"[OffsetScanner] Bangumi {bangumi_id} not found") + return False + + return await self._check_bangumi(bangumi) diff --git a/backend/src/module/core/program.py b/backend/src/module/core/program.py index ac27dfd4..09a4ac93 100644 --- a/backend/src/module/core/program.py +++ b/backend/src/module/core/program.py @@ -13,7 +13,7 @@ from module.update import ( start_up, ) -from .sub_thread import RenameThread, RSSThread +from .sub_thread import OffsetScanThread, RenameThread, RSSThread logger = logging.getLogger(__name__) @@ -29,7 +29,7 @@ figlet = r""" """ -class Program(RenameThread, RSSThread): +class Program(RenameThread, RSSThread, OffsetScanThread): @staticmethod def __start_info(): for line in figlet.splitlines(): @@ -91,6 +91,8 @@ class Program(RenameThread, RSSThread): self.rename_start() if self.enable_rss: self.rss_start() + # Start offset scanner for background mismatch detection + self.scan_start() logger.info("Program running.") return ResponseModel( status=True, @@ -104,6 +106,7 @@ class Program(RenameThread, RSSThread): self.stop_event.set() await self.rename_stop() await self.rss_stop() + await self.scan_stop() return ResponseModel( status=True, status_code=200, diff --git a/backend/src/module/core/sub_thread.py b/backend/src/module/core/sub_thread.py index 31b3c301..131fa4af 100644 --- a/backend/src/module/core/sub_thread.py +++ b/backend/src/module/core/sub_thread.py @@ -1,4 +1,5 @@ import asyncio +import logging from module.conf import settings from module.downloader import DownloadClient @@ -6,8 +7,11 @@ from module.manager import Renamer, eps_complete from module.notification import PostNotification from module.rss import RSSAnalyser, RSSEngine +from .offset_scanner import OffsetScanner from .status import ProgramStatus +logger = logging.getLogger(__name__) + class RSSThread(ProgramStatus): def __init__(self): @@ -83,3 +87,50 @@ class RenameThread(ProgramStatus): except asyncio.CancelledError: pass self._rename_task = None + + +# Offset scan interval in seconds (6 hours) +OFFSET_SCAN_INTERVAL = 6 * 60 * 60 + + +class OffsetScanThread(ProgramStatus): + """Background thread for scanning bangumi offset mismatches.""" + + def __init__(self): + super().__init__() + self._scan_task: asyncio.Task | None = None + self._scanner = OffsetScanner() + + async def scan_loop(self): + # Initial delay to let the system stabilize + await asyncio.sleep(60) + + while not self.stop_event.is_set(): + try: + flagged = await self._scanner.scan_all() + logger.info(f"[OffsetScanThread] Scan complete, flagged {flagged} bangumi") + except Exception as e: + logger.error(f"[OffsetScanThread] Error during scan: {e}") + + try: + await asyncio.wait_for( + self.stop_event.wait(), + timeout=OFFSET_SCAN_INTERVAL, + ) + except asyncio.TimeoutError: + pass + + def scan_start(self): + self._scan_task = asyncio.create_task(self.scan_loop()) + logger.info("[OffsetScanThread] Started offset scanner") + + async def scan_stop(self): + if self._scan_task and not self._scan_task.done(): + self.stop_event.set() + self._scan_task.cancel() + try: + await self._scan_task + except asyncio.CancelledError: + pass + self._scan_task = None + logger.info("[OffsetScanThread] Stopped offset scanner") diff --git a/backend/src/module/database/bangumi.py b/backend/src/module/database/bangumi.py index 6a73a48d..6fe2b729 100644 --- a/backend/src/module/database/bangumi.py +++ b/backend/src/module/database/bangumi.py @@ -294,3 +294,51 @@ class BangumiDatabase: statement = select(Bangumi).where(Bangumi.save_path == save_path) result = self.session.execute(statement) return result.scalar_one_or_none() + + def get_needs_review(self) -> list[Bangumi]: + """Get all bangumi that need review for offset mismatch.""" + statement = select(Bangumi).where( + and_( + Bangumi.needs_review == True, # noqa: E712 + Bangumi.deleted == false(), + ) + ) + result = self.session.execute(statement) + return list(result.scalars().all()) + + def get_active_for_scan(self) -> list[Bangumi]: + """Get all active (non-deleted, non-archived) bangumi for offset scanning.""" + statement = select(Bangumi).where( + and_( + Bangumi.deleted == false(), + Bangumi.archived == false(), + ) + ) + result = self.session.execute(statement) + return list(result.scalars().all()) + + def set_needs_review(self, _id: int, reason: str) -> bool: + """Mark a bangumi as needing review.""" + bangumi = self.session.get(Bangumi, _id) + if not bangumi: + return False + bangumi.needs_review = True + bangumi.needs_review_reason = reason + self.session.add(bangumi) + self.session.commit() + _invalidate_bangumi_cache() + logger.debug(f"[Database] Marked bangumi id {_id} as needs_review: {reason}") + return True + + def clear_needs_review(self, _id: int) -> bool: + """Clear the needs_review flag for a bangumi.""" + bangumi = self.session.get(Bangumi, _id) + if not bangumi: + return False + bangumi.needs_review = False + bangumi.needs_review_reason = None + self.session.add(bangumi) + self.session.commit() + _invalidate_bangumi_cache() + logger.debug(f"[Database] Cleared needs_review for bangumi id {_id}") + return True diff --git a/backend/src/module/database/combine.py b/backend/src/module/database/combine.py index 0ea186a5..2edec31d 100644 --- a/backend/src/module/database/combine.py +++ b/backend/src/module/database/combine.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) TABLE_MODELS: list[type[SQLModel]] = [Bangumi, RSSItem, Torrent, User, Passkey] # Increment this when adding new migrations to MIGRATIONS list. -CURRENT_SCHEMA_VERSION = 4 +CURRENT_SCHEMA_VERSION = 5 # Each migration is a tuple of (version, description, list of SQL statements). # Migrations are applied in order. A migration at index i brings the schema @@ -70,6 +70,16 @@ MIGRATIONS = [ "add archived column to bangumi", ["ALTER TABLE bangumi ADD COLUMN archived BOOLEAN DEFAULT 0"], ), + ( + 5, + "rename offset to episode_offset, add season_offset and review fields", + [ + "ALTER TABLE bangumi RENAME COLUMN offset TO episode_offset", + "ALTER TABLE bangumi ADD COLUMN season_offset INTEGER DEFAULT 0", + "ALTER TABLE bangumi ADD COLUMN needs_review INTEGER DEFAULT 0", + "ALTER TABLE bangumi ADD COLUMN needs_review_reason TEXT DEFAULT NULL", + ], + ), ] @@ -149,6 +159,10 @@ class Database(Session): columns = [col["name"] for col in inspector.get_columns("bangumi")] if "archived" in columns: needs_run = False + if "bangumi" in tables and version == 5: + columns = [col["name"] for col in inspector.get_columns("bangumi")] + if "episode_offset" in columns: + needs_run = False if needs_run: with self.engine.connect() as conn: for stmt in statements: diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py index 13e575c8..0d145990 100644 --- a/backend/src/module/manager/renamer.py +++ b/backend/src/module/manager/renamer.py @@ -30,14 +30,20 @@ class Renamer(DownloadClient): file_info: EpisodeFile | SubtitleFile, bangumi_name: str, method: str, - offset: int = 0, + episode_offset: int = 0, + season_offset: int = 0, ) -> str: - season = f"0{file_info.season}" if file_info.season < 10 else file_info.season - # Apply offset (offset is stored as the value to ADD) - adjusted_episode = int(file_info.episode) + offset + # Apply season offset + adjusted_season = file_info.season + season_offset + if adjusted_season < 1: + adjusted_season = file_info.season # Safety: don't go below 1 + logger.warning(f"[Renamer] Season offset {season_offset} would result in invalid season, ignoring") + season = f"0{adjusted_season}" if adjusted_season < 10 else adjusted_season + # Apply episode offset + adjusted_episode = int(file_info.episode) + episode_offset if adjusted_episode < 1: adjusted_episode = int(file_info.episode) # Safety: don't go below 1 - logger.warning(f"[Renamer] Offset {offset} would result in negative episode, ignoring") + logger.warning(f"[Renamer] Episode offset {episode_offset} would result in negative episode, ignoring") episode = f"0{adjusted_episode}" if adjusted_episode < 10 else adjusted_episode if method == "none" or method == "subtitle_none": return file_info.media_path @@ -64,7 +70,8 @@ class Renamer(DownloadClient): method: str, season: int, _hash: str, - offset: int = 0, + episode_offset: int = 0, + season_offset: int = 0, **kwargs, ): ep = self._parser.torrent_parser( @@ -73,19 +80,22 @@ class Renamer(DownloadClient): season=season, ) if ep: - new_path = self.gen_path(ep, bangumi_name, method=method, offset=offset) + new_path = self.gen_path(ep, bangumi_name, method=method, episode_offset=episode_offset, season_offset=season_offset) if media_path != new_path: if new_path not in self.check_pool.keys(): if await self.rename_torrent_file( _hash=_hash, old_path=media_path, new_path=new_path ): - # Return adjusted episode number for notification - adjusted_episode = int(ep.episode) + offset + # Return adjusted season and episode numbers for notification + adjusted_season = ep.season + season_offset + if adjusted_season < 1: + adjusted_season = ep.season + adjusted_episode = int(ep.episode) + episode_offset if adjusted_episode < 1: adjusted_episode = int(ep.episode) return Notification( official_title=bangumi_name, - season=ep.season, + season=adjusted_season, episode=adjusted_episode, ) else: @@ -101,7 +111,8 @@ class Renamer(DownloadClient): season: int, method: str, _hash: str, - offset: int = 0, + episode_offset: int = 0, + season_offset: int = 0, **kwargs, ): for media_path in media_list: @@ -111,7 +122,7 @@ class Renamer(DownloadClient): season=season, ) if ep: - new_path = self.gen_path(ep, bangumi_name, method=method, offset=offset) + new_path = self.gen_path(ep, bangumi_name, method=method, episode_offset=episode_offset, season_offset=season_offset) if media_path != new_path: renamed = await self.rename_torrent_file( _hash=_hash, old_path=media_path, new_path=new_path @@ -131,7 +142,8 @@ class Renamer(DownloadClient): season: int, method: str, _hash, - offset: int = 0, + episode_offset: int = 0, + season_offset: int = 0, **kwargs, ): method = "subtitle_" + method @@ -143,7 +155,7 @@ class Renamer(DownloadClient): file_type="subtitle", ) if sub: - new_path = self.gen_path(sub, bangumi_name, method=method, offset=offset) + new_path = self.gen_path(sub, bangumi_name, method=method, episode_offset=episode_offset, season_offset=season_offset) if subtitle_path != new_path: renamed = await self.rename_torrent_file( _hash=_hash, old_path=subtitle_path, new_path=new_path @@ -151,16 +163,20 @@ class Renamer(DownloadClient): if not renamed: logger.warning(f"[Renamer] {subtitle_path} rename failed") - def _lookup_offset_by_path(self, save_path: str) -> int: - """Look up the offset for a bangumi by its save_path.""" + def _lookup_offsets_by_path(self, save_path: str) -> tuple[int, int]: + """Look up episode and season offsets for a bangumi by its save_path. + + Returns: + tuple[int, int]: (episode_offset, season_offset) + """ try: with Database() as db: bangumi = db.bangumi.match_by_save_path(save_path) if bangumi: - return bangumi.offset + return bangumi.episode_offset, bangumi.season_offset except Exception as e: - logger.debug(f"[Renamer] Could not lookup offset for {save_path}: {e}") - return 0 + logger.debug(f"[Renamer] Could not lookup offsets for {save_path}: {e}") + return 0, 0 async def rename(self) -> list[Notification]: # Get torrent info @@ -178,15 +194,16 @@ class Renamer(DownloadClient): save_path = info["save_path"] media_list, subtitle_list = self.check_files(files) bangumi_name, season = self._path_to_bangumi(save_path) - # Look up offset from database - offset = self._lookup_offset_by_path(save_path) + # Look up offsets from database + episode_offset, season_offset = self._lookup_offsets_by_path(save_path) kwargs = { "torrent_name": torrent_name, "bangumi_name": bangumi_name, "method": rename_method, "season": season, "_hash": torrent_hash, - "offset": offset, + "episode_offset": episode_offset, + "season_offset": season_offset, } # Rename single media file if len(media_list) == 1: diff --git a/backend/src/module/models/bangumi.py b/backend/src/module/models/bangumi.py index c19b509b..d5a3b834 100644 --- a/backend/src/module/models/bangumi.py +++ b/backend/src/module/models/bangumi.py @@ -19,7 +19,8 @@ class Bangumi(SQLModel, table=True): source: Optional[str] = Field(alias="source", title="来源") subtitle: Optional[str] = Field(alias="subtitle", title="字幕") eps_collect: bool = Field(default=False, alias="eps_collect", title="是否已收集") - offset: int = Field(default=0, alias="offset", title="番剧偏移量") + episode_offset: int = Field(default=0, alias="episode_offset", title="集数偏移量") + season_offset: int = Field(default=0, alias="season_offset", title="季度偏移量") filter: str = Field(default="720,\\d+-\\d+", alias="filter", title="番剧过滤器") rss_link: str = Field(default="", alias="rss_link", title="番剧RSS链接") poster_link: Optional[str] = Field(alias="poster_link", title="番剧海报链接") @@ -29,6 +30,8 @@ class Bangumi(SQLModel, table=True): deleted: bool = Field(False, alias="deleted", title="是否已删除", index=True) archived: bool = Field(default=False, alias="archived", title="是否已归档", index=True) air_weekday: Optional[int] = Field(default=None, alias="air_weekday", title="放送星期") + needs_review: bool = Field(default=False, alias="needs_review", title="需要检查") + needs_review_reason: Optional[str] = Field(default=None, alias="needs_review_reason", title="检查原因") class BangumiUpdate(SQLModel): @@ -44,7 +47,8 @@ class BangumiUpdate(SQLModel): source: Optional[str] = Field(alias="source", title="来源") subtitle: Optional[str] = Field(alias="subtitle", title="字幕") eps_collect: bool = Field(default=False, alias="eps_collect", title="是否已收集") - offset: int = Field(default=0, alias="offset", title="番剧偏移量") + episode_offset: int = Field(default=0, alias="episode_offset", title="集数偏移量") + season_offset: int = Field(default=0, alias="season_offset", title="季度偏移量") filter: str = Field(default="720,\\d+-\\d+", alias="filter", title="番剧过滤器") rss_link: str = Field(default="", alias="rss_link", title="番剧RSS链接") poster_link: Optional[str] = Field(alias="poster_link", title="番剧海报链接") @@ -54,6 +58,8 @@ class BangumiUpdate(SQLModel): deleted: bool = Field(False, alias="deleted", title="是否已删除") archived: bool = Field(default=False, alias="archived", title="是否已归档") air_weekday: Optional[int] = Field(default=None, alias="air_weekday", title="放送星期") + needs_review: bool = Field(default=False, alias="needs_review", title="需要检查") + needs_review_reason: Optional[str] = Field(default=None, alias="needs_review_reason", title="检查原因") class Notification(BaseModel): @@ -85,7 +91,8 @@ class SeasonInfo: season_raw: str group: str filter: list | None - offset: int | None + episode_offset: int | None + season_offset: int | None dpi: str source: str subtitle: str diff --git a/backend/src/module/parser/analyser/offset_detector.py b/backend/src/module/parser/analyser/offset_detector.py new file mode 100644 index 00000000..e54e5ad6 --- /dev/null +++ b/backend/src/module/parser/analyser/offset_detector.py @@ -0,0 +1,115 @@ +"""Offset detector for detecting season/episode mismatches with TMDB data.""" + +import logging +from dataclasses import dataclass +from typing import Literal + +from module.parser.analyser.tmdb_parser import TMDBInfo + +logger = logging.getLogger(__name__) + + +@dataclass +class OffsetSuggestion: + """Suggested offsets to align RSS parsed data with TMDB.""" + + season_offset: int + episode_offset: int + reason: str + confidence: Literal["high", "medium", "low"] + + +def detect_offset_mismatch( + parsed_season: int, + parsed_episode: int, + tmdb_info: TMDBInfo, +) -> OffsetSuggestion | None: + """Detect if there's a mismatch between parsed season/episode and TMDB data. + + Uses air date gaps to detect "virtual seasons" - when TMDB has 1 season but + subtitle groups split it into S1/S2 based on broadcast breaks (>6 months gap). + + Args: + parsed_season: Season number parsed from RSS/torrent name + parsed_episode: Episode number parsed from RSS/torrent name + tmdb_info: TMDB information for the anime + + Returns: + OffsetSuggestion if a mismatch is detected, None otherwise + """ + if not tmdb_info or not tmdb_info.last_season: + return None + + suggested_season_offset = 0 + suggested_episode_offset = 0 + reasons = [] + confidence: Literal["high", "medium", "low"] = "high" + + # Check season mismatch + # If parsed season exceeds TMDB's total seasons, suggest mapping to last season + if parsed_season > tmdb_info.last_season: + suggested_season_offset = tmdb_info.last_season - parsed_season + target_season = parsed_season + suggested_season_offset + + # Check if this season has virtual season breakpoints (detected from air date gaps) + if tmdb_info.virtual_season_starts and target_season in tmdb_info.virtual_season_starts: + vs_starts = tmdb_info.virtual_season_starts[target_season] + # Calculate which virtual season the parsed_season maps to + # e.g., if vs_starts = [1, 29] and parsed_season = 2, we're in the 2nd virtual season + virtual_season_index = parsed_season - target_season # 0-indexed from target + + if virtual_season_index < len(vs_starts): + # Episode offset is the start of this virtual season minus 1 + suggested_episode_offset = vs_starts[virtual_season_index] - 1 + reasons.append( + f"RSS显示S{parsed_season},但TMDB只有{tmdb_info.last_season}季" + f"(检测到第{virtual_season_index + 1}部分从第{vs_starts[virtual_season_index]}集开始)" + ) + logger.debug( + f"[OffsetDetector] Virtual season detected: S{parsed_season} maps to " + f"TMDB S{target_season} starting at episode {vs_starts[virtual_season_index]}" + ) + else: + reasons.append(f"RSS显示S{parsed_season},但TMDB只有{tmdb_info.last_season}季") + else: + reasons.append(f"RSS显示S{parsed_season},但TMDB只有{tmdb_info.last_season}季") + + logger.debug( + f"[OffsetDetector] Season mismatch: parsed S{parsed_season}, " + f"TMDB has {tmdb_info.last_season} seasons, suggesting offset {suggested_season_offset}" + ) + + # Check episode range for target season + target_season = parsed_season + suggested_season_offset + if tmdb_info.season_episode_counts: + season_ep_count = tmdb_info.season_episode_counts.get(target_season, 0) + adjusted_episode = parsed_episode + suggested_episode_offset + + if season_ep_count > 0 and adjusted_episode > season_ep_count: + # Episode exceeds the count for this season + if tmdb_info.series_status == "Returning Series": + confidence = "medium" + reasons.append( + f"调整后集数{adjusted_episode}超出TMDB该季的{season_ep_count}集" + f"(正在放送中,TMDB可能未更新)" + ) + else: + reasons.append( + f"调整后集数{adjusted_episode}超出TMDB该季的{season_ep_count}集" + ) + + logger.debug( + f"[OffsetDetector] Episode range issue: adjusted E{adjusted_episode}, " + f"TMDB S{target_season} has {season_ep_count} episodes" + ) + + # Only return suggestion if there's actually a mismatch + if reasons: + return OffsetSuggestion( + season_offset=suggested_season_offset, + episode_offset=suggested_episode_offset, + reason="; ".join(reasons), + confidence=confidence, + ) + + return None diff --git a/backend/src/module/parser/analyser/tmdb_parser.py b/backend/src/module/parser/analyser/tmdb_parser.py index 431ca18a..9c089f39 100644 --- a/backend/src/module/parser/analyser/tmdb_parser.py +++ b/backend/src/module/parser/analyser/tmdb_parser.py @@ -26,6 +26,7 @@ class TMDBInfo: poster_link: str = None series_status: str = None # "Ended", "Returning Series", etc. season_episode_counts: dict[int, int] = None # {1: 13, 2: 12, ...} + virtual_season_starts: dict[int, list[int]] = None # {1: [1, 29], ...} - episode numbers where virtual seasons start def get_offset_for_season(self, season: int) -> int: """Calculate offset for a season (negative sum of all previous seasons' episodes). @@ -49,6 +50,10 @@ def info_url(e, key): return f"{TMDB_URL}/3/tv/{e}?api_key={TMDB_API}&language={LANGUAGE[key]}" +def season_url(tv_id, season_number, key): + return f"{TMDB_URL}/3/tv/{tv_id}/season/{season_number}?api_key={TMDB_API}&language={LANGUAGE[key]}" + + async def is_animation(tv_id, language, req: RequestContent) -> bool: url_info = info_url(tv_id, language) type_id = await req.get_json(url_info) @@ -59,6 +64,107 @@ async def is_animation(tv_id, language, req: RequestContent) -> bool: return False +async def get_season_episode_air_dates(tv_id: int, season_number: int, language: str, req: RequestContent) -> list[dict]: + """Get episode air dates for a season. + + Returns: + List of {episode_number, air_date} dicts, sorted by episode number + """ + import datetime + + url = season_url(tv_id, season_number, language) + season_data = await req.get_json(url) + if not season_data: + return [] + + episodes = [] + for ep in season_data.get("episodes", []): + ep_num = ep.get("episode_number") + air_date_str = ep.get("air_date") + if ep_num and air_date_str: + try: + air_date = datetime.date.fromisoformat(air_date_str) + episodes.append({"episode_number": ep_num, "air_date": air_date}) + except ValueError: + continue + + return sorted(episodes, key=lambda x: x["episode_number"]) + + +def detect_virtual_seasons(episodes: list[dict], gap_months: int = 6) -> list[int]: + """Detect virtual season breakpoints based on air date gaps. + + When there's a gap > gap_months between consecutive episodes, + it indicates a "cour break" or "virtual season" boundary. + + Args: + episodes: List of {episode_number, air_date} dicts + gap_months: Minimum gap in months to consider a season break (default 6) + + Returns: + List of episode numbers where virtual seasons START (e.g., [1, 29] means S1 starts at ep1, S2 at ep29) + """ + import datetime + + if len(episodes) < 2: + return [1] if episodes else [] + + virtual_season_starts = [1] # First virtual season always starts at episode 1 + gap_days = gap_months * 30 # Approximate months to days + + for i in range(1, len(episodes)): + prev_ep = episodes[i - 1] + curr_ep = episodes[i] + days_diff = (curr_ep["air_date"] - prev_ep["air_date"]).days + + if days_diff > gap_days: + virtual_season_starts.append(curr_ep["episode_number"]) + logger.debug( + f"[TMDB] Detected virtual season break: {days_diff} days gap " + f"between ep{prev_ep['episode_number']} and ep{curr_ep['episode_number']}" + ) + + return virtual_season_starts + + +async def get_aired_episode_count(tv_id: int, season_number: int, language: str, req: RequestContent) -> int: + """Get the count of episodes that have actually aired for a season. + + Args: + tv_id: TMDB TV show ID + season_number: Season number + language: Language code + req: Request content instance + + Returns: + Number of episodes that have aired (air_date <= today) + """ + import datetime + + url = season_url(tv_id, season_number, language) + season_data = await req.get_json(url) + if not season_data: + return 0 + + episodes = season_data.get("episodes", []) + today = datetime.date.today() + aired_count = 0 + + for ep in episodes: + air_date_str = ep.get("air_date") + if air_date_str: + try: + air_date = datetime.date.fromisoformat(air_date_str) + if air_date <= today: + aired_count += 1 + except ValueError: + # Invalid date format, skip this episode + continue + + logger.debug(f"[TMDB] Season {season_number}: {aired_count} aired of {len(episodes)} total episodes") + return aired_count + + def get_season(seasons: list) -> tuple[int, str]: ss = [s for s in seasons if s["air_date"] is not None and "特别" not in s["season"]] ss = sorted(ss, key=lambda e: e.get("air_date"), reverse=True) @@ -112,11 +218,25 @@ async def tmdb_parser(title, language, test: bool = False) -> TMDBInfo | None: # Extract series status (e.g., "Ended", "Returning Series") series_status = info_content.get("status") # Extract episode counts per season (exclude specials at season 0) - season_episode_counts = { - s.get("season_number"): s.get("episode_count", 0) - for s in info_content.get("seasons", []) - if s.get("season_number", 0) > 0 - } + # For ongoing series, we need to get actual aired episode counts + season_episode_counts = {} + virtual_season_starts = {} + for s in info_content.get("seasons", []): + season_num = s.get("season_number", 0) + if season_num > 0: + total_eps = s.get("episode_count", 0) + # Get episode air dates for virtual season detection + episodes = await get_season_episode_air_dates(id, season_num, language, req) + if episodes: + # Detect virtual seasons based on air date gaps + vs_starts = detect_virtual_seasons(episodes) + if len(vs_starts) > 1: + virtual_season_starts[season_num] = vs_starts + logger.debug(f"[TMDB] Season {season_num} has virtual seasons starting at episodes: {vs_starts}") + # Count only aired episodes + season_episode_counts[season_num] = len(episodes) + else: + season_episode_counts[season_num] = total_eps if poster_path is None: poster_path = info_content.get("poster_path") original_title = info_content.get("original_name") @@ -140,6 +260,7 @@ async def tmdb_parser(title, language, test: bool = False) -> TMDBInfo | None: poster_link=poster_link, series_status=series_status, season_episode_counts=season_episode_counts, + virtual_season_starts=virtual_season_starts if virtual_season_starts else None, ) _tmdb_cache[cache_key] = result return result diff --git a/webui/src/api/bangumi.ts b/webui/src/api/bangumi.ts index 121f2680..24fc2dd7 100644 --- a/webui/src/api/bangumi.ts +++ b/webui/src/api/bangumi.ts @@ -1,5 +1,11 @@ import { omit } from 'radash'; -import type { BangumiAPI, BangumiRule, OffsetSuggestion } from '#/bangumi'; +import type { + BangumiAPI, + BangumiRule, + DetectOffsetRequest, + DetectOffsetResponse, + OffsetSuggestion, +} from '#/bangumi'; import type { ApiSuccess } from '#/api'; export const apiBangumi = { @@ -189,4 +195,42 @@ export const apiBangumi = { ); return data; }, + + /** + * 检测季度/集数与 TMDB 的不匹配 + * @param request - 包含标题、解析的季度和集数 + */ + async detectOffset(request: DetectOffsetRequest) { + const { data } = await axios.post( + 'api/v1/bangumi/detect-offset', + request + ); + return data; + }, + + /** + * 清除 bangumi 的需要检查标记 + * @param bangumiId - bangumi 的 id + */ + async dismissReview(bangumiId: number) { + const { data } = await axios.post( + `api/v1/bangumi/dismiss-review/${bangumiId}` + ); + return data; + }, + + /** + * 获取所有需要检查偏移量的 bangumi + */ + async getNeedsReview() { + const { data } = await axios.get( + 'api/v1/bangumi/needs-review' + ); + return data.map((bangumi) => ({ + ...bangumi, + filter: bangumi.filter.split(','), + rss_link: bangumi.rss_link.split(','), + air_weekday: bangumi.air_weekday ?? null, + })) as BangumiRule[]; + }, }; diff --git a/webui/src/components/ab-bangumi-card.vue b/webui/src/components/ab-bangumi-card.vue index 005c1ade..b15a2ad9 100644 --- a/webui/src/components/ab-bangumi-card.vue +++ b/webui/src/components/ab-bangumi-card.vue @@ -28,7 +28,7 @@ const posterSrc = computed(() => resolvePosterUrl(props.bangumi.poster_link)); @click="() => $emit('click')" @keydown.enter="() => $emit('click')" > -
+
@@ -146,6 +146,20 @@ const posterSrc = computed(() => resolvePosterUrl(props.bangumi.poster_link)); display: block; } +// Card glow animation when needs review - yellow +.card-poster--needs-review { + animation: card-glow 2.5s ease-in-out infinite; +} + +@keyframes card-glow { + 0%, 100% { + box-shadow: var(--shadow-md), 0 0 0 0 rgba(251, 191, 36, 0); + } + 50% { + box-shadow: var(--shadow-md), 0 0 16px 4px rgba(251, 191, 36, 0.6); + } +} + .card-placeholder { width: 100%; height: 100%; diff --git a/webui/src/components/ab-edit-rule.vue b/webui/src/components/ab-edit-rule.vue index 3e348dfa..1cd51e77 100644 --- a/webui/src/components/ab-edit-rule.vue +++ b/webui/src/components/ab-edit-rule.vue @@ -1,7 +1,7 @@ + + + + diff --git a/webui/src/components/search/ab-search-confirm.vue b/webui/src/components/search/ab-search-confirm.vue index f32972be..d774c7b3 100644 --- a/webui/src/components/search/ab-search-confirm.vue +++ b/webui/src/components/search/ab-search-confirm.vue @@ -1,7 +1,7 @@