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