mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 04:16:38 +08:00
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:
@@ -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}` : '',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user