Unify organize ingest pipeline

This commit is contained in:
ShukeBta
2026-06-14 23:17:52 +08:00
parent a623702276
commit 479a06f008
41 changed files with 3075 additions and 728 deletions
+3
View File
@@ -5,6 +5,9 @@ export const subscriptionsAPI = {
list: () =>
api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items),
history: () =>
api.get<{ items: Subscription[] }>('/subscriptions/history').then((r) => r.data.items),
create: (input: {
name: string
feed_url: string
+274 -91
View File
@@ -58,32 +58,67 @@ type AutoOrganizeConfig = {
enabled: string
afterDownload: string
scrapeAfter: string
downloadSmartClassify: string
smartClassify: string
sourceDir: string
targetDir: string
transferMode: string
intervalSeconds: string
keepSeeding: string
movieFormat: string
tvFormat: string
animeFormat: string
scrapeAutoOnScan: string
scrapeProviders: string
scrapeLanguage: string
scrapeDelayMinMs: string
scrapeDelayMaxMs: string
}
const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = {
enabled: 'false',
afterDownload: 'false',
scrapeAfter: 'true',
downloadSmartClassify: 'true',
smartClassify: 'true',
sourceDir: '',
targetDir: '',
transferMode: 'hardlink',
intervalSeconds: '300',
keepSeeding: 'true',
movieFormat: '{title} ({year})/{title} ({year})',
tvFormat: '{title} ({year})/Season {season:02}/{title} S{season:02}E{episode:02}',
animeFormat: '{title}/Season {season:02}/{title} S{season:02}E{episode:02}',
scrapeAutoOnScan: 'false',
scrapeProviders: 'tmdb,douban,bangumi,thetvdb,fanart',
scrapeLanguage: 'zh-CN',
scrapeDelayMinMs: '250',
scrapeDelayMaxMs: '500',
}
const AUTO_ORGANIZE_KEYS: Record<keyof AutoOrganizeConfig, string> = {
enabled: 'organize.auto',
afterDownload: 'organizer.auto_after_download',
scrapeAfter: 'organize.scrape_after',
downloadSmartClassify: 'downloads.smart_classify',
smartClassify: 'organizer.smart_classify',
sourceDir: 'organize.source_dir',
targetDir: 'organize.target_dir',
transferMode: 'organize.transfer_mode',
intervalSeconds: 'organize.interval_seconds',
keepSeeding: 'organize.keep_seeding',
movieFormat: 'organize.movie_format',
tvFormat: 'organize.tv_format',
animeFormat: 'organize.anime_format',
scrapeAutoOnScan: 'scrape.auto_on_scan',
scrapeProviders: 'scrape.providers',
scrapeLanguage: 'scrape.language',
scrapeDelayMinMs: 'scrape.delay_min_ms',
scrapeDelayMaxMs: 'scrape.delay_max_ms',
}
type AutoOrganizeTab = 'basic' | 'naming' | 'scrape'
function settingIndex(rows: Setting[]): Record<string, string> {
const out: Record<string, string> = {}
for (const row of rows) out[row.key] = row.value
@@ -96,10 +131,21 @@ function mergeAutoOrganizeSettings(rows: Setting[]): AutoOrganizeConfig {
enabled: idx[AUTO_ORGANIZE_KEYS.enabled] ?? AUTO_ORGANIZE_DEFAULTS.enabled,
afterDownload: idx[AUTO_ORGANIZE_KEYS.afterDownload] ?? AUTO_ORGANIZE_DEFAULTS.afterDownload,
scrapeAfter: idx[AUTO_ORGANIZE_KEYS.scrapeAfter] ?? AUTO_ORGANIZE_DEFAULTS.scrapeAfter,
downloadSmartClassify: idx[AUTO_ORGANIZE_KEYS.downloadSmartClassify] ?? AUTO_ORGANIZE_DEFAULTS.downloadSmartClassify,
smartClassify: idx[AUTO_ORGANIZE_KEYS.smartClassify] ?? AUTO_ORGANIZE_DEFAULTS.smartClassify,
sourceDir: idx[AUTO_ORGANIZE_KEYS.sourceDir] ?? AUTO_ORGANIZE_DEFAULTS.sourceDir,
targetDir: idx[AUTO_ORGANIZE_KEYS.targetDir] ?? AUTO_ORGANIZE_DEFAULTS.targetDir,
transferMode: idx[AUTO_ORGANIZE_KEYS.transferMode] ?? AUTO_ORGANIZE_DEFAULTS.transferMode,
intervalSeconds: idx[AUTO_ORGANIZE_KEYS.intervalSeconds] ?? AUTO_ORGANIZE_DEFAULTS.intervalSeconds,
keepSeeding: idx[AUTO_ORGANIZE_KEYS.keepSeeding] ?? AUTO_ORGANIZE_DEFAULTS.keepSeeding,
movieFormat: idx[AUTO_ORGANIZE_KEYS.movieFormat] ?? AUTO_ORGANIZE_DEFAULTS.movieFormat,
tvFormat: idx[AUTO_ORGANIZE_KEYS.tvFormat] ?? AUTO_ORGANIZE_DEFAULTS.tvFormat,
animeFormat: idx[AUTO_ORGANIZE_KEYS.animeFormat] ?? AUTO_ORGANIZE_DEFAULTS.animeFormat,
scrapeAutoOnScan: idx[AUTO_ORGANIZE_KEYS.scrapeAutoOnScan] ?? AUTO_ORGANIZE_DEFAULTS.scrapeAutoOnScan,
scrapeProviders: idx[AUTO_ORGANIZE_KEYS.scrapeProviders] ?? AUTO_ORGANIZE_DEFAULTS.scrapeProviders,
scrapeLanguage: idx[AUTO_ORGANIZE_KEYS.scrapeLanguage] ?? AUTO_ORGANIZE_DEFAULTS.scrapeLanguage,
scrapeDelayMinMs: idx[AUTO_ORGANIZE_KEYS.scrapeDelayMinMs] ?? AUTO_ORGANIZE_DEFAULTS.scrapeDelayMinMs,
scrapeDelayMaxMs: idx[AUTO_ORGANIZE_KEYS.scrapeDelayMaxMs] ?? AUTO_ORGANIZE_DEFAULTS.scrapeDelayMaxMs,
}
}
@@ -144,6 +190,7 @@ export function FileManagerPage() {
const [autoSaving, setAutoSaving] = useState(false)
const [autoRunning, setAutoRunning] = useState(false)
const [autoLoading, setAutoLoading] = useState(true)
const [autoTab, setAutoTab] = useState<AutoOrganizeTab>('basic')
const currentDir = useMemo(() => {
if (data?.path) return data.path
@@ -213,6 +260,7 @@ export function FileManagerPage() {
const changeAutoConfig = (key: keyof AutoOrganizeConfig, value: string) => {
setAutoConfig((current) => ({ ...current, [key]: value }))
if (key === 'scrapeAfter') setScrapeAfter(settingOn(value))
setAutoDirty(true)
}
@@ -223,10 +271,10 @@ export function FileManagerPage() {
await adminAPI.updateSetting(AUTO_ORGANIZE_KEYS[key], autoConfig[key] ?? '')
}
setAutoDirty(false)
toast.success('自动整理设置已保存')
toast.success('整理入库设置已保存')
return true
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存自动整理设置失败')
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存整理入库设置失败')
return false
} finally {
setAutoSaving(false)
@@ -447,99 +495,234 @@ export function FileManagerPage() {
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_150px_140px]">
<label className="space-y-1">
<span className="text-xs text-ink-50">整理源目录(待整理 / 下载目录)</span>
<div className="flex gap-2">
<input
className="input-base w-full"
placeholder="例如 F:\\downloads 或 /downloads"
value={autoConfig.sourceDir}
onChange={(event) => changeAutoConfig('sourceDir', event.target.value)}
/>
<button
type="button"
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
disabled={!currentDir}
onClick={() => changeAutoConfig('sourceDir', currentDir)}
>
当前
</button>
</div>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">整理目的地目录(媒体库根目录)</span>
<div className="flex gap-2">
<input
className="input-base w-full"
placeholder="例如 F:\\media 或 /media"
value={autoConfig.targetDir}
onChange={(event) => changeAutoConfig('targetDir', event.target.value)}
/>
<button
type="button"
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
disabled={!currentDir}
onClick={() => changeAutoConfig('targetDir', currentDir)}
>
当前
</button>
</div>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">默认整理方式</span>
<select
className="input-base w-full"
value={autoConfig.transferMode}
onChange={(event) => changeAutoConfig('transferMode', event.target.value)}
<div className="flex flex-wrap gap-2 rounded-2xl border border-gray-200 bg-gray-50 p-1">
{[
['basic', '基础设置'],
['naming', '命名规则'],
['scrape', '刮削联动'],
].map(([key, label]) => (
<button
key={key}
type="button"
className={
autoTab === key
? 'rounded-xl bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 shadow-sm'
: 'rounded-xl px-3 py-1.5 text-xs text-ink-100 hover:bg-white/70'
}
onClick={() => setAutoTab(key as AutoOrganizeTab)}
>
<option value="hardlink">硬链接</option>
<option value="move">移动</option>
<option value="copy">复制</option>
<option value="symlink">软链接</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">检查间隔(秒)</span>
<input
type="number"
min={60}
className="input-base w-full"
value={autoConfig.intervalSeconds}
onChange={(event) => changeAutoConfig('intervalSeconds', event.target.value)}
/>
</label>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.enabled)}
onChange={(event) => changeAutoConfig('enabled', event.target.checked ? 'true' : 'false')}
/>
整理源目录定时自动整理
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.afterDownload)}
onChange={(event) => changeAutoConfig('afterDownload', event.target.checked ? 'true' : 'false')}
/>
qB 下载完成后自动整理
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.scrapeAfter)}
onChange={(event) => changeAutoConfig('scrapeAfter', event.target.checked ? 'true' : 'false')}
/>
整理后自动刮削
</label>
<span className="text-xs text-sand-500">
{label}
</button>
))}
<span className="ml-auto self-center px-2 text-xs text-sand-500">
{autoDirty ? '有未保存设置' : '设置已同步'} · 定时任务名:organize_source
</span>
</div>
{autoTab === 'basic' && (
<>
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_150px_140px]">
<label className="space-y-1">
<span className="text-xs text-ink-50">整理源目录(待整理 / 下载目录)</span>
<div className="flex gap-2">
<input
className="input-base w-full"
placeholder="例如 F:\\downloads 或 /downloads"
value={autoConfig.sourceDir}
onChange={(event) => changeAutoConfig('sourceDir', event.target.value)}
/>
<button
type="button"
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
disabled={!currentDir}
onClick={() => changeAutoConfig('sourceDir', currentDir)}
>
当前
</button>
</div>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">整理目的地目录(媒体库根目录)</span>
<div className="flex gap-2">
<input
className="input-base w-full"
placeholder="例如 F:\\media 或 /media"
value={autoConfig.targetDir}
onChange={(event) => changeAutoConfig('targetDir', event.target.value)}
/>
<button
type="button"
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
disabled={!currentDir}
onClick={() => changeAutoConfig('targetDir', currentDir)}
>
当前
</button>
</div>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">默认整理方式</span>
<select
className="input-base w-full"
value={autoConfig.transferMode}
onChange={(event) => changeAutoConfig('transferMode', event.target.value)}
>
<option value="hardlink">硬链接</option>
<option value="move">移动</option>
<option value="copy">复制</option>
<option value="symlink">软链接</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">检查间隔(秒)</span>
<input
type="number"
min={60}
className="input-base w-full"
value={autoConfig.intervalSeconds}
onChange={(event) => changeAutoConfig('intervalSeconds', event.target.value)}
/>
</label>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.enabled)}
onChange={(event) => changeAutoConfig('enabled', event.target.checked ? 'true' : 'false')}
/>
整理源目录定时自动整理
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.afterDownload)}
onChange={(event) => changeAutoConfig('afterDownload', event.target.checked ? 'true' : 'false')}
/>
qB 下载完成后自动整理
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.downloadSmartClassify)}
onChange={(event) => changeAutoConfig('downloadSmartClassify', event.target.checked ? 'true' : 'false')}
/>
下载器智能分类
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.smartClassify)}
onChange={(event) => changeAutoConfig('smartClassify', event.target.checked ? 'true' : 'false')}
/>
智能分类到子库
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.keepSeeding)}
onChange={(event) => changeAutoConfig('keepSeeding', event.target.checked ? 'true' : 'false')}
/>
保种
</label>
</div>
</>
)}
{autoTab === 'naming' && (
<div className="grid gap-3">
<label className="space-y-1">
<span className="text-xs text-ink-50">电影命名格式</span>
<input
className="input-base w-full font-mono text-xs"
value={autoConfig.movieFormat}
onChange={(event) => changeAutoConfig('movieFormat', event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">剧集命名格式</span>
<input
className="input-base w-full font-mono text-xs"
value={autoConfig.tvFormat}
onChange={(event) => changeAutoConfig('tvFormat', event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">动漫命名格式</span>
<input
className="input-base w-full font-mono text-xs"
value={autoConfig.animeFormat}
onChange={(event) => changeAutoConfig('animeFormat', event.target.value)}
/>
</label>
<p className="text-xs text-sand-500">
可用占位符:{'{title}'} {'{year}'} {'{season}'} {'{season:02}'} {'{episode}'} {'{episode:02}'} {'{category}'}。扩展名会自动补齐。
</p>
</div>
)}
{autoTab === 'scrape' && (
<>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.scrapeAfter)}
onChange={(event) => changeAutoConfig('scrapeAfter', event.target.checked ? 'true' : 'false')}
/>
整理后自动刮削
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.scrapeAutoOnScan)}
onChange={(event) => changeAutoConfig('scrapeAutoOnScan', event.target.checked ? 'true' : 'false')}
/>
扫描后自动刮削
</label>
</div>
<div className="grid gap-3 lg:grid-cols-[1fr_160px_160px_160px]">
<label className="space-y-1">
<span className="text-xs text-ink-50">刮削源优先级</span>
<input
className="input-base w-full"
placeholder="tmdb,douban,bangumi,thetvdb,fanart"
value={autoConfig.scrapeProviders}
onChange={(event) => changeAutoConfig('scrapeProviders', event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">首选语言</span>
<input
className="input-base w-full"
value={autoConfig.scrapeLanguage}
onChange={(event) => changeAutoConfig('scrapeLanguage', event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">最小间隔 ms</span>
<input
type="number"
min={0}
className="input-base w-full"
value={autoConfig.scrapeDelayMinMs}
onChange={(event) => changeAutoConfig('scrapeDelayMinMs', event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">最大间隔 ms</span>
<input
type="number"
min={0}
className="input-base w-full"
value={autoConfig.scrapeDelayMaxMs}
onChange={(event) => changeAutoConfig('scrapeDelayMaxMs', event.target.value)}
/>
</label>
</div>
</>
)}
</section>
{data?.path && (
+4 -181
View File
@@ -1,13 +1,13 @@
import { FormEvent, useEffect, useState } from 'react'
import { FolderOpen, Loader2, Save, SettingsIcon } from 'lucide-react'
import { Loader2, Save, SettingsIcon } from 'lucide-react'
import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
import type { Library, Setting } from '../types'
// SettingsPage replaces the Vue SettingsView's General / Organize /
// Scrape / Adult tabs. The Go backend stores settings as a single
// SettingsPage replaces the Vue SettingsView's curated runtime settings.
// The Go backend stores settings as a single
// key/value table; we group the most useful keys client-side and let
// the operator edit them with typed widgets (select / toggle / input).
//
@@ -156,145 +156,6 @@ const GROUPS: SettingGroup[] = [
},
],
},
{
key: 'organize',
label: '整理 & 刮削',
description: '媒体文件命名 + 自动刮削 + 整理目标',
items: [
{
key: 'organize.auto',
label: '整理源目录定时自动整理',
type: 'toggle',
hint: '开启后后台会按下方间隔递归扫描「整理源目录」,自动整理到「整理目的地目录」并扫描入库。默认关闭,避免无意中频繁读盘。',
defaultValue: 'false',
},
{
key: 'organizer.auto_after_download',
label: '下载完成后自动整理入库',
type: 'toggle',
hint: '开启后 qB 下载完成时,系统会优先使用种子的 content_path 整理该文件/目录,并在整理完成后扫描目标媒体库。',
},
{
key: 'organize.scrape_after',
label: '整理后自动刮削',
type: 'toggle',
hint: '开启后,手动/自动整理完成并扫描入库后,会立即触发 TMDb/豆瓣/Bangumi/JavBus/JavDB 等元数据刮削。需要先配置可用刮削源。',
defaultValue: 'false',
},
{
key: 'downloads.smart_classify',
label: '下载器智能分类',
type: 'toggle',
hint: '订阅下载和站点搜索下载未指定保存路径时,自动按媒体类型/分类写入 qB 保存目录与 qB 分类(如:/downloads/国产剧、/downloads/综艺)。',
defaultValue: 'true',
},
{
key: 'organizer.smart_classify',
label: '启用智能分类',
type: 'toggle',
hint: '整理/入库时根据元数据(语言/国家/类型)自动分类到媒体库子目录(如:华语电影、欧美剧、日番)',
},
{
key: 'organize.source_dir',
label: '整理源目录(待整理)',
type: 'text',
hint: '从该目录读取待整理文件;留空则默认整理整个媒体库(媒体库路径)。',
placeholder: '/mnt/downloads',
},
{
key: 'organize.target_dir',
label: '整理目的地目录',
type: 'text',
hint: '整理后输出到该目录;留空则默认整理到各媒体库对应路径(见下方参考)。与「源目录」相互独立。',
placeholder: '/mnt/media/organized',
},
{
key: 'organize.transfer_mode',
label: '默认转移方式',
type: 'select',
hint: '移动会删除源文件;复制/硬链接/软链接保留源文件,PT 做种不中断。硬链接同盘零额外占用。',
options: [
{ value: 'move', label: '移动(删除源文件)' },
{ value: 'copy', label: '复制(保留源文件)' },
{ value: 'hardlink', label: '硬链接(保留源,做种不中断,不占双倍空间)' },
{ value: 'symlink', label: '软链接(符号链接,保留源)' },
],
},
{
key: 'organize.interval_seconds',
label: '自动整理间隔秒数',
type: 'number',
hint: '仅在「整理源目录定时自动整理」开启后生效;最小 60 秒,建议 300 秒或更高。',
defaultValue: '300',
placeholder: '300',
},
{
key: 'organize.keep_seeding',
label: '保种(整理后继续做种上传)',
type: 'toggle',
hint: '开启后即使选择「移动」也会自动改用硬链接保留源文件,确保 qBittorrent 继续做种。硬链接要求源和目标在同一文件系统;失败时会提示,不再静默复制占用双倍空间。',
},
{
key: 'organize.movie_format',
label: '电影命名格式',
type: 'text',
hint: '例: {title} ({year})/{title} ({year})',
placeholder: '{title} ({year})/{title} ({year})',
},
{
key: 'organize.tv_format',
label: '剧集命名格式',
type: 'text',
placeholder: '{title} ({year})/Season {season}/{title} S{season:02}E{episode:02}',
},
{
key: 'organize.anime_format',
label: '动漫命名格式',
type: 'text',
placeholder: '{title}/Season {season}/{title} S{season:02}E{episode:02}',
},
{
key: 'scrape.auto_on_scan',
label: '扫描后自动刮削',
type: 'toggle',
},
{
key: 'scrape.providers',
label: '刮削源优先级',
type: 'text',
hint: '逗号分隔: tmdb,bangumi,thetvdb,fanart',
placeholder: 'tmdb,bangumi,thetvdb,fanart',
},
{
key: 'scrape.language',
label: '刮削首选语言',
type: 'text',
placeholder: 'zh-CN',
},
{
key: 'scrape.delay_min_ms',
label: '刮削最小间隔毫秒',
type: 'number',
hint: '参考 nowen-video 的随机节流策略。批量刮削时两条媒体之间会随机等待,避免 TMDb / Bangumi / JavBus / JavDB 等源请求过快。',
defaultValue: '250',
placeholder: '250',
},
{
key: 'scrape.delay_max_ms',
label: '刮削最大间隔毫秒',
type: 'number',
hint: '如遇到站点限速、超时或 403,可提高到 2000-5000;填 0 可关闭批量刮削间隔。',
defaultValue: '500',
placeholder: '500',
},
{
key: 'scan.periodic_enabled',
label: '周期性整库重扫',
type: 'toggle',
hint: '默认关闭。文件新增/变更由实时监听增量入库,无需定时全量重扫。开启会每 60 分钟重扫整库,频繁读盘会损伤硬盘,一般无需开启。',
},
],
},
{
key: 'cloud-upload',
label: '网盘转存',
@@ -499,7 +360,7 @@ export function SettingsPage() {
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">系统设置</h1>
<p className="text-sm text-ink-50">
按分组编辑转码 / 整理 / 刮削 / 下载器等关键配置
按分组编辑转码 / 网盘转存 / Adult 等关键配置
</p>
</div>
</div>
@@ -554,44 +415,6 @@ export function SettingsPage() {
</div>
</form>
)}
{/* 整理 tab 时显示各媒体库默认路径 */}
{!loading && activeGroup === 'organize' && libraries.length > 0 && (
<div className="glass-panel">
<div className="mb-3 flex items-center gap-2 text-sm text-ink-100">
<FolderOpen size={16} className="text-brand-500" />
<span>默认整理路径参考(未设目的地目录时按媒体库归类)</span>
</div>
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">媒体库</th>
<th>类型</th>
<th>路径</th>
<th>整理后示例</th>
</tr>
</thead>
<tbody>
{libraries.map((lib) => (
<tr key={lib.id} className="border-t border-gray-200">
<td className="py-2 font-medium text-ink-600">{lib.name}</td>
<td className="text-ink-50">
{lib.type === 'movie' ? '电影' : lib.type === 'tv' ? '电视剧' : lib.type === 'anime' ? '动漫' : '音乐'}
</td>
<td className="font-mono text-xs text-ink-50">{lib.path}</td>
<td className="font-mono text-[11px] text-sand-500">
{lib.type === 'movie'
? `${lib.path}/片名 (2024)/片名 (2024).mkv`
: lib.type === 'tv' || lib.type === 'anime'
? `${lib.path}/剧名/Season 01/剧名 - S01E01.mkv`
: lib.path}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
+44 -4
View File
@@ -1,6 +1,6 @@
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { CalendarClock, CheckCircle2, Film, Pencil, Play, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react'
import { Archive, CalendarClock, CheckCircle2, Film, Pencil, Play, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react'
import { subscriptionsAPI } from '../api/subscriptions'
import { imageURL } from '../api/client'
@@ -9,6 +9,7 @@ import type { Subscription } from '../types'
export function SubscriptionsPage() {
const [items, setItems] = useState<Subscription[]>([])
const [historyItems, setHistoryItems] = useState<Subscription[]>([])
const [name, setName] = useState('')
const [feed, setFeed] = useState('')
const [filter, setFilter] = useState('')
@@ -28,9 +29,11 @@ export function SubscriptionsPage() {
const [loading, setLoading] = useState(true)
const refresh = () =>
subscriptionsAPI
.list()
.then(setItems)
Promise.all([subscriptionsAPI.list(), subscriptionsAPI.history()])
.then(([active, history]) => {
setItems(active)
setHistoryItems(history)
})
.finally(() => setLoading(false))
useEffect(() => {
@@ -311,6 +314,43 @@ export function SubscriptionsPage() {
))}
</div>
)}
{historyItems.length > 0 && (
<section className="space-y-3">
<div className="flex items-center gap-2">
<Archive size={18} className="text-brand-500" />
<h2 className="font-display text-xl font-semibold text-ink-600">订阅历史</h2>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-ink-50">{historyItems.length} 条</span>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{historyItems.map((subscription) => (
<article key={subscription.id} className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex gap-3">
<div className="h-20 w-14 flex-shrink-0 overflow-hidden rounded-xl bg-primary-400/10">
{subscription.poster_url ? (
<img src={imageURL(subscription.poster_url)} alt={subscription.name} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center text-brand-500">
<Film size={18} />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-semibold text-ink-600" title={subscription.name}>{subscription.name}</h3>
<p className="mt-1 text-xs text-ink-50">
{subscription.archive_reason || '订阅已完成'}
</p>
<p className="mt-2 text-xs text-ink-100">
{subscription.archived_at ? new Date(subscription.archived_at).toLocaleString() : '完成时间未知'}
</p>
<p className="mt-1 text-xs text-ink-50">{subscriptionProgressLabel(subscription)}</p>
</div>
</div>
</article>
))}
</div>
</section>
)}
</div>
)
}
+2
View File
@@ -206,6 +206,8 @@ export interface Subscription {
priority?: number
enabled: boolean
last_run_at?: string
archived_at?: string
archive_reason?: string
created_at: string
updated_at: string
}