mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-04-24 02:20:38 +08:00
feat: add bangumi archive and episode offset features (#958)
* feat: add bangumi archive and episode offset features
Archive Feature:
- Add archived field to Bangumi model with database migration (v4)
- Add archive/unarchive API endpoints (PATCH /bangumi/archive/{id})
- Add auto-archive for ended series via TMDB metadata refresh
- Add collapsible archived section in UI with visual styling
- Add archive/unarchive button in edit rule popup
Episode Offset Feature:
- Extract series_status and season_episode_counts from TMDB API
- Add suggest-offset API endpoint with auto-detection logic
- Apply offset in renamer gen_path() for episode numbering
- Add offset field with "Auto Detect" button in rule editor
- Look up offset from database when renaming files
The offset auto-detection calculates the sum of episodes from all
previous seasons (e.g., if S01 has 13 episodes, S02E18 → S02E05
with offset=-13).
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* docs: add changelog for bangumi archive and episode offset features
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
28
CHANGELOG.md
28
CHANGELOG.md
@@ -1,3 +1,31 @@
|
||||
# [3.2.0-beta.6] - 2026-01-25
|
||||
|
||||
## Backend
|
||||
|
||||
### Features
|
||||
|
||||
- 新增番剧归档功能:支持手动归档/取消归档,已完结番剧自动归档
|
||||
- 新增剧集偏移自动检测:根据 TMDB 季度集数自动计算偏移量(如 S02E18 → S02E05)
|
||||
- TMDB 解析器新增 `series_status` 和 `season_episode_counts` 字段提取
|
||||
- 新增数据库迁移 v4:为 `bangumi` 表添加 `archived` 字段
|
||||
- 新增 API 端点:
|
||||
- `PATCH /bangumi/archive/{id}` - 归档番剧
|
||||
- `PATCH /bangumi/unarchive/{id}` - 取消归档
|
||||
- `GET /bangumi/refresh/metadata` - 刷新元数据并自动归档已完结番剧
|
||||
- `GET /bangumi/suggest-offset/{id}` - 获取建议的剧集偏移量
|
||||
- 重命名模块支持从数据库查询偏移量并应用到文件名
|
||||
|
||||
## Frontend
|
||||
|
||||
### Features
|
||||
|
||||
- 番剧列表页新增可折叠的「已归档」分区
|
||||
- 规则编辑弹窗新增归档/取消归档按钮
|
||||
- 规则编辑器新增剧集偏移字段和「自动检测」按钮
|
||||
- 新增 i18n 翻译(中文/英文)
|
||||
|
||||
---
|
||||
|
||||
# [3.2.0-beta.5] - 2026-01-24
|
||||
|
||||
## Backend
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from module.manager import TorrentManager
|
||||
from module.models import APIResponse, Bangumi, BangumiUpdate
|
||||
@@ -7,6 +8,11 @@ from module.security.api import UNAUTHORIZED, get_current_user
|
||||
|
||||
from .response import u_response
|
||||
|
||||
|
||||
class OffsetSuggestion(BaseModel):
|
||||
suggested_offset: int
|
||||
reason: str
|
||||
|
||||
router = APIRouter(prefix="/bangumi", tags=["bangumi"])
|
||||
|
||||
|
||||
@@ -148,3 +154,51 @@ async def reset_all():
|
||||
status_code=200,
|
||||
content={"msg_en": "Reset all rules successfully.", "msg_zh": "重置所有规则成功。"},
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
path="/archive/{bangumi_id}",
|
||||
response_model=APIResponse,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def archive_rule(bangumi_id: int):
|
||||
"""Archive a bangumi."""
|
||||
with TorrentManager() as manager:
|
||||
resp = manager.archive_rule(bangumi_id)
|
||||
return u_response(resp)
|
||||
|
||||
|
||||
@router.patch(
|
||||
path="/unarchive/{bangumi_id}",
|
||||
response_model=APIResponse,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def unarchive_rule(bangumi_id: int):
|
||||
"""Unarchive a bangumi."""
|
||||
with TorrentManager() as manager:
|
||||
resp = manager.unarchive_rule(bangumi_id)
|
||||
return u_response(resp)
|
||||
|
||||
|
||||
@router.get(
|
||||
path="/refresh/metadata",
|
||||
response_model=APIResponse,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def refresh_metadata():
|
||||
"""Refresh TMDB metadata and auto-archive ended series."""
|
||||
with TorrentManager() as manager:
|
||||
resp = await manager.refresh_metadata()
|
||||
return u_response(resp)
|
||||
|
||||
|
||||
@router.get(
|
||||
path="/suggest-offset/{bangumi_id}",
|
||||
response_model=OffsetSuggestion,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def suggest_offset(bangumi_id: int):
|
||||
"""Suggest offset based on TMDB episode counts."""
|
||||
with TorrentManager() as manager:
|
||||
resp = await manager.suggest_offset(bangumi_id)
|
||||
return resp
|
||||
|
||||
@@ -172,11 +172,17 @@ class BangumiDatabase:
|
||||
return unmatched
|
||||
|
||||
def match_torrent(self, torrent_name: str) -> Optional[Bangumi]:
|
||||
statement = select(Bangumi).where(
|
||||
and_(
|
||||
func.instr(torrent_name, Bangumi.title_raw) > 0,
|
||||
Bangumi.deleted == false(),
|
||||
statement = (
|
||||
select(Bangumi)
|
||||
.where(
|
||||
and_(
|
||||
func.instr(torrent_name, Bangumi.title_raw) > 0,
|
||||
Bangumi.deleted == false(),
|
||||
)
|
||||
)
|
||||
# Prefer longer title_raw matches (more specific)
|
||||
.order_by(func.length(Bangumi.title_raw).desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = self.session.execute(statement)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -213,3 +219,35 @@ class BangumiDatabase:
|
||||
statement = select(Bangumi).where(func.instr(rss_link, Bangumi.rss_link) > 0)
|
||||
result = self.session.execute(statement)
|
||||
return list(result.scalars().all())
|
||||
|
||||
def archive_one(self, _id: int) -> bool:
|
||||
"""Set archived=True for the given bangumi."""
|
||||
bangumi = self.session.get(Bangumi, _id)
|
||||
if not bangumi:
|
||||
logger.warning(f"[Database] Cannot archive bangumi id: {_id}, not found.")
|
||||
return False
|
||||
bangumi.archived = True
|
||||
self.session.add(bangumi)
|
||||
self.session.commit()
|
||||
_invalidate_bangumi_cache()
|
||||
logger.debug(f"[Database] Archived bangumi id: {_id}.")
|
||||
return True
|
||||
|
||||
def unarchive_one(self, _id: int) -> bool:
|
||||
"""Set archived=False for the given bangumi."""
|
||||
bangumi = self.session.get(Bangumi, _id)
|
||||
if not bangumi:
|
||||
logger.warning(f"[Database] Cannot unarchive bangumi id: {_id}, not found.")
|
||||
return False
|
||||
bangumi.archived = False
|
||||
self.session.add(bangumi)
|
||||
self.session.commit()
|
||||
_invalidate_bangumi_cache()
|
||||
logger.debug(f"[Database] Unarchived bangumi id: {_id}.")
|
||||
return True
|
||||
|
||||
def match_by_save_path(self, save_path: str) -> Optional[Bangumi]:
|
||||
"""Find bangumi by save_path to get offset."""
|
||||
statement = select(Bangumi).where(Bangumi.save_path == save_path)
|
||||
result = self.session.execute(statement)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -15,7 +15,7 @@ from .user import UserDatabase
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Increment this when adding new migrations to MIGRATIONS list.
|
||||
CURRENT_SCHEMA_VERSION = 3
|
||||
CURRENT_SCHEMA_VERSION = 4
|
||||
|
||||
# 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
|
||||
@@ -57,6 +57,11 @@ MIGRATIONS = [
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_passkey_credential_id ON passkey(credential_id)",
|
||||
],
|
||||
),
|
||||
(
|
||||
4,
|
||||
"add archived column to bangumi",
|
||||
["ALTER TABLE bangumi ADD COLUMN archived BOOLEAN DEFAULT 0"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -125,6 +130,10 @@ class Database(Session):
|
||||
needs_run = False
|
||||
if version == 3 and "passkey" in tables:
|
||||
needs_run = False
|
||||
if "bangumi" in tables and version == 4:
|
||||
columns = [col["name"] for col in inspector.get_columns("bangumi")]
|
||||
if "archived" in columns:
|
||||
needs_run = False
|
||||
if needs_run:
|
||||
with self.engine.connect() as conn:
|
||||
for stmt in statements:
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
import re
|
||||
|
||||
from module.conf import settings
|
||||
from module.database import Database
|
||||
from module.downloader import DownloadClient
|
||||
from module.models import EpisodeFile, Notification, SubtitleFile
|
||||
from module.parser import TitleParser
|
||||
@@ -26,12 +27,18 @@ class Renamer(DownloadClient):
|
||||
|
||||
@staticmethod
|
||||
def gen_path(
|
||||
file_info: EpisodeFile | SubtitleFile, bangumi_name: str, method: str
|
||||
file_info: EpisodeFile | SubtitleFile,
|
||||
bangumi_name: str,
|
||||
method: str,
|
||||
offset: int = 0,
|
||||
) -> str:
|
||||
season = f"0{file_info.season}" if file_info.season < 10 else file_info.season
|
||||
episode = (
|
||||
f"0{file_info.episode}" if file_info.episode < 10 else file_info.episode
|
||||
)
|
||||
# Apply offset (offset is stored as the value to ADD)
|
||||
adjusted_episode = int(file_info.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")
|
||||
episode = f"0{adjusted_episode}" if adjusted_episode < 10 else adjusted_episode
|
||||
if method == "none" or method == "subtitle_none":
|
||||
return file_info.media_path
|
||||
elif method == "pn":
|
||||
@@ -57,6 +64,7 @@ class Renamer(DownloadClient):
|
||||
method: str,
|
||||
season: int,
|
||||
_hash: str,
|
||||
offset: int = 0,
|
||||
**kwargs,
|
||||
):
|
||||
ep = self._parser.torrent_parser(
|
||||
@@ -65,16 +73,20 @@ class Renamer(DownloadClient):
|
||||
season=season,
|
||||
)
|
||||
if ep:
|
||||
new_path = self.gen_path(ep, bangumi_name, method=method)
|
||||
new_path = self.gen_path(ep, bangumi_name, method=method, offset=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
|
||||
if adjusted_episode < 1:
|
||||
adjusted_episode = int(ep.episode)
|
||||
return Notification(
|
||||
official_title=bangumi_name,
|
||||
season=ep.season,
|
||||
episode=ep.episode,
|
||||
episode=adjusted_episode,
|
||||
)
|
||||
else:
|
||||
logger.warning(f"[Renamer] {media_path} parse failed")
|
||||
@@ -89,6 +101,7 @@ class Renamer(DownloadClient):
|
||||
season: int,
|
||||
method: str,
|
||||
_hash: str,
|
||||
offset: int = 0,
|
||||
**kwargs,
|
||||
):
|
||||
for media_path in media_list:
|
||||
@@ -98,7 +111,7 @@ class Renamer(DownloadClient):
|
||||
season=season,
|
||||
)
|
||||
if ep:
|
||||
new_path = self.gen_path(ep, bangumi_name, method=method)
|
||||
new_path = self.gen_path(ep, bangumi_name, method=method, offset=offset)
|
||||
if media_path != new_path:
|
||||
renamed = await self.rename_torrent_file(
|
||||
_hash=_hash, old_path=media_path, new_path=new_path
|
||||
@@ -118,6 +131,7 @@ class Renamer(DownloadClient):
|
||||
season: int,
|
||||
method: str,
|
||||
_hash,
|
||||
offset: int = 0,
|
||||
**kwargs,
|
||||
):
|
||||
method = "subtitle_" + method
|
||||
@@ -129,7 +143,7 @@ class Renamer(DownloadClient):
|
||||
file_type="subtitle",
|
||||
)
|
||||
if sub:
|
||||
new_path = self.gen_path(sub, bangumi_name, method=method)
|
||||
new_path = self.gen_path(sub, bangumi_name, method=method, offset=offset)
|
||||
if subtitle_path != new_path:
|
||||
renamed = await self.rename_torrent_file(
|
||||
_hash=_hash, old_path=subtitle_path, new_path=new_path
|
||||
@@ -137,6 +151,17 @@ 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."""
|
||||
try:
|
||||
with Database() as db:
|
||||
bangumi = db.bangumi.match_by_save_path(save_path)
|
||||
if bangumi:
|
||||
return bangumi.offset
|
||||
except Exception as e:
|
||||
logger.debug(f"[Renamer] Could not lookup offset for {save_path}: {e}")
|
||||
return 0
|
||||
|
||||
async def rename(self) -> list[Notification]:
|
||||
# Get torrent info
|
||||
logger.debug("[Renamer] Start rename process.")
|
||||
@@ -153,12 +178,15 @@ 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)
|
||||
kwargs = {
|
||||
"torrent_name": torrent_name,
|
||||
"bangumi_name": bangumi_name,
|
||||
"method": rename_method,
|
||||
"season": season,
|
||||
"_hash": torrent_hash,
|
||||
"offset": offset,
|
||||
}
|
||||
# Rename single media file
|
||||
if len(media_list) == 1:
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import logging
|
||||
|
||||
from module.conf import settings
|
||||
from module.database import Database
|
||||
from module.downloader import DownloadClient
|
||||
from module.models import Bangumi, BangumiUpdate, ResponseModel
|
||||
from module.parser import TitleParser
|
||||
from module.parser.analyser.bgm_calendar import fetch_bgm_calendar, match_weekday
|
||||
from module.parser.analyser.tmdb_parser import tmdb_parser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -206,3 +208,116 @@ class TorrentManager(Database):
|
||||
)
|
||||
else:
|
||||
return data
|
||||
|
||||
def archive_rule(self, _id: int):
|
||||
"""Archive a bangumi."""
|
||||
data = self.bangumi.search_id(_id)
|
||||
if not data:
|
||||
return ResponseModel(
|
||||
status_code=406,
|
||||
status=False,
|
||||
msg_en=f"Can't find id {_id}",
|
||||
msg_zh=f"无法找到 id {_id}",
|
||||
)
|
||||
if self.bangumi.archive_one(_id):
|
||||
logger.info(f"[Manager] Archived {data.official_title}")
|
||||
return ResponseModel(
|
||||
status_code=200,
|
||||
status=True,
|
||||
msg_en=f"Archived {data.official_title}",
|
||||
msg_zh=f"已归档 {data.official_title}",
|
||||
)
|
||||
return ResponseModel(
|
||||
status_code=500,
|
||||
status=False,
|
||||
msg_en=f"Failed to archive {data.official_title}",
|
||||
msg_zh=f"归档 {data.official_title} 失败",
|
||||
)
|
||||
|
||||
def unarchive_rule(self, _id: int):
|
||||
"""Unarchive a bangumi."""
|
||||
data = self.bangumi.search_id(_id)
|
||||
if not data:
|
||||
return ResponseModel(
|
||||
status_code=406,
|
||||
status=False,
|
||||
msg_en=f"Can't find id {_id}",
|
||||
msg_zh=f"无法找到 id {_id}",
|
||||
)
|
||||
if self.bangumi.unarchive_one(_id):
|
||||
logger.info(f"[Manager] Unarchived {data.official_title}")
|
||||
return ResponseModel(
|
||||
status_code=200,
|
||||
status=True,
|
||||
msg_en=f"Unarchived {data.official_title}",
|
||||
msg_zh=f"已取消归档 {data.official_title}",
|
||||
)
|
||||
return ResponseModel(
|
||||
status_code=500,
|
||||
status=False,
|
||||
msg_en=f"Failed to unarchive {data.official_title}",
|
||||
msg_zh=f"取消归档 {data.official_title} 失败",
|
||||
)
|
||||
|
||||
async def refresh_metadata(self):
|
||||
"""Refresh TMDB metadata and auto-archive ended series."""
|
||||
bangumis = self.bangumi.search_all()
|
||||
language = settings.rss_parser.language
|
||||
archived_count = 0
|
||||
poster_count = 0
|
||||
|
||||
for bangumi in bangumis:
|
||||
if bangumi.deleted:
|
||||
continue
|
||||
tmdb_info = await tmdb_parser(bangumi.official_title, language)
|
||||
if tmdb_info:
|
||||
# Update poster if missing
|
||||
if not bangumi.poster_link and tmdb_info.poster_link:
|
||||
bangumi.poster_link = tmdb_info.poster_link
|
||||
poster_count += 1
|
||||
# Auto-archive ended series
|
||||
if tmdb_info.series_status == "Ended" and not bangumi.archived:
|
||||
bangumi.archived = True
|
||||
archived_count += 1
|
||||
logger.info(f"[Manager] Auto-archived ended series: {bangumi.official_title}")
|
||||
|
||||
if archived_count > 0 or poster_count > 0:
|
||||
self.bangumi.update_all(bangumis)
|
||||
|
||||
logger.info(f"[Manager] Metadata refresh: archived {archived_count}, updated posters {poster_count}")
|
||||
return ResponseModel(
|
||||
status_code=200,
|
||||
status=True,
|
||||
msg_en=f"Metadata refreshed. Archived {archived_count} ended series, updated {poster_count} posters.",
|
||||
msg_zh=f"已刷新元数据。归档了 {archived_count} 部已完结番剧,更新了 {poster_count} 个海报。",
|
||||
)
|
||||
|
||||
async def suggest_offset(self, bangumi_id: int) -> dict:
|
||||
"""Suggest offset based on TMDB episode counts."""
|
||||
data = self.bangumi.search_id(bangumi_id)
|
||||
if not data:
|
||||
return {"suggested_offset": 0, "reason": f"Bangumi id {bangumi_id} not found"}
|
||||
|
||||
language = settings.rss_parser.language
|
||||
tmdb_info = await tmdb_parser(data.official_title, language)
|
||||
|
||||
if not tmdb_info or not tmdb_info.season_episode_counts:
|
||||
return {"suggested_offset": 0, "reason": "Unable to fetch TMDB episode data"}
|
||||
|
||||
season = data.season
|
||||
if season <= 1:
|
||||
return {"suggested_offset": 0, "reason": "Season 1 does not need offset"}
|
||||
|
||||
offset = tmdb_info.get_offset_for_season(season)
|
||||
if offset == 0:
|
||||
return {"suggested_offset": 0, "reason": "No previous seasons found"}
|
||||
|
||||
# Build reason with episode counts
|
||||
prev_seasons = [
|
||||
f"S{s}: {tmdb_info.season_episode_counts.get(s, 0)} eps"
|
||||
for s in range(1, season)
|
||||
if s in tmdb_info.season_episode_counts
|
||||
]
|
||||
reason = f"Previous seasons: {', '.join(prev_seasons)}"
|
||||
|
||||
return {"suggested_offset": offset, "reason": reason}
|
||||
|
||||
@@ -27,6 +27,7 @@ class Bangumi(SQLModel, table=True):
|
||||
rule_name: Optional[str] = Field(alias="rule_name", title="番剧规则名")
|
||||
save_path: Optional[str] = Field(alias="save_path", title="番剧保存路径")
|
||||
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="放送星期")
|
||||
|
||||
|
||||
@@ -51,6 +52,7 @@ class BangumiUpdate(SQLModel):
|
||||
rule_name: Optional[str] = Field(alias="rule_name", title="番剧规则名")
|
||||
save_path: Optional[str] = Field(alias="save_path", title="番剧保存路径")
|
||||
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="放送星期")
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,18 @@ class TMDBInfo:
|
||||
last_season: int
|
||||
year: str
|
||||
poster_link: str = None
|
||||
series_status: str = None # "Ended", "Returning Series", etc.
|
||||
season_episode_counts: dict[int, int] = None # {1: 13, 2: 12, ...}
|
||||
|
||||
def get_offset_for_season(self, season: int) -> int:
|
||||
"""Calculate offset for a season (negative sum of all previous seasons' episodes).
|
||||
|
||||
Used when RSS episode numbers are absolute (e.g., S02E18 should be S02E05).
|
||||
Returns the offset to subtract from the parsed episode number.
|
||||
"""
|
||||
if not self.season_episode_counts or season <= 1:
|
||||
return 0
|
||||
return -sum(self.season_episode_counts.get(s, 0) for s in range(1, season))
|
||||
|
||||
|
||||
LANGUAGE = {"zh": "zh-CN", "jp": "ja-JP", "en": "en-US"}
|
||||
@@ -97,6 +109,14 @@ async def tmdb_parser(title, language, test: bool = False) -> TMDBInfo | None:
|
||||
for s in info_content.get("seasons")
|
||||
]
|
||||
last_season, poster_path = get_season(season)
|
||||
# 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
|
||||
}
|
||||
if poster_path is None:
|
||||
poster_path = info_content.get("poster_path")
|
||||
original_title = info_content.get("original_name")
|
||||
@@ -111,13 +131,15 @@ async def tmdb_parser(title, language, test: bool = False) -> TMDBInfo | None:
|
||||
else:
|
||||
poster_link = None
|
||||
result = TMDBInfo(
|
||||
id,
|
||||
official_title,
|
||||
original_title,
|
||||
season,
|
||||
last_season,
|
||||
str(year_number),
|
||||
poster_link,
|
||||
id=id,
|
||||
title=official_title,
|
||||
original_title=original_title,
|
||||
season=season,
|
||||
last_season=last_season,
|
||||
year=str(year_number),
|
||||
poster_link=poster_link,
|
||||
series_status=series_status,
|
||||
season_episode_counts=season_episode_counts,
|
||||
)
|
||||
_tmdb_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { omit } from 'radash';
|
||||
import type { BangumiAPI, BangumiRule } from '#/bangumi';
|
||||
import type { BangumiAPI, BangumiRule, OffsetSuggestion } from '#/bangumi';
|
||||
import type { ApiSuccess } from '#/api';
|
||||
|
||||
export const apiBangumi = {
|
||||
@@ -146,4 +146,47 @@ export const apiBangumi = {
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 归档指定 bangumi
|
||||
* @param bangumiId - 需要归档的 bangumi 的 id
|
||||
*/
|
||||
async archiveRule(bangumiId: number) {
|
||||
const { data } = await axios.patch<ApiSuccess>(
|
||||
`api/v1/bangumi/archive/${bangumiId}`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 取消归档指定 bangumi
|
||||
* @param bangumiId - 需要取消归档的 bangumi 的 id
|
||||
*/
|
||||
async unarchiveRule(bangumiId: number) {
|
||||
const { data } = await axios.patch<ApiSuccess>(
|
||||
`api/v1/bangumi/unarchive/${bangumiId}`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 刷新 TMDB 元数据并自动归档已完结番剧
|
||||
*/
|
||||
async refreshMetadata() {
|
||||
const { data } = await axios.get<ApiSuccess>(
|
||||
'api/v1/bangumi/refresh/metadata'
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取自动检测的剧集偏移量建议
|
||||
* @param bangumiId - bangumi 的 id
|
||||
*/
|
||||
async suggestOffset(bangumiId: number) {
|
||||
const { data } = await axios.get<OffsetSuggestion>(
|
||||
`api/v1/bangumi/suggest-offset/${bangumiId}`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { BangumiRule } from '#/bangumi';
|
||||
const emit = defineEmits<{
|
||||
(e: 'apply', rule: BangumiRule): void;
|
||||
(e: 'enable', id: number): void;
|
||||
(e: 'archive', id: number): void;
|
||||
(e: 'unarchive', id: number): void;
|
||||
(
|
||||
e: 'deleteFile',
|
||||
type: 'disable' | 'delete',
|
||||
@@ -57,6 +59,14 @@ function emitEnable() {
|
||||
emit('enable', rule.value.id);
|
||||
}
|
||||
|
||||
function emitArchive() {
|
||||
emit('archive', rule.value.id);
|
||||
}
|
||||
|
||||
function emitUnarchive() {
|
||||
emit('unarchive', rule.value.id);
|
||||
}
|
||||
|
||||
const popupTitle = computed(() => {
|
||||
if (rule.value.deleted) {
|
||||
return t('homepage.rule.enable_rule');
|
||||
@@ -99,6 +109,20 @@ const boxSize = computed(() => {
|
||||
<ab-rule v-model:rule="rule"></ab-rule>
|
||||
|
||||
<div fx-cer justify-end gap-x-10>
|
||||
<ab-button
|
||||
v-if="rule.archived"
|
||||
size="small"
|
||||
@click="emitUnarchive"
|
||||
>
|
||||
{{ $t('homepage.rule.unarchive') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
v-else
|
||||
size="small"
|
||||
@click="emitArchive"
|
||||
>
|
||||
{{ $t('homepage.rule.archive') }}
|
||||
</ab-button>
|
||||
<ab-button-multi
|
||||
size="small"
|
||||
type="warn"
|
||||
|
||||
@@ -8,6 +8,24 @@ const rule = defineModel<BangumiRule>('rule', {
|
||||
required: true,
|
||||
});
|
||||
|
||||
const offsetLoading = ref(false);
|
||||
const offsetReason = ref('');
|
||||
|
||||
async function autoDetectOffset() {
|
||||
if (!rule.value.id) return;
|
||||
offsetLoading.value = true;
|
||||
offsetReason.value = '';
|
||||
try {
|
||||
const result = await apiBangumi.suggestOffset(rule.value.id);
|
||||
rule.value.offset = result.suggested_offset;
|
||||
offsetReason.value = result.reason;
|
||||
} catch (e) {
|
||||
console.error('Failed to detect offset:', e);
|
||||
} finally {
|
||||
offsetLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const items: SettingItem<BangumiRule>[] = [
|
||||
{
|
||||
configKey: 'official_title',
|
||||
@@ -36,15 +54,6 @@ const items: SettingItem<BangumiRule>[] = [
|
||||
},
|
||||
bottomLine: true,
|
||||
},
|
||||
// {
|
||||
// configKey: 'offset',
|
||||
// label: () => t('homepage.rule.offset'),
|
||||
// type: 'input',
|
||||
// css: 'w-72',
|
||||
// prop: {
|
||||
// type: 'number',
|
||||
// },
|
||||
// },
|
||||
{
|
||||
configKey: 'filter',
|
||||
label: () => t('homepage.rule.exclude'),
|
||||
@@ -62,5 +71,65 @@ const items: SettingItem<BangumiRule>[] = [
|
||||
v-bind="i"
|
||||
v-model:data="rule[i.configKey]"
|
||||
></ab-setting>
|
||||
|
||||
<!-- Offset field with auto-detect button -->
|
||||
<div class="offset-row">
|
||||
<div class="offset-label">{{ $t('homepage.rule.offset') }}</div>
|
||||
<div class="offset-controls">
|
||||
<input
|
||||
v-model.number="rule.offset"
|
||||
type="number"
|
||||
class="offset-input"
|
||||
/>
|
||||
<ab-button
|
||||
size="small"
|
||||
:loading="offsetLoading"
|
||||
@click="autoDetectOffset"
|
||||
>
|
||||
{{ $t('homepage.rule.auto_detect') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
<div v-if="offsetReason" class="offset-reason">{{ offsetReason }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.offset-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.offset-label {
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.offset-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.offset-input {
|
||||
width: 72px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.offset-reason {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -131,7 +131,12 @@
|
||||
"exclude": "Exclude",
|
||||
"no_btn": "No",
|
||||
"official_title": "Official Title",
|
||||
"offset": "Offset",
|
||||
"offset": "Episode Offset",
|
||||
"auto_detect": "Auto Detect",
|
||||
"archive": "Archive",
|
||||
"unarchive": "Unarchive",
|
||||
"archived": "Archived",
|
||||
"archived_section": "Archived ({count})",
|
||||
"season": "Season",
|
||||
"year": "Year",
|
||||
"yes_btn": "Yes"
|
||||
|
||||
@@ -132,6 +132,11 @@
|
||||
"no_btn": "否",
|
||||
"official_title": "官方名称",
|
||||
"offset": "剧集偏移",
|
||||
"auto_detect": "自动检测",
|
||||
"archive": "归档",
|
||||
"unarchive": "取消归档",
|
||||
"archived": "已归档",
|
||||
"archived_section": "已归档 ({count})",
|
||||
"season": "季度",
|
||||
"year": "年份",
|
||||
"yes_btn": "是"
|
||||
|
||||
@@ -5,7 +5,7 @@ definePage({
|
||||
});
|
||||
|
||||
const { editRule } = storeToRefs(useBangumiStore());
|
||||
const { updateRule, enableRule, ruleManage } = useBangumiStore();
|
||||
const { updateRule, enableRule, archiveRule, unarchiveRule, ruleManage } = useBangumiStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,6 +34,8 @@ const { updateRule, enableRule, ruleManage } = useBangumiStore();
|
||||
v-model:show="editRule.show"
|
||||
v-model:rule="editRule.item"
|
||||
@enable="(id) => enableRule(id)"
|
||||
@archive="(id) => archiveRule(id)"
|
||||
@unarchive="(id) => unarchiveRule(id)"
|
||||
@delete-file="(type, { id, deleteFile }) => ruleManage(type, id, deleteFile)"
|
||||
@apply="(rule) => updateRule(rule.id, rule)"
|
||||
/>
|
||||
@@ -65,7 +67,7 @@ const { updateRule, enableRule, ruleManage } = useBangumiStore();
|
||||
|
||||
.layout-main {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
flex-direction: column;
|
||||
gap: var(--layout-gap);
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import type { BangumiRule } from '#/bangumi';
|
||||
|
||||
definePage({
|
||||
name: 'Bangumi List',
|
||||
});
|
||||
|
||||
const { bangumi } = storeToRefs(useBangumiStore());
|
||||
const { bangumi, showArchived, activeBangumi, archivedBangumi } = storeToRefs(useBangumiStore());
|
||||
const { getAll, openEditPopup } = useBangumiStore();
|
||||
|
||||
const refreshing = ref(false);
|
||||
@@ -20,6 +22,56 @@ async function onRefresh() {
|
||||
onActivated(() => {
|
||||
getAll();
|
||||
});
|
||||
|
||||
// Group bangumi by official_title + season
|
||||
interface BangumiGroup {
|
||||
key: string;
|
||||
primary: BangumiRule;
|
||||
rules: BangumiRule[];
|
||||
}
|
||||
|
||||
function groupBangumi(items: BangumiRule[]): BangumiGroup[] {
|
||||
if (!items) return [];
|
||||
const map = new Map<string, BangumiRule[]>();
|
||||
for (const item of items) {
|
||||
const key = `${item.official_title}::${item.season}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, []);
|
||||
}
|
||||
map.get(key)!.push(item);
|
||||
}
|
||||
const groups: BangumiGroup[] = [];
|
||||
for (const [key, rules] of map) {
|
||||
groups.push({ key, primary: rules[0], rules });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
const groupedBangumi = computed<BangumiGroup[]>(() => groupBangumi(activeBangumi.value));
|
||||
const groupedArchivedBangumi = computed<BangumiGroup[]>(() => groupBangumi(archivedBangumi.value));
|
||||
|
||||
// Rule list popup state
|
||||
const ruleListPopup = reactive<{
|
||||
show: boolean;
|
||||
group: BangumiGroup | null;
|
||||
}>({
|
||||
show: false,
|
||||
group: null,
|
||||
});
|
||||
|
||||
function onCardClick(group: BangumiGroup) {
|
||||
if (group.rules.length === 1) {
|
||||
openEditPopup(group.primary);
|
||||
} else {
|
||||
ruleListPopup.group = group;
|
||||
ruleListPopup.show = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onRuleSelect(rule: BangumiRule) {
|
||||
ruleListPopup.show = false;
|
||||
openEditPopup(rule);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -60,21 +112,93 @@ onActivated(() => {
|
||||
</div>
|
||||
|
||||
<!-- Bangumi grid -->
|
||||
<transition-group
|
||||
v-else
|
||||
name="bangumi"
|
||||
tag="div"
|
||||
class="bangumi-grid"
|
||||
<template v-else>
|
||||
<transition-group
|
||||
name="bangumi"
|
||||
tag="div"
|
||||
class="bangumi-grid"
|
||||
>
|
||||
<div
|
||||
v-for="group in groupedBangumi"
|
||||
:key="group.key"
|
||||
class="bangumi-group-wrapper"
|
||||
:class="[group.rules.every(r => r.deleted) && 'grayscale']"
|
||||
>
|
||||
<ab-bangumi-card
|
||||
:bangumi="group.primary"
|
||||
type="primary"
|
||||
@click="() => onCardClick(group)"
|
||||
/>
|
||||
<div v-if="group.rules.length > 1" class="group-badge">
|
||||
{{ group.rules.length }}
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
|
||||
<!-- Archived section -->
|
||||
<div v-if="groupedArchivedBangumi.length > 0" class="archived-section">
|
||||
<div
|
||||
class="archived-header"
|
||||
@click="showArchived = !showArchived"
|
||||
>
|
||||
<span class="archived-title">
|
||||
{{ $t('homepage.rule.archived_section', { count: archivedBangumi.length }) }}
|
||||
</span>
|
||||
<span class="archived-toggle">{{ showArchived ? '−' : '+' }}</span>
|
||||
</div>
|
||||
|
||||
<transition-group
|
||||
v-show="showArchived"
|
||||
name="bangumi"
|
||||
tag="div"
|
||||
class="bangumi-grid archived-grid"
|
||||
>
|
||||
<div
|
||||
v-for="group in groupedArchivedBangumi"
|
||||
:key="group.key"
|
||||
class="bangumi-group-wrapper archived-item"
|
||||
>
|
||||
<ab-bangumi-card
|
||||
:bangumi="group.primary"
|
||||
type="primary"
|
||||
@click="() => onCardClick(group)"
|
||||
/>
|
||||
<div v-if="group.rules.length > 1" class="group-badge">
|
||||
{{ group.rules.length }}
|
||||
</div>
|
||||
<div class="archived-badge">{{ $t('homepage.rule.archived') }}</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Rule list popup for grouped items -->
|
||||
<ab-popup
|
||||
v-model:show="ruleListPopup.show"
|
||||
:title="ruleListPopup.group?.primary.official_title || ''"
|
||||
>
|
||||
<ab-bangumi-card
|
||||
v-for="i in bangumi"
|
||||
:key="i.id"
|
||||
:class="[i.deleted && 'grayscale']"
|
||||
:bangumi="i"
|
||||
type="primary"
|
||||
@click="() => openEditPopup(i)"
|
||||
></ab-bangumi-card>
|
||||
</transition-group>
|
||||
<div v-if="ruleListPopup.group" class="rule-list">
|
||||
<div
|
||||
v-for="rule in ruleListPopup.group.rules"
|
||||
:key="rule.id"
|
||||
class="rule-list-item"
|
||||
:class="[rule.deleted && 'rule-list-item--disabled']"
|
||||
@click="onRuleSelect(rule)"
|
||||
>
|
||||
<div class="rule-list-item-info">
|
||||
<div class="rule-list-item-title">
|
||||
{{ rule.group_name || rule.rule_name || `Rule #${rule.id}` }}
|
||||
</div>
|
||||
<div class="rule-list-item-meta">
|
||||
<span v-if="rule.dpi">{{ rule.dpi }}</span>
|
||||
<span v-if="rule.subtitle">{{ rule.subtitle }}</span>
|
||||
<span v-if="rule.source">{{ rule.source }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule-list-item-arrow">›</div>
|
||||
</div>
|
||||
</div>
|
||||
</ab-popup>
|
||||
|
||||
</div>
|
||||
</ab-pull-refresh>
|
||||
@@ -90,6 +214,7 @@ onActivated(() => {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
|
||||
@include forTablet {
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
@@ -102,6 +227,149 @@ onActivated(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.bangumi-group-wrapper {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
width: fit-content;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.group-badge {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 11px;
|
||||
background: #ff3b30;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 6px rgba(255, 59, 48, 0.4);
|
||||
}
|
||||
|
||||
.archived-section {
|
||||
margin-top: 24px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.archived-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 8px;
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.archived-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.archived-toggle {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.archived-grid {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.archived-item {
|
||||
filter: grayscale(30%);
|
||||
}
|
||||
|
||||
.archived-badge {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rule-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.rule-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-list-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rule-list-item-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rule-list-item-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
span + span::before {
|
||||
content: '·';
|
||||
margin-right: 8px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.rule-list-item-arrow {
|
||||
font-size: 18px;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-guide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ruleTemplate } from '#/bangumi';
|
||||
|
||||
export const useBangumiStore = defineStore('bangumi', () => {
|
||||
const bangumi = ref<BangumiRule[]>([]);
|
||||
const showArchived = ref(false);
|
||||
const editRule = reactive<{
|
||||
show: boolean;
|
||||
item: BangumiRule;
|
||||
@@ -11,6 +12,16 @@ export const useBangumiStore = defineStore('bangumi', () => {
|
||||
item: { ...ruleTemplate },
|
||||
});
|
||||
|
||||
// Computed: active bangumi (not deleted, not archived)
|
||||
const activeBangumi = computed(() =>
|
||||
bangumi.value.filter((b) => !b.deleted && !b.archived)
|
||||
);
|
||||
|
||||
// Computed: archived bangumi (not deleted, archived)
|
||||
const archivedBangumi = computed(() =>
|
||||
bangumi.value.filter((b) => !b.deleted && b.archived)
|
||||
);
|
||||
|
||||
async function getAll() {
|
||||
const res = await apiBangumi.getAll();
|
||||
const sort = (arr: BangumiRule[]) => arr.sort((a, b) => b.id - a.id);
|
||||
@@ -38,6 +49,9 @@ export const useBangumiStore = defineStore('bangumi', () => {
|
||||
const { execute: disableRule } = useApi(apiBangumi.disableRule, opts);
|
||||
const { execute: deleteRule } = useApi(apiBangumi.deleteRule, opts);
|
||||
const { execute: refreshPoster } = useApi(apiBangumi.refreshPoster, opts);
|
||||
const { execute: archiveRule } = useApi(apiBangumi.archiveRule, opts);
|
||||
const { execute: unarchiveRule } = useApi(apiBangumi.unarchiveRule, opts);
|
||||
const { execute: refreshMetadata } = useApi(apiBangumi.refreshMetadata, opts);
|
||||
|
||||
function openEditPopup(data: BangumiRule) {
|
||||
editRule.show = true;
|
||||
@@ -62,6 +76,9 @@ export const useBangumiStore = defineStore('bangumi', () => {
|
||||
|
||||
return {
|
||||
bangumi,
|
||||
showArchived,
|
||||
activeBangumi,
|
||||
archivedBangumi,
|
||||
editRule,
|
||||
|
||||
getAll,
|
||||
@@ -70,6 +87,9 @@ export const useBangumiStore = defineStore('bangumi', () => {
|
||||
disableRule,
|
||||
deleteRule,
|
||||
refreshPoster,
|
||||
archiveRule,
|
||||
unarchiveRule,
|
||||
refreshMetadata,
|
||||
openEditPopup,
|
||||
ruleManage,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
export interface BangumiRule {
|
||||
added: boolean;
|
||||
deleted: boolean;
|
||||
archived: boolean;
|
||||
dpi: string;
|
||||
eps_collect: boolean;
|
||||
filter: string[];
|
||||
@@ -39,6 +40,7 @@ export type BangumiUpdate = Omit<BangumiAPI, 'id'>;
|
||||
export const ruleTemplate: BangumiRule = {
|
||||
added: false,
|
||||
deleted: false,
|
||||
archived: false,
|
||||
dpi: '',
|
||||
eps_collect: false,
|
||||
filter: [],
|
||||
@@ -58,3 +60,8 @@ export const ruleTemplate: BangumiRule = {
|
||||
year: null,
|
||||
air_weekday: null,
|
||||
};
|
||||
|
||||
export interface OffsetSuggestion {
|
||||
suggested_offset: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user