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()

View File

@@ -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)

View File

@@ -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,

View File

@@ -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")

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<DetectOffsetResponse>(
'api/v1/bangumi/detect-offset',
request
);
return data;
},
/**
* 清除 bangumi 的需要检查标记
* @param bangumiId - bangumi 的 id
*/
async dismissReview(bangumiId: number) {
const { data } = await axios.post<ApiSuccess>(
`api/v1/bangumi/dismiss-review/${bangumiId}`
);
return data;
},
/**
* 获取所有需要检查偏移量的 bangumi
*/
async getNeedsReview() {
const { data } = await axios.get<BangumiAPI[]>(
'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[];
},
};

View File

@@ -28,7 +28,7 @@ const posterSrc = computed(() => resolvePosterUrl(props.bangumi.poster_link));
@click="() => $emit('click')"
@keydown.enter="() => $emit('click')"
>
<div class="card-poster">
<div class="card-poster" :class="{ 'card-poster--needs-review': bangumi.needs_review }">
<template v-if="bangumi.poster_link">
<img :src="posterSrc" :alt="bangumi.official_title" class="card-img" />
</template>
@@ -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%;

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { CheckOne, Close, Copy, Down, ErrorPicture, Right } from '@icon-park/vue-next';
import { NDynamicTags, NSpin, useMessage } from 'naive-ui';
import type { BangumiRule } from '#/bangumi';
import type { BangumiRule, DetectOffsetResponse } from '#/bangumi';
const emit = defineEmits<{
(e: 'apply', rule: BangumiRule): void;
@@ -37,6 +37,7 @@ const showAdvanced = ref(true);
const copied = ref(false);
const offsetLoading = ref(false);
const offsetReason = ref('');
const dismissingReview = ref(false);
// Delete file dialog state
const deleteFileDialog = reactive<{
@@ -92,15 +93,33 @@ async function copyRssLink() {
}
}
// Auto detect offset
// Auto detect offset using the new detectOffset API
async function autoDetectOffset() {
if (!localRule.value.id) return;
if (!localRule.value.official_title || !localRule.value.season) return;
offsetLoading.value = true;
offsetReason.value = '';
try {
const result = await apiBangumi.suggestOffset(localRule.value.id);
localRule.value.offset = result.suggested_offset;
offsetReason.value = result.reason;
const result: DetectOffsetResponse = await apiBangumi.detectOffset({
title: localRule.value.official_title,
parsed_season: localRule.value.season,
parsed_episode: 1,
});
if (result.has_mismatch && result.suggestion) {
localRule.value.season_offset = result.suggestion.season_offset;
localRule.value.episode_offset = result.suggestion.episode_offset;
offsetReason.value = result.suggestion.reason;
// Clear needs_review after applying offset
localRule.value.needs_review = false;
localRule.value.needs_review_reason = null;
message.success(t('offset.suggestion_applied'));
} else {
offsetReason.value = t('offset.no_mismatch');
// Clear needs_review if no mismatch detected
localRule.value.needs_review = false;
localRule.value.needs_review_reason = null;
message.info(t('offset.no_mismatch'));
}
} catch (e) {
console.error('Failed to detect offset:', e);
message.error('Failed to detect offset');
@@ -109,6 +128,23 @@ async function autoDetectOffset() {
}
}
// Dismiss the needs_review warning
async function dismissReview() {
if (!localRule.value.id) return;
dismissingReview.value = true;
try {
await apiBangumi.dismissReview(localRule.value.id);
localRule.value.needs_review = false;
localRule.value.needs_review_reason = null;
message.success(t('offset.review_dismissed'));
} catch (e) {
console.error('Failed to dismiss review:', e);
message.error('Failed to dismiss review');
} finally {
dismissingReview.value = false;
}
}
const close = () => (show.value = false);
function showDeleteFileDialog() {
@@ -175,6 +211,37 @@ function emitUnarchive() {
</button>
</header>
<!-- Needs Review Warning Banner -->
<div v-if="localRule.needs_review" class="review-warning">
<div class="review-warning-main">
<span class="review-warning-emoji"></span>
<div class="review-warning-content">
<div class="review-warning-title">{{ $t('offset.needs_review') }}</div>
<div v-if="localRule.needs_review_reason" class="review-warning-reason">
{{ localRule.needs_review_reason }}
</div>
</div>
</div>
<div class="review-warning-actions">
<button
class="detect-btn"
:disabled="offsetLoading"
@click="autoDetectOffset"
>
<NSpin v-if="offsetLoading" :size="12" />
<span v-else>{{ $t('homepage.rule.auto_detect') }}</span>
</button>
<button
class="dismiss-btn"
:disabled="dismissingReview"
@click="dismissReview"
>
<NSpin v-if="dismissingReview" :size="12" />
<span v-else>{{ $t('offset.dismiss') }}</span>
</button>
</div>
</div>
<!-- Content -->
<div class="edit-content">
<!-- Bangumi Info -->
@@ -261,27 +328,31 @@ function emitUnarchive() {
</div>
</div>
<!-- Offset row -->
<!-- Season Offset row -->
<div class="advanced-row">
<label class="advanced-label">{{ $t('homepage.rule.offset') }}</label>
<label class="advanced-label">{{ $t('homepage.rule.season_offset') }}</label>
<div class="advanced-control offset-controls">
<input
v-model.number="localRule.offset"
v-model.number="localRule.season_offset"
type="number"
ab-input
class="offset-input"
/>
</div>
</div>
<!-- Episode Offset row -->
<div class="advanced-row">
<label class="advanced-label">{{ $t('homepage.rule.episode_offset') }}</label>
<div class="advanced-control offset-controls">
<input
v-model.number="localRule.episode_offset"
type="number"
ab-input
class="offset-input"
/>
<button
class="detect-btn"
:disabled="offsetLoading"
@click="autoDetectOffset"
>
<NSpin v-if="offsetLoading" :size="14" />
<span v-else>{{ $t('homepage.rule.auto_detect') }}</span>
</button>
</div>
</div>
<div v-if="offsetReason" class="offset-reason">{{ offsetReason }}</div>
</div>
</Transition>
</div>
@@ -400,6 +471,100 @@ function emitUnarchive() {
}
}
// Review warning banner
.review-warning {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
margin: 12px 20px;
background: #fef9ed;
border: 1px solid #fde68a;
border-radius: var(--radius-md);
}
.review-warning-main {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.review-warning-emoji {
font-size: 20px;
flex-shrink: 0;
}
.review-warning-content {
flex: 1;
min-width: 0;
}
.review-warning-title {
font-size: 13px;
font-weight: 600;
color: #92400e;
}
.review-warning-reason {
font-size: 12px;
color: #a16207;
line-height: 1.3;
margin-top: 2px;
}
.review-warning-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.review-warning-actions .detect-btn,
.review-warning-actions .dismiss-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 14px;
font-size: 13px;
font-family: inherit;
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
white-space: nowrap;
transition: all var(--transition-fast);
&:disabled {
cursor: wait;
}
}
.review-warning-actions .detect-btn {
min-width: 90px;
color: #fff;
background: var(--color-primary);
border: none;
&:hover:not(:disabled) {
background: var(--color-primary-hover);
}
}
.review-warning-actions .dismiss-btn {
min-width: 70px;
color: var(--color-text-secondary);
background: var(--color-surface);
border: 1px solid var(--color-border);
&:hover:not(:disabled) {
border-color: var(--color-text-muted);
color: var(--color-text);
}
}
.edit-content {
flex: 1;
overflow-y: auto;
@@ -783,12 +948,6 @@ function emitUnarchive() {
}
}
.offset-reason {
font-size: 12px;
color: var(--color-text-secondary);
margin-top: -4px;
}
// Expand transition
.expand-enter-active,
.expand-leave-active {

View File

@@ -0,0 +1,471 @@
<script lang="ts" setup>
import { Caution, Close } from '@icon-park/vue-next';
import type { OffsetSuggestionDetail, TMDBSummary } from '#/bangumi';
const props = withDefaults(
defineProps<{
bangumiTitle: string;
parsedSeason: number;
parsedEpisode: number;
tmdbInfo: TMDBSummary | null;
suggestion: OffsetSuggestionDetail | null;
}>(),
{
tmdbInfo: null,
suggestion: null,
}
);
const emit = defineEmits<{
(e: 'apply', offsets: { seasonOffset: number; episodeOffset: number }): void;
(e: 'keep'): void;
(e: 'cancel'): void;
}>();
const { t } = useMyI18n();
const show = defineModel('show', { default: false });
// Local editable offset values
const seasonOffset = ref(props.suggestion?.season_offset ?? 0);
const episodeOffset = ref(props.suggestion?.episode_offset ?? 0);
// Watch for suggestion changes
watch(
() => props.suggestion,
(newVal) => {
if (newVal) {
seasonOffset.value = newVal.season_offset;
episodeOffset.value = newVal.episode_offset;
}
}
);
// Preview calculation
const preview = computed(() => {
const newSeason = props.parsedSeason + seasonOffset.value;
const newEpisode = props.parsedEpisode + episodeOffset.value;
const formatNum = (n: number) => (n < 10 ? `0${n}` : `${n}`);
return {
from: `S${formatNum(props.parsedSeason)}E${formatNum(props.parsedEpisode)}`,
to: `S${formatNum(Math.max(1, newSeason))}E${formatNum(Math.max(1, newEpisode))}`,
};
});
// Confidence badge color
const confidenceColor = computed(() => {
switch (props.suggestion?.confidence) {
case 'high':
return 'var(--color-error)';
case 'medium':
return 'var(--color-warning)';
default:
return 'var(--color-text-muted)';
}
});
function handleApply() {
emit('apply', {
seasonOffset: seasonOffset.value,
episodeOffset: episodeOffset.value,
});
show.value = false;
}
function handleKeep() {
emit('keep');
show.value = false;
}
function handleCancel() {
emit('cancel');
show.value = false;
}
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="show" class="dialog-backdrop" @click.self="handleCancel">
<div class="dialog-modal" role="dialog" aria-modal="true">
<!-- Header -->
<header class="dialog-header">
<div class="header-icon">
<Caution theme="filled" size="20" />
</div>
<h2 class="dialog-title">{{ t('offset.dialog_title') }}</h2>
<button class="close-btn" aria-label="Close" @click="handleCancel">
<Close theme="outline" size="18" />
</button>
</header>
<!-- Content -->
<div class="dialog-content">
<!-- Bangumi title -->
<div class="bangumi-title">{{ bangumiTitle }}</div>
<!-- Comparison section -->
<div class="comparison">
<!-- RSS parsed result -->
<div class="comparison-box">
<div class="comparison-label">{{ t('offset.parsed_result') }}</div>
<div class="comparison-value">
<span class="value-label">{{ t('offset.season') }}:</span>
<span class="value-num">{{ parsedSeason }}</span>
</div>
<div class="comparison-value">
<span class="value-label">{{ t('offset.episode') }}:</span>
<span class="value-num">{{ parsedEpisode }}</span>
</div>
</div>
<div class="comparison-vs">&ne;</div>
<!-- TMDB data -->
<div class="comparison-box">
<div class="comparison-label">{{ t('offset.tmdb_data') }}</div>
<div v-if="tmdbInfo" class="comparison-value">
<span class="value-label">{{ t('offset.total_seasons') }}:</span>
<span class="value-num">{{ tmdbInfo.total_seasons }}</span>
</div>
<div v-if="tmdbInfo && tmdbInfo.season_episode_counts[parsedSeason + (suggestion?.season_offset ?? 0)]" class="comparison-value">
<span class="value-label">S{{ parsedSeason + (suggestion?.season_offset ?? 0) }} {{ t('offset.episode') }}:</span>
<span class="value-num">{{ tmdbInfo.season_episode_counts[parsedSeason + (suggestion?.season_offset ?? 0)] }}</span>
</div>
</div>
</div>
<!-- Reason -->
<div v-if="suggestion?.reason" class="reason-section">
<span class="reason-badge" :style="{ backgroundColor: confidenceColor }">
{{ suggestion.confidence }}
</span>
<span class="reason-text">{{ suggestion.reason }}</span>
</div>
<!-- Offset inputs -->
<div class="offset-section">
<div class="offset-title">{{ t('offset.suggested_offset') }}</div>
<div class="offset-row">
<label class="offset-label">{{ t('offset.season_offset') }}:</label>
<input
v-model.number="seasonOffset"
type="number"
class="offset-input"
/>
<span class="offset-hint">&rarr; S{{ parsedSeason }} {{ t('offset.season') === '季度' ? '变为' : 'becomes' }} S{{ Math.max(1, parsedSeason + seasonOffset) }}</span>
</div>
<div class="offset-row">
<label class="offset-label">{{ t('offset.episode_offset') }}:</label>
<input
v-model.number="episodeOffset"
type="number"
class="offset-input"
/>
<span class="offset-hint">&rarr; E{{ parsedEpisode }} {{ t('offset.season') === '季度' ? '保持' : 'stays' }} E{{ Math.max(1, parsedEpisode + episodeOffset) }}</span>
</div>
</div>
<!-- Preview -->
<div class="preview-section">
<span class="preview-label">{{ t('offset.preview') }}:</span>
<span class="preview-from">{{ preview.from }}</span>
<span class="preview-arrow">&rarr;</span>
<span class="preview-to">{{ preview.to }}</span>
</div>
</div>
<!-- Footer -->
<footer class="dialog-footer">
<ab-button size="small" type="secondary" @click="handleCancel">
{{ t('offset.cancel') }}
</ab-button>
<ab-button size="small" @click="handleKeep">
{{ t('offset.keep') }}
</ab-button>
<ab-button size="small" type="primary" @click="handleApply">
{{ t('offset.apply') }}
</ab-button>
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style lang="scss" scoped>
.dialog-backdrop {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-overlay);
z-index: var(--z-modal);
padding: 16px;
}
.dialog-modal {
width: 100%;
max-width: 480px;
max-height: 90vh;
display: flex;
flex-direction: column;
background: var(--color-surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.dialog-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border);
}
.header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: #fef3c7;
color: #f59e0b;
border-radius: var(--radius-sm);
}
.dialog-title {
flex: 1;
font-size: 16px;
font-weight: 600;
color: var(--color-text);
margin: 0;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--color-text-muted);
transition: all var(--transition-fast);
&:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
}
.dialog-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.bangumi-title {
font-size: 15px;
font-weight: 600;
color: var(--color-primary);
margin-bottom: 16px;
text-align: center;
}
.comparison {
display: flex;
align-items: stretch;
gap: 12px;
margin-bottom: 16px;
}
.comparison-box {
flex: 1;
padding: 12px;
background: var(--color-surface-hover);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.comparison-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 8px;
text-transform: uppercase;
}
.comparison-value {
display: flex;
justify-content: space-between;
font-size: 13px;
margin-bottom: 4px;
.value-label {
color: var(--color-text-muted);
}
.value-num {
font-weight: 600;
color: var(--color-text);
}
}
.comparison-vs {
display: flex;
align-items: center;
font-size: 24px;
color: var(--color-warning);
font-weight: bold;
}
.reason-section {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px;
background: #fef9ed;
border: 1px solid #fde68a;
border-radius: var(--radius-md);
margin-bottom: 16px;
}
.reason-badge {
flex-shrink: 0;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 600;
color: white;
text-transform: uppercase;
}
.reason-text {
font-size: 13px;
color: var(--color-text-secondary);
line-height: 1.4;
}
.offset-section {
padding: 16px;
background: var(--color-surface-hover);
border-radius: var(--radius-md);
margin-bottom: 16px;
}
.offset-title {
font-size: 13px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 12px;
}
.offset-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
}
.offset-label {
flex-shrink: 0;
width: 100px;
font-size: 13px;
color: var(--color-text-secondary);
}
.offset-input {
width: 70px;
height: 32px;
padding: 0 8px;
font-size: 14px;
text-align: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
&:focus {
outline: none;
border-color: var(--color-primary);
}
}
.offset-hint {
flex: 1;
font-size: 12px;
color: var(--color-text-muted);
}
.preview-section {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px;
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border-radius: var(--radius-md);
}
.preview-label {
font-size: 13px;
font-weight: 500;
color: var(--color-text-secondary);
}
.preview-from {
font-size: 16px;
font-weight: 600;
color: var(--color-text-muted);
text-decoration: line-through;
}
.preview-arrow {
font-size: 18px;
color: var(--color-primary);
}
.preview-to {
font-size: 16px;
font-weight: 600;
color: var(--color-primary);
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--color-border);
}
// Modal transition
.modal-enter-active,
.modal-leave-active {
transition: opacity 200ms ease;
.dialog-modal {
transition: transform 200ms ease, opacity 200ms ease;
}
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.dialog-modal {
transform: scale(0.95) translateY(10px);
opacity: 0;
}
}
</style>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { CheckOne, Close, Copy, Down, ErrorPicture, Right } from '@icon-park/vue-next';
import { NDynamicTags, NSpin, useMessage } from 'naive-ui';
import type { BangumiRule } from '#/bangumi';
import type { BangumiRule, DetectOffsetResponse, OffsetSuggestionDetail, TMDBSummary } from '#/bangumi';
const props = defineProps<{
bangumi: BangumiRule;
@@ -20,6 +20,8 @@ const localBangumi = ref<BangumiRule>(JSON.parse(JSON.stringify(props.bangumi)))
// Sync when props change
watch(() => props.bangumi, (newVal) => {
localBangumi.value = JSON.parse(JSON.stringify(newVal));
// Re-detect offset when bangumi changes
detectOffsetMismatch();
}, { deep: true });
const posterSrc = computed(() => resolvePosterUrl(localBangumi.value.poster_link));
@@ -27,6 +29,54 @@ const showAdvanced = ref(false);
const copied = ref(false);
const offsetLoading = ref(false);
// Offset mismatch detection state
const showOffsetDialog = ref(false);
const offsetSuggestion = ref<OffsetSuggestionDetail | null>(null);
const tmdbInfo = ref<TMDBSummary | null>(null);
// Detect offset mismatch on mount
async function detectOffsetMismatch() {
if (!localBangumi.value.official_title || !localBangumi.value.season) return;
try {
const result: DetectOffsetResponse = await apiBangumi.detectOffset({
title: localBangumi.value.official_title,
parsed_season: localBangumi.value.season,
parsed_episode: 1, // Use episode 1 as baseline for detection
});
if (result.has_mismatch && result.suggestion) {
offsetSuggestion.value = result.suggestion;
tmdbInfo.value = result.tmdb_info;
showOffsetDialog.value = true;
}
} catch (e) {
console.error('Failed to detect offset mismatch:', e);
}
}
// Handle offset dialog apply
function handleOffsetApply(offsets: { seasonOffset: number; episodeOffset: number }) {
localBangumi.value.season_offset = offsets.seasonOffset;
localBangumi.value.episode_offset = offsets.episodeOffset;
showOffsetDialog.value = false;
}
// Handle offset dialog keep (no change)
function handleOffsetKeep() {
showOffsetDialog.value = false;
}
// Handle offset dialog cancel
function handleOffsetCancel() {
showOffsetDialog.value = false;
}
// Run detection on mount
onMounted(() => {
detectOffsetMismatch();
});
// Info tags for display (just values, no labels)
const infoTags = computed(() => {
const tags: { value: string; type: string }[] = [];
@@ -70,7 +120,7 @@ async function autoDetectOffset() {
offsetLoading.value = true;
try {
const result = await apiBangumi.suggestOffset(localBangumi.value.id);
localBangumi.value.offset = result.suggested_offset;
localBangumi.value.episode_offset = result.suggested_offset;
} catch (e) {
console.error('Failed to detect offset:', e);
message.error('Failed to detect offset');
@@ -159,12 +209,25 @@ function handleConfirm() {
</div>
</div>
<!-- Offset row -->
<!-- Season Offset row -->
<div class="advanced-row">
<label class="advanced-label">{{ $t('homepage.rule.offset') }}</label>
<label class="advanced-label">{{ $t('homepage.rule.season_offset') }}</label>
<div class="advanced-control offset-controls">
<input
v-model.number="localBangumi.offset"
v-model.number="localBangumi.season_offset"
type="number"
ab-input
class="offset-input"
/>
</div>
</div>
<!-- Episode Offset row -->
<div class="advanced-row">
<label class="advanced-label">{{ $t('homepage.rule.episode_offset') }}</label>
<div class="advanced-control offset-controls">
<input
v-model.number="localBangumi.episode_offset"
type="number"
ab-input
class="offset-input"
@@ -194,6 +257,19 @@ function handleConfirm() {
</button>
</footer>
</div>
<!-- Offset Mismatch Dialog -->
<ab-offset-mismatch-dialog
v-model:show="showOffsetDialog"
:bangumi-title="localBangumi.official_title"
:parsed-season="localBangumi.season"
:parsed-episode="1"
:tmdb-info="tmdbInfo"
:suggestion="offsetSuggestion"
@apply="handleOffsetApply"
@keep="handleOffsetKeep"
@cancel="handleOffsetCancel"
/>
</div>
</template>

View File

@@ -131,8 +131,10 @@
"exclude": "Exclude",
"no_btn": "No",
"official_title": "Official Title",
"offset": "Episode Offset",
"episode_offset": "Episode Offset",
"season_offset": "Season Offset",
"auto_detect": "Auto Detect",
"needs_review": "Needs Review",
"archive": "Archive",
"unarchive": "Unarchive",
"archived": "Archived",
@@ -336,6 +338,30 @@
"confirm": "Confirm",
"select": "Select"
},
"offset": {
"dialog_title": "Season/Episode Mismatch Detected",
"parsed_result": "RSS Parsed Result",
"tmdb_data": "TMDB Data",
"season": "Season",
"episode": "Episode",
"total_seasons": "Total Seasons",
"season_episodes": "S{season} Episodes",
"suggested_offset": "Suggested Offset",
"season_offset": "Season Offset",
"episode_offset": "Episode Offset",
"preview": "Preview",
"apply": "Apply Suggestion",
"keep": "Keep Original",
"cancel": "Cancel",
"badge_tooltip": "Review needed: {reason}",
"reason_season_mismatch": "RSS shows S{parsed}, but TMDB only has {total} season(s)",
"reason_episode_exceeded": "Episode {ep} exceeds TMDB count of {count} for this season",
"needs_review": "Offset Review Needed",
"dismiss": "Dismiss",
"suggestion_applied": "Offset suggestion applied",
"no_mismatch": "No season/episode mismatch detected",
"review_dismissed": "Review reminder dismissed"
},
"search": {
"subscribe": "Subscribe",
"no_results": "No results found. Try different keywords.",

View File

@@ -131,8 +131,10 @@
"exclude": "排除",
"no_btn": "否",
"official_title": "官方名称",
"offset": "集偏移",
"episode_offset": "集偏移",
"season_offset": "季度偏移",
"auto_detect": "自动检测",
"needs_review": "需要检查",
"archive": "归档",
"unarchive": "取消归档",
"archived": "已归档",
@@ -336,6 +338,30 @@
"confirm": "确认",
"select": "选择"
},
"offset": {
"dialog_title": "检测到季度/集数不匹配",
"parsed_result": "RSS 解析结果",
"tmdb_data": "TMDB 数据",
"season": "季度",
"episode": "集数",
"total_seasons": "总季数",
"season_episodes": "S{season} 集数",
"suggested_offset": "建议偏移量",
"season_offset": "季度偏移",
"episode_offset": "集数偏移",
"preview": "预览",
"apply": "应用建议",
"keep": "保持原样",
"cancel": "取消",
"badge_tooltip": "需要检查: {reason}",
"reason_season_mismatch": "RSS显示S{parsed}但TMDB只有{total}季",
"reason_episode_exceeded": "集数{ep}超出TMDB该季的{count}集",
"needs_review": "需要检查偏移量",
"dismiss": "忽略",
"suggestion_applied": "偏移量建议已应用",
"no_mismatch": "未检测到季度/集数不匹配",
"review_dismissed": "检查提醒已忽略"
},
"search": {
"subscribe": "订阅",
"no_results": "未找到相关结果,试试其他关键词",

View File

@@ -76,6 +76,11 @@ function onRuleSelect(rule: BangumiRule) {
ruleListPopup.show = false;
openEditPopup(rule);
}
// Check if any rule in group needs review
function groupNeedsReview(group: BangumiGroup): boolean {
return group.rules.some(r => r.needs_review);
}
</script>
<template>
@@ -145,8 +150,22 @@ function onRuleSelect(rule: BangumiRule) {
type="primary"
@click="() => onCardClick(group)"
/>
<div v-if="group.rules.length > 1" class="group-badge">
{{ group.rules.length }}
<!-- Combined notification badge -->
<div
v-if="groupNeedsReview(group) || group.rules.length > 1"
class="group-badge"
:class="{ 'group-badge--warning': groupNeedsReview(group) }"
>
<template v-if="groupNeedsReview(group)">
<span class="badge-icon">!</span>
<template v-if="group.rules.length > 1">
<span class="badge-divider"></span>
<span class="badge-count">{{ group.rules.length }}</span>
</template>
</template>
<template v-else>
{{ group.rules.length }}
</template>
</div>
</div>
</transition-group>
@@ -181,8 +200,22 @@ function onRuleSelect(rule: BangumiRule) {
type="primary"
@click="() => onCardClick(group)"
/>
<div v-if="group.rules.length > 1" class="group-badge">
{{ group.rules.length }}
<!-- Combined notification badge -->
<div
v-if="groupNeedsReview(group) || group.rules.length > 1"
class="group-badge"
:class="{ 'group-badge--warning': groupNeedsReview(group) }"
>
<template v-if="groupNeedsReview(group)">
<span class="badge-icon">!</span>
<template v-if="group.rules.length > 1">
<span class="badge-divider"></span>
<span class="badge-count">{{ group.rules.length }}</span>
</template>
</template>
<template v-else>
{{ group.rules.length }}
</template>
</div>
<div class="archived-badge">{{ $t('homepage.rule.archived') }}</div>
</div>
@@ -201,12 +234,15 @@ function onRuleSelect(rule: BangumiRule) {
v-for="rule in ruleListPopup.group.rules"
:key="rule.id"
class="rule-list-item"
:class="[rule.deleted && 'rule-list-item--disabled']"
:class="[
rule.deleted && 'rule-list-item--disabled',
rule.needs_review && 'rule-list-item--warning'
]"
@click="onRuleSelect(rule)"
>
<div class="rule-list-item-info">
<div class="rule-list-item-title">
{{ rule.group_name || rule.rule_name || $t('homepage.rule.unnamed') }}
<span v-if="rule.needs_review" class="warning-text">! </span>{{ rule.group_name || rule.rule_name || $t('homepage.rule.unnamed') }}
</div>
<div class="rule-list-item-tags">
<ab-tag v-if="rule.dpi" :title="rule.dpi" type="primary" />
@@ -305,22 +341,46 @@ function onRuleSelect(rule: BangumiRule) {
.group-badge {
position: absolute;
top: -10px;
right: -10px;
min-width: 22px;
height: 22px;
top: -8px;
right: -8px;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 11px;
border-radius: 10px;
background: var(--color-primary);
color: #fff;
font-size: 13px;
font-weight: 600;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
z-index: 10;
pointer-events: none;
box-shadow: 0 2px 6px rgba(124, 77, 255, 0.4);
// Warning variant - yellow with purple border
&--warning {
background: #fbbf24;
border: 2px solid var(--color-primary);
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(251, 191, 36, 0.5);
}
.badge-icon {
font-weight: 800;
}
.badge-divider {
width: 1px;
height: 10px;
background: currentColor;
opacity: 0.5;
}
.badge-count {
font-weight: 700;
}
}
.archived-section {
@@ -425,6 +485,26 @@ function onRuleSelect(rule: BangumiRule) {
&--disabled {
opacity: 0.5;
}
// Warning variant - needs review
&--warning {
background: linear-gradient(135deg, rgba(251, 191, 36, 0.15) 0%, rgba(251, 191, 36, 0.08) 100%);
border-left: 3px solid #fbbf24;
&:hover {
background: linear-gradient(135deg, rgba(251, 191, 36, 0.22) 0%, rgba(251, 191, 36, 0.12) 100%);
}
}
// Add spacing between items
& + & {
margin-top: 8px;
}
}
.warning-text {
color: #d97706;
font-weight: 700;
}
.rule-list-item-info {

View File

@@ -11,7 +11,8 @@ export interface BangumiRule {
group_name: string;
id: number;
official_title: string;
offset: number;
episode_offset: number;
season_offset: number;
poster_link: string | null;
rss_link: string[];
rule_name: string;
@@ -23,6 +24,8 @@ export interface BangumiRule {
title_raw: string;
year: string | null;
air_weekday: number | null; // 0=Mon, 1=Tue, ..., 6=Sun, null=Unknown
needs_review: boolean;
needs_review_reason: string | null;
}
export interface BangumiAPI extends Omit<BangumiRule, 'filter' | 'rss_link'> {
@@ -47,7 +50,8 @@ export const ruleTemplate: BangumiRule = {
group_name: '',
id: 0,
official_title: '',
offset: 0,
episode_offset: 0,
season_offset: 0,
poster_link: '',
rss_link: [],
rule_name: '',
@@ -59,9 +63,42 @@ export const ruleTemplate: BangumiRule = {
title_raw: '',
year: null,
air_weekday: null,
needs_review: false,
needs_review_reason: null,
};
/** Legacy offset suggestion (for backward compatibility) */
export interface OffsetSuggestion {
suggested_offset: number;
reason: string;
}
/** TMDB summary for display in offset dialog */
export interface TMDBSummary {
title: string;
total_seasons: number;
season_episode_counts: Record<number, number>;
status: string | null;
}
/** Detailed offset suggestion from detector */
export interface OffsetSuggestionDetail {
season_offset: number;
episode_offset: number;
reason: string;
confidence: 'high' | 'medium' | 'low';
}
/** Request for detect-offset API */
export interface DetectOffsetRequest {
title: string;
parsed_season: number;
parsed_episode: number;
}
/** Response from detect-offset API */
export interface DetectOffsetResponse {
has_mismatch: boolean;
suggestion: OffsetSuggestionDetail | null;
tmdb_info: TMDBSummary | null;
}

View File

@@ -25,6 +25,7 @@ declare module '@vue/runtime-core' {
AbImage: typeof import('./../../src/components/ab-image.vue')['default']
AbLabel: typeof import('./../../src/components/ab-label.vue')['default']
AbMobileNav: typeof import('./../../src/components/layout/ab-mobile-nav.vue')['default']
AbOffsetMismatchDialog: typeof import('./../../src/components/basic/ab-offset-mismatch-dialog.vue')['default']
AbPageTitle: typeof import('./../../src/components/basic/ab-page-title.vue')['default']
AbPopup: typeof import('./../../src/components/ab-popup.vue')['default']
AbPullRefresh: typeof import('./../../src/components/basic/ab-pull-refresh.vue')['default']