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