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:
Estrella Pan
2026-01-25 09:13:49 +01:00
committed by GitHub
parent ce5b23ea03
commit 55b15ea8fe
17 changed files with 787 additions and 48 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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="放送星期")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -132,6 +132,11 @@
"no_btn": "否",
"official_title": "官方名称",
"offset": "剧集偏移",
"auto_detect": "自动检测",
"archive": "归档",
"unarchive": "取消归档",
"archived": "已归档",
"archived_section": "已归档 ({count})",
"season": "季度",
"year": "年份",
"yes_btn": "是"

View File

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

View File

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

View File

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

View File

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