fix(media): 修复emby电影库混入剧集/特别篇识别/剧集合集 + 新增手动编辑元数据

emby 兼容层(hills/yamby 等客户端):
- 电影库不再混入剧集:filterLikelyEpisodicPathsFromMovieQuery 按路径
  特征(Season/S0x/Specials/电视剧/日番/国漫等)剔除误入电影库的剧集
- itemPayload 改用 mediaShouldBeEpisode:有季集号且(属剧集库或路径像剧集)
  才标记为 Episode,修正电影库里剧集被当成 Movie

特别篇识别:
- episode_parser 新增 patSpecialSeason,识别 S0/Season 0/Specials/SP/OVA/
  OAD/番外/特别篇/特典 文件夹 → 第0季;seasonFromParents 返回 found 标志,
  区分"明确第0季"与"未识别默认第1季"
- emby seasonDir 正则同步支持 Specials/SP/OVA/特别篇 等

剧集合集:
- groupSeries 抽出 isSeriesCard,卡片链接按是否剧集判定,修复剧集卡片
  跳转;每集分离的根因(季集号/分组key)随入库识别修复一并改善

手动编辑元数据(自采集视频无TMDB信息时可自定义封面/标题/简介/分类):
- 新增 MediaService.UpdateMetadata + PATCH /media/:id/metadata(需admin)
- 前端 MetadataEditDialog 接入 LibraryPage 与 MediaDetailPage

其他:scanner/scraper/nfo/local+cloud_metadata/adult 元数据识别配套改进;
.gitignore 忽略本地 .dev-cache/.dev-data 运行产物。
This commit is contained in:
ShukeBta
2026-06-18 21:56:25 +00:00
parent cce88ce3b0
commit b44c7f860b
34 changed files with 1601 additions and 207 deletions
+25
View File
@@ -19,6 +19,7 @@ export interface ManualScrapeCandidate {
source: string
media_type?: string
title: string
original_name?: string
overview?: string
poster_url?: string
backdrop_url?: string
@@ -31,6 +32,27 @@ export interface ManualScrapeCandidate {
languages?: string[]
countries?: string[]
genres?: string[]
nsfw?: boolean
}
export interface MediaMetadataUpdate {
title?: string
original_name?: string
overview?: string
poster_url?: string
backdrop_url?: string
year?: number
rating?: number
season_num?: number
episode_num?: number
tmdb_id?: number
bangumi_id?: number
douban_id?: string
thetvdb_id?: string
languages?: string
countries?: string
genres?: string
nsfw?: boolean
}
export const libraryAPI = {
@@ -75,6 +97,9 @@ export const mediaAPI = {
get: (id: string) => api.get<Media>(`/media/${id}`).then((r) => r.data),
updateMetadata: (id: string, payload: MediaMetadataUpdate) =>
api.patch<Media>(`/media/${id}/metadata`, payload, { timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
manualScrapeSearch: (id: string, params: { query: string; provider?: string; media_type?: string }) =>
api
.get<{ items: ManualScrapeCandidate[] }>(`/media/${id}/scrape/search`, { params })
+50 -15
View File
@@ -28,7 +28,7 @@ export function APIConfigsPanel() {
<div>
<p className="font-display text-lg font-semibold text-ink-600">外部 API 配置</p>
<p className="text-xs text-ink-50">
TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 密钥管理
TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban / Adult 密钥与源管理
· AES-GCM 加密存储
</p>
</div>
@@ -73,14 +73,17 @@ export function APIConfigsPanel() {
)}
</td>
<td className="px-4 py-3 font-mono text-xs">
{item.has_key ? (
{apiConfigConfigured(item) ? (
<span className="text-brand-500">{item.masked_key}</span>
) : (
<span className="text-gray-500">未配置</span>
)}
{item.provider === 'adult' && apiConfigSourceCount(item) > 0 ? (
<span className="text-brand-500">{apiConfigSourceCount(item)} 个源</span>
) : null}
</td>
<td className="px-4 py-3">
{item.has_key ? (
{apiConfigConfigured(item) ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-400/10 px-2 py-0.5 text-xs text-emerald-400">
已配置
</span>
@@ -148,14 +151,17 @@ function EditingRow({
}) {
const [apiKey, setAPIKey] = useState('')
const [baseURL, setBaseURL] = useState(item.base_url ?? '')
const [extra, setExtra] = useState(item.extra ?? '')
const [enabled, setEnabled] = useState(item.enabled)
const [saving, setSaving] = useState(false)
const isAdult = item.provider === 'adult'
const submit = async (e: FormEvent) => {
e.preventDefault()
setSaving(true)
try {
const patch: Record<string, unknown> = { base_url: baseURL, enabled }
if (isAdult) patch.extra = extra
if (apiKey.trim()) patch.api_key = apiKey.trim()
await apiConfigsAPI.update(item.provider, patch)
toast.success(`${item.provider} 已保存`)
@@ -175,25 +181,38 @@ function EditingRow({
<td colSpan={4} className="px-4 py-3">
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
<span className="text-sm font-medium text-ink-600">{item.provider}</span>
{!isAdult && (
<label className="flex-1 text-xs text-ink-50">
API Key
<input
className="input-base mt-1"
type="password"
placeholder={item.has_key ? '•••••••••••• (留空保留原值)' : '输入密钥'}
value={apiKey}
onChange={(e) => setAPIKey(e.target.value)}
/>
</label>
)}
<label className="flex-1 text-xs text-ink-50">
API Key
{isAdult ? '主源 URL' : 'Base URL'}
<input
className="input-base mt-1"
type="password"
placeholder={item.has_key ? '•••••••••••• (留空保留原值)' : '输入密钥'}
value={apiKey}
onChange={(e) => setAPIKey(e.target.value)}
/>
</label>
<label className="flex-1 text-xs text-ink-50">
Base URL
<input
className="input-base mt-1"
placeholder="https://api.themoviedb.org/3"
placeholder={isAdult ? 'https://javdb.com' : 'https://api.themoviedb.org/3'}
value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
/>
</label>
{isAdult && (
<label className="min-w-64 flex-1 text-xs text-ink-50">
备用源 URL
<textarea
className="input-base mt-1 min-h-20 resize-y"
placeholder={'https://javbus.sbs\nhttps://www.javbus.com'}
value={extra}
onChange={(e) => setExtra(e.target.value)}
/>
</label>
)}
<label className="flex items-center gap-2 text-xs text-ink-50">
<input
type="checkbox"
@@ -217,3 +236,19 @@ function EditingRow({
</tr>
)
}
function apiConfigConfigured(item: APIConfig): boolean {
if (item.provider === 'adult') {
return Boolean(item.base_url?.trim() || item.extra?.trim())
}
return item.has_key
}
function apiConfigSourceCount(item: APIConfig): number {
if (item.provider !== 'adult') return 0
return [item.base_url, item.extra]
.join('\n')
.split(/[\s,;]+/)
.map((value) => value.trim())
.filter(Boolean).length
}
@@ -23,6 +23,7 @@ const providers = [
{ value: 'douban', label: '豆瓣' },
{ value: 'bangumi', label: 'Bangumi' },
{ value: 'thetvdb', label: 'TheTVDB' },
{ value: 'adult', label: 'Adult / 番号' },
]
export function ManualScrapeDialog({
@@ -164,6 +165,7 @@ export function ManualScrapeDialog({
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold uppercase text-brand-700">{item.source}</span>
{item.nsfw ? <span className="rounded-full bg-rose-50 px-2 py-0.5 text-[11px] font-bold text-rose-600">成人</span> : null}
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
</div>
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-ink-50">{item.overview || '暂无简介'}</p>
@@ -191,6 +193,7 @@ function candidateKey(item: ManualScrapeCandidate): string {
function candidateIDText(item: ManualScrapeCandidate): string {
const parts = [
item.tmdb_id ? `TMDb ${item.tmdb_id}` : '',
item.original_name && item.source === 'adult' ? `番号 ${item.original_name}` : '',
item.douban_id ? `豆瓣 ${item.douban_id}` : '',
item.bangumi_id ? `Bangumi ${item.bangumi_id}` : '',
item.thetvdb_id ? `TheTVDB ${item.thetvdb_id}` : '',
+220
View File
@@ -0,0 +1,220 @@
import { useEffect, useState } from 'react'
import { Save, X } from 'lucide-react'
import toast from 'react-hot-toast'
import { mediaAPI, type MediaMetadataUpdate } from '../api/library'
import type { Media } from '../types'
interface MetadataEditDialogProps {
open: boolean
media: Media | null
mediaIds?: string[]
mode?: 'media' | 'series'
scopeLabel?: string
onClose: () => void
onSaved: (media: Media) => void | Promise<void>
}
export function MetadataEditDialog({
open,
media,
mediaIds,
mode = 'media',
scopeLabel,
onClose,
onSaved,
}: MetadataEditDialogProps) {
const [form, setForm] = useState({
title: '',
original_name: '',
overview: '',
poster_url: '',
backdrop_url: '',
year: '',
rating: '',
season_num: '',
episode_num: '',
tmdb_id: '',
bangumi_id: '',
douban_id: '',
thetvdb_id: '',
languages: '',
countries: '',
genres: '',
nsfw: false,
})
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open || !media) return
setForm({
title: media.title || '',
original_name: media.original_name || '',
overview: media.overview || '',
poster_url: media.poster_url || '',
backdrop_url: media.backdrop_url || '',
year: media.year > 0 ? String(media.year) : '',
rating: media.rating > 0 ? String(media.rating) : '',
season_num: media.season_num > 0 || media.episode_num > 0 ? String(media.season_num || 0) : '',
episode_num: media.episode_num > 0 ? String(media.episode_num) : '',
tmdb_id: media.tmdb_id > 0 ? String(media.tmdb_id) : '',
bangumi_id: media.bangumi_id > 0 ? String(media.bangumi_id) : '',
douban_id: media.douban_id || '',
thetvdb_id: media.thetvdb_id || '',
languages: media.languages || '',
countries: media.countries || '',
genres: media.genres || '',
nsfw: !!media.nsfw,
})
}, [open, media])
if (!open || !media) return null
const targetIds = Array.from(new Set((mediaIds && mediaIds.length > 0 ? mediaIds : [media.id]).filter(Boolean)))
const isSeries = mode === 'series' && targetIds.length > 1
const set = (key: keyof typeof form, value: string | boolean) => {
setForm((prev) => ({ ...prev, [key]: value }))
}
const toNumber = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return 0
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : 0
}
const buildPayload = (): MediaMetadataUpdate => {
const payload: MediaMetadataUpdate = {
title: form.title,
overview: form.overview,
poster_url: form.poster_url,
backdrop_url: form.backdrop_url,
year: Math.trunc(toNumber(form.year)),
rating: toNumber(form.rating),
tmdb_id: Math.trunc(toNumber(form.tmdb_id)),
bangumi_id: Math.trunc(toNumber(form.bangumi_id)),
douban_id: form.douban_id,
thetvdb_id: form.thetvdb_id,
languages: form.languages,
countries: form.countries,
genres: form.genres,
nsfw: form.nsfw,
}
if (!isSeries) {
payload.original_name = form.original_name
payload.season_num = Math.trunc(toNumber(form.season_num))
payload.episode_num = Math.trunc(toNumber(form.episode_num))
}
return payload
}
const save = async () => {
if (!form.title.trim()) {
toast.error('标题不能为空')
return
}
setSaving(true)
try {
const payload = buildPayload()
let next: Media | null = null
for (const id of targetIds) {
const updated = await mediaAPI.updateMetadata(id, payload)
if (!next || id === media.id) next = updated
}
if (!next) next = await mediaAPI.updateMetadata(media.id, payload)
toast.success(isSeries ? `整剧元数据已保存:${targetIds.length} 集` : '元数据已保存')
await onSaved(next)
onClose()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '保存失败'
toast.error(msg)
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-900/40 px-4 py-8 backdrop-blur-sm">
<div className="flex max-h-[88vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl">
<div className="flex items-start justify-between gap-4 border-b border-gray-200 px-5 py-4">
<div>
<h2 className="font-display text-xl font-bold text-gray-900">
{isSeries ? '编辑整剧元数据' : '编辑元数据'}
</h2>
<p className="mt-1 text-xs text-gray-500">{scopeLabel || '用于手动修正自采集或无法自动匹配的媒体。'}</p>
</div>
<button onClick={onClose} className="btn-ghost h-9 w-9 p-0" aria-label="关闭">
<X size={16} />
</button>
</div>
<div className="grid flex-1 gap-4 overflow-y-auto p-5 md:grid-cols-2">
<Field label="标题" value={form.title} onChange={(value) => set('title', value)} />
{!isSeries && <Field label="原名 / 单集名" value={form.original_name} onChange={(value) => set('original_name', value)} />}
<Field label="海报 URL" value={form.poster_url} onChange={(value) => set('poster_url', value)} />
<Field label="背景 / 单集剧照 URL" value={form.backdrop_url} onChange={(value) => set('backdrop_url', value)} />
<Field label="年份" value={form.year} onChange={(value) => set('year', value)} inputMode="numeric" />
<Field label="评分" value={form.rating} onChange={(value) => set('rating', value)} inputMode="decimal" />
{!isSeries && <Field label="季" value={form.season_num} onChange={(value) => set('season_num', value)} inputMode="numeric" />}
{!isSeries && <Field label="集" value={form.episode_num} onChange={(value) => set('episode_num', value)} inputMode="numeric" />}
<Field label="TMDb ID" value={form.tmdb_id} onChange={(value) => set('tmdb_id', value)} inputMode="numeric" />
<Field label="Bangumi ID" value={form.bangumi_id} onChange={(value) => set('bangumi_id', value)} inputMode="numeric" />
<Field label="豆瓣 ID" value={form.douban_id} onChange={(value) => set('douban_id', value)} />
<Field label="TheTVDB ID" value={form.thetvdb_id} onChange={(value) => set('thetvdb_id', value)} />
<Field label="语言" value={form.languages} onChange={(value) => set('languages', value)} placeholder="zh,en" />
<Field label="国家/地区" value={form.countries} onChange={(value) => set('countries', value)} placeholder="CN,JP,US" />
<Field label="类型" value={form.genres} onChange={(value) => set('genres', value)} placeholder="剧情,动画" />
<label className="flex h-11 items-center gap-2 rounded-xl border border-gray-200 px-3 text-sm font-semibold text-gray-700">
<input
type="checkbox"
checked={form.nsfw}
onChange={(event) => set('nsfw', event.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-brand-600"
/>
成人内容
</label>
<label className="md:col-span-2">
<span className="mb-1 block text-xs font-bold text-gray-500">简介</span>
<textarea
value={form.overview}
onChange={(event) => set('overview', event.target.value)}
rows={5}
className="w-full rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-700 outline-none focus:border-brand-300"
/>
</label>
</div>
<div className="flex justify-end gap-2 border-t border-gray-200 px-5 py-4">
<button onClick={onClose} className="btn-outline px-4">取消</button>
<button onClick={save} disabled={saving} className="btn-primary px-5">
<Save size={16} />
保存
</button>
</div>
</div>
</div>
)
}
function Field({
label,
value,
onChange,
placeholder,
inputMode,
}: {
label: string
value: string
onChange: (value: string) => void
placeholder?: string
inputMode?: 'numeric' | 'decimal'
}) {
return (
<label>
<span className="mb-1 block text-xs font-bold text-gray-500">{label}</span>
<input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
inputMode={inputMode}
className="h-11 w-full rounded-xl border border-gray-200 bg-white px-3 text-sm font-semibold text-gray-700 outline-none focus:border-brand-300"
/>
</label>
)
}
+49 -14
View File
@@ -30,7 +30,7 @@ export function APIConfigsPage() {
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">外部 API 配置</h1>
<p className="text-sm text-ink-50">
管理 TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 的密钥。
管理 TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban / Adult 的密钥与源。
后端使用 AES-GCM 加密存储,数据库泄漏时密钥仍然安全。
</p>
</div>
@@ -50,14 +50,17 @@ export function APIConfigsPage() {
function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => void }) {
const [apiKey, setAPIKey] = useState('')
const [baseURL, setBaseURL] = useState(item.base_url ?? '')
const [extra, setExtra] = useState(item.extra ?? '')
const [enabled, setEnabled] = useState(item.enabled)
const [saving, setSaving] = useState(false)
const isAdult = item.provider === 'adult'
const submit = async (e: FormEvent) => {
e.preventDefault()
setSaving(true)
try {
const patch: Record<string, unknown> = { base_url: baseURL, enabled }
if (isAdult) patch.extra = extra
if (apiKey.trim()) patch.api_key = apiKey.trim()
await apiConfigsAPI.update(item.provider, patch)
toast.success(`${item.provider} 已保存`)
@@ -93,32 +96,48 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
<p className="text-xs text-ink-50">{item.description}</p>
)}
<p className="mt-2 text-xs text-sand-500">
状态: {item.has_key ? <span className="text-emerald-400">已配置</span> : <span className="text-sand-500">未配置</span>}
状态: {apiConfigConfigured(item) ? <span className="text-emerald-400">已配置</span> : <span className="text-sand-500">未配置</span>}
{item.has_key && (
<span className="ml-2 font-mono text-brand-500">{item.masked_key}</span>
)}
{isAdult && apiConfigSourceCount(item) > 0 && (
<span className="ml-2 text-brand-500">{apiConfigSourceCount(item)} 个源</span>
)}
</p>
</div>
<div className="space-y-2">
{!isAdult && (
<label className="block text-xs text-ink-50">
API Key (留空保留原值)
<input
className="input-base mt-1"
type="password"
placeholder={item.has_key ? '••••••••••••' : '尚未配置'}
value={apiKey}
onChange={(e) => setAPIKey(e.target.value)}
/>
</label>
)}
<label className="block text-xs text-ink-50">
API Key (留空保留原值)
{isAdult ? '主源 URL' : 'Base URL (可选)'}
<input
className="input-base mt-1"
type="password"
placeholder={item.has_key ? '••••••••••••' : '尚未配置'}
value={apiKey}
onChange={(e) => setAPIKey(e.target.value)}
/>
</label>
<label className="block text-xs text-ink-50">
Base URL (可选)
<input
className="input-base mt-1"
placeholder="https://api.themoviedb.org/3"
placeholder={isAdult ? 'https://javdb.com' : 'https://api.themoviedb.org/3'}
value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
/>
</label>
{isAdult && (
<label className="block text-xs text-ink-50">
备用源 URL
<textarea
className="input-base mt-1 min-h-24 resize-y"
placeholder={'https://javbus.sbs\nhttps://www.javbus.com'}
value={extra}
onChange={(e) => setExtra(e.target.value)}
/>
</label>
)}
<label className="inline-flex items-center gap-2 text-xs text-ink-50">
<input
type="checkbox"
@@ -152,3 +171,19 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
</form>
)
}
function apiConfigConfigured(item: APIConfig): boolean {
if (item.provider === 'adult') {
return Boolean(item.base_url?.trim() || item.extra?.trim())
}
return item.has_key
}
function apiConfigSourceCount(item: APIConfig): number {
if (item.provider !== 'adult') return 0
return [item.base_url, item.extra]
.join('\n')
.split(/[\s,;]+/)
.map((value) => value.trim())
.filter(Boolean).length
}
+1 -1
View File
@@ -220,7 +220,7 @@ export function HomePage() {
key={card.key}
media={card.rep}
count={card.count}
linkTo={card.count > 1 ? seriesCardLink(card) : undefined}
linkTo={seriesCardLink(card)}
/>
))}
</div>
+38 -7
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import toast from 'react-hot-toast'
import { ArrowLeft, Play, Film, Database, FileText, Search, Sparkles, Trash2 } from 'lucide-react'
import { ArrowLeft, Play, Film, Database, FileText, Search, Sparkles, Trash2, Pencil } from 'lucide-react'
import { libraryAPI } from '../api/library'
import { storageAPI, type CloudScanStatus } from '../api/storage_config'
@@ -17,6 +17,7 @@ import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard
import { useWebSocket } from '../hooks/useWebSocket'
import { confirmAction } from '../components/ConfirmDialog'
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
import { MetadataEditDialog } from '../components/MetadataEditDialog'
export function LibraryPage() {
const { id = '' } = useParams()
@@ -33,6 +34,7 @@ export function LibraryPage() {
const [scraping, setScraping] = useState(false)
const [seriesToolBusy, setSeriesToolBusy] = useState('')
const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false)
const [seriesMetadataEditOpen, setSeriesMetadataEditOpen] = useState(false)
const [manualMovie, setManualMovie] = useState<Media | null>(null)
const [movieToolBusy, setMovieToolBusy] = useState('')
@@ -56,7 +58,7 @@ export function LibraryPage() {
// 按季分组,按集排序
const seasons = new Map<number, Media[]>()
for (const ep of eps) {
const s = ep.season_num || 1
const s = ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1)
if (!seasons.has(s)) seasons.set(s, [])
seasons.get(s)!.push(ep)
}
@@ -226,7 +228,11 @@ export function LibraryPage() {
}
const next = seriesCards.find((card) => card.key === key)
if (next) setSelectedSeries(next)
if (next) {
setSelectedSeries(next)
} else {
setSelectedSeries(null)
}
}, [isSeries, loading, searchParams, seriesCards])
useEffect(() => {
@@ -385,6 +391,7 @@ export function LibraryPage() {
const clearSelectedSeries = () => {
setSelectedSeries(null)
setSelectedSeason(null)
setSeriesMetadataEditOpen(false)
const next = new URLSearchParams(searchParams)
next.delete('series')
setSearchParams(next)
@@ -518,6 +525,10 @@ export function LibraryPage() {
<Search size={13} className="text-[#c9954a]" />
<span>手动匹配整剧</span>
</button>
<button onClick={() => setSeriesMetadataEditOpen(true)} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<Pencil size={13} />
<span>编辑元数据</span>
</button>
<button onClick={handleSeriesProbe} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<Database size={13} />
<span>{seriesToolBusy === 'probe' ? '探测中…' : '探测媒体轨'}</span>
@@ -550,14 +561,16 @@ export function LibraryPage() {
: 'border-sand-200 bg-white text-ink-100 hover:border-brand-200 hover:text-brand-600')
}
>
第 {season} 季 · {episodes.length} 集
{season === 0 ? '特别篇' : `第 ${season} 季`} · {episodes.length} 集
</button>
))}
</div>
<div>
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
第 {selectedSeason ?? selectedEpisodes[0]?.season ?? 1} 季
{(selectedSeason ?? selectedEpisodes[0]?.season ?? 1) === 0
? '特别篇'
: `第 ${selectedSeason ?? selectedEpisodes[0]?.season ?? 1} 季`}
</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{visibleEpisodes.map((ep) => (
@@ -566,8 +579,17 @@ export function LibraryPage() {
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
>
<Link to={`/play/${ep.id}`} className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
{ep.episode_num || '—'}
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
{ep.backdrop_url || ep.poster_url ? (
<img
src={imageURL(ep.backdrop_url || ep.poster_url || '')}
alt=""
className="h-full w-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
ep.episode_num || '—'
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-ink-600">
@@ -609,6 +631,15 @@ export function LibraryPage() {
onClose={() => setManualSeriesScrapeOpen(false)}
onApplied={reloadCurrentLibrary}
/>
<MetadataEditDialog
open={seriesMetadataEditOpen}
media={selectedSeries?.rep ?? null}
mediaIds={selectedSeriesMediaIDs}
mode="series"
scopeLabel={selectedSeries ? seriesTitle(selectedSeries.rep) : '当前剧集'}
onClose={() => setSeriesMetadataEditOpen(false)}
onSaved={reloadCurrentLibrary}
/>
<ManualScrapeDialog
open={!!manualMovie}
media={manualMovie}
+18 -3
View File
@@ -1,7 +1,7 @@
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database, Search } from 'lucide-react'
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database, Search, Pencil } from 'lucide-react'
import toast from 'react-hot-toast'
import { mediaAPI } from '../api/library'
@@ -14,6 +14,7 @@ import type { Media } from '../types'
import { confirmAction } from '../components/ConfirmDialog'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
import { MetadataEditDialog } from '../components/MetadataEditDialog'
function fmtDuration(sec: number): string {
if (!sec || sec <= 0) return '—'
@@ -47,6 +48,7 @@ export function MediaDetailPage() {
const [favourite, setFavourite] = useState(false)
const [loading, setLoading] = useState(true)
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
const [metadataEditOpen, setMetadataEditOpen] = useState(false)
const refresh = async () => {
if (!id) return
@@ -136,9 +138,9 @@ export function MediaDetailPage() {
<div className="relative overflow-hidden rounded-3xl bg-white border border-gray-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)]">
{/* ── Cinematic Blurred Backdrop Glow ── */}
<div className="absolute inset-0 h-[480px] z-0 overflow-hidden">
{media.poster_url ? (
{(media.backdrop_url || media.poster_url) ? (
<img
src={imageURL(media.poster_url)}
src={imageURL(media.backdrop_url || media.poster_url || '')}
alt=""
className="w-full h-full object-cover opacity-[0.04] scale-110 blur-2xl"
referrerPolicy="no-referrer"
@@ -321,6 +323,10 @@ export function MediaDetailPage() {
<Search size={13} className="text-[#c9954a]" />
<span>手动匹配刮削</span>
</button>
<button onClick={() => setMetadataEditOpen(true)} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Pencil size={13} className="text-gray-600" />
<span>编辑元数据</span>
</button>
<button onClick={reprobe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Database size={13} className="text-gray-600" />
<span>探测媒体轨 (ffprobe)</span>
@@ -351,6 +357,15 @@ export function MediaDetailPage() {
onClose={() => setManualScrapeOpen(false)}
onApplied={refresh}
/>
<MetadataEditDialog
open={metadataEditOpen}
media={media}
onClose={() => setMetadataEditOpen(false)}
onSaved={async (next) => {
setMedia(next)
await refresh()
}}
/>
</div>
)
}
+7 -3
View File
@@ -26,7 +26,7 @@ export function getSeriesKey(media: Media): string {
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}`
if (media.douban_id) return `douban:${media.douban_id}`
if (media.thetvdb_id) return `thetvdb:${media.thetvdb_id}`
return `lib:${media.library_id}|show:${normalizeTitle(fromPath || seriesTitle(media))}`
return `lib:${targetLibraryID(media)}|show:${normalizeTitle(fromPath || seriesTitle(media))}`
}
if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}`
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}`
@@ -38,6 +38,10 @@ export function isEpisodeLike(media: Media): boolean {
return (media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0
}
export function isSeriesCard(card: SeriesCard): boolean {
return card.count > 1 || isEpisodeLike(card.rep) || isEpisodeLike(card.linkMedia)
}
export function seriesTitle(media: Media): string {
const fromPath = seriesTitleFromPath(media.path)
return fromPath || media.title || media.original_name || '未命名节目'
@@ -95,8 +99,8 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
}
export function seriesCardLink(card: SeriesCard): string {
if (card.count > 1) {
return `/library/${targetLibraryID(card.linkMedia)}`
if (isSeriesCard(card)) {
return `/library/${targetLibraryID(card.linkMedia)}?series=${encodeURIComponent(card.key)}`
}
return `/media/${card.rep.id}`
}