mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-07-16 11:41:25 +08:00
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:
@@ -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()
|
||||
|
||||
117
backend/src/module/core/offset_scanner.py
Normal file
117
backend/src/module/core/offset_scanner.py
Normal 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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
115
backend/src/module/parser/analyser/offset_detector.py
Normal file
115
backend/src/module/parser/analyser/offset_detector.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
471
webui/src/components/basic/ab-offset-mismatch-dialog.vue
Normal file
471
webui/src/components/basic/ab-offset-mismatch-dialog.vue
Normal 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">≠</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">→ 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">→ 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">→</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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "未找到相关结果,试试其他关键词",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
1
webui/types/dts/components.d.ts
vendored
1
webui/types/dts/components.d.ts
vendored
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user