fix: refresh library artwork previews

This commit is contained in:
ShukeBta
2026-05-29 10:13:34 +08:00
parent bbf8f41507
commit 00b9c1bb79
4 changed files with 228 additions and 91 deletions
+6
View File
@@ -267,6 +267,12 @@ func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
updates["nsfw"] = true updates["nsfw"] = true
} }
} }
if m.PosterURL != "" {
updates["poster_url"] = m.PosterURL
}
if m.BackdropURL != "" {
updates["backdrop_url"] = m.BackdropURL
}
if lib := m.LibraryID; lib != "" && lib != existing.LibraryID { if lib := m.LibraryID; lib != "" && lib != existing.LibraryID {
updates["library_id"] = m.LibraryID updates["library_id"] = m.LibraryID
} }
+49
View File
@@ -491,6 +491,55 @@ func TestScanLibraryDoesNotMarkArtworkOnlyAsMatched(t *testing.T) {
} }
} }
func TestScanLibraryRefreshesArtworkOnlyMetadata(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "Movie.mkv")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
oldPoster := filepath.Join(root, "Movie-thumb.jpg")
if err := os.WriteFile(oldPoster, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
lib := model.Library{Name: "Movies", Path: root, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
if _, err := scanner.ScanLibrary(t.Context(), lib.ID); err != nil {
t.Fatal(err)
}
newPoster := filepath.Join(root, "poster.jpg")
if err := os.WriteFile(newPoster, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := scanner.ScanLibrary(t.Context(), lib.ID); err != nil {
t.Fatal(err)
}
var media model.Media
if err := db.First(&media, "path = ?", mediaPath).Error; err != nil {
t.Fatal(err)
}
if media.PosterURL != newPoster {
t.Fatalf("poster_url = %q, want refreshed local poster %q", media.PosterURL, newPoster)
}
if media.ScrapeStatus == "matched" {
t.Fatalf("artwork-only refresh should keep media enrichable, got %q", media.ScrapeStatus)
}
}
func TestScanLibraryParsesEpisodesForMovieTypedLibrary(t *testing.T) { func TestScanLibraryParsesEpisodesForMovieTypedLibrary(t *testing.T) {
root := t.TempDir() root := t.TempDir()
seasonDir := filepath.Join(root, "哈哈哈哈哈 (2020)", "Season 06") seasonDir := filepath.Join(root, "哈哈哈哈哈 (2020)", "Season 06")
+157 -87
View File
@@ -1,11 +1,13 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react' import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { ArrowRight, Film, Library as LibraryIcon, Music, PlayCircle, Tv } from 'lucide-react' import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle, Tv } from 'lucide-react'
import { imageURL } from '../api/client' import { imageURL } from '../api/client'
import { libraryAPI } from '../api/library' import { libraryAPI } from '../api/library'
import { MediaCard } from '../components/MediaCard'
import type { Library, Media } from '../types' import type { Library, Media } from '../types'
import { artworkScore, groupSeries, type SeriesCard } from '../utils/groupSeries'
type LibraryPreview = { type LibraryPreview = {
library: Library library: Library
@@ -13,12 +15,19 @@ type LibraryPreview = {
total: number total: number
} }
type CategorySummary = {
type: string
libraries: number
total: number
items: Media[]
}
const TYPE_ICONS: Record<string, ReactNode> = { const TYPE_ICONS: Record<string, ReactNode> = {
movie: <Film size={20} />, movie: <Film size={18} />,
tv: <Tv size={20} />, tv: <Tv size={18} />,
anime: <PlayCircle size={20} />, anime: <PlayCircle size={18} />,
variety: <Tv size={20} />, variety: <Tv size={18} />,
music: <Music size={20} />, music: <Music size={18} />,
} }
const TYPE_LABELS: Record<string, string> = { const TYPE_LABELS: Record<string, string> = {
@@ -30,11 +39,11 @@ const TYPE_LABELS: Record<string, string> = {
} }
const TYPE_DESCRIPTIONS: Record<string, string> = { const TYPE_DESCRIPTIONS: Record<string, string> = {
movie: '电影、动画电影、纪录片电影', movie: '电影 / 动画电影 / 纪录片',
tv: '电视剧、短剧、纪录片剧集', tv: '国产剧 / 欧美剧 / 日韩剧',
anime: '番剧、动画、OVA 与剧场版', anime: '国漫 / 日番 / OVA',
variety: '综艺、真人秀、晚会与节目合集', variety: '综艺 / 真人秀 / 晚会',
music: '音乐、MV、演唱会与音频内容', music: '音乐 / MV / 演唱会',
} }
export function LibrariesPage() { export function LibrariesPage() {
@@ -50,7 +59,7 @@ export function LibrariesPage() {
const libs = await libraryAPI.list() const libs = await libraryAPI.list()
const rows = await Promise.all(libs.map(async (library) => { const rows = await Promise.all(libs.map(async (library) => {
try { try {
const page = await libraryAPI.listMedia(library.id, 1, 16) const page = await libraryAPI.listMedia(library.id, 1, 120)
return { library, items: page.items, total: page.total } satisfies LibraryPreview return { library, items: page.items, total: page.total } satisfies LibraryPreview
} catch { } catch {
return { library, items: [], total: 0 } satisfies LibraryPreview return { library, items: [], total: 0 } satisfies LibraryPreview
@@ -66,12 +75,13 @@ export function LibrariesPage() {
}, []) }, [])
const categories = useMemo(() => { const categories = useMemo(() => {
const map = new Map<string, { type: string; libraries: number; total: number }>() const map = new Map<string, CategorySummary>()
for (const preview of previews) { for (const preview of previews) {
const type = preview.library.type || 'other' const type = preview.library.type || 'other'
const current = map.get(type) ?? { type, libraries: 0, total: 0 } const current = map.get(type) ?? { type, libraries: 0, total: 0, items: [] }
current.libraries += 1 current.libraries += 1
current.total += preview.total current.total += preview.total
current.items.push(...preview.items)
map.set(type, current) map.set(type, current)
} }
return Array.from(map.values()).sort((a, b) => b.total - a.total) return Array.from(map.values()).sort((a, b) => b.total - a.total)
@@ -81,6 +91,17 @@ export function LibrariesPage() {
? previews ? previews
: previews.filter((preview) => preview.library.type === activeType) : previews.filter((preview) => preview.library.type === activeType)
const activeItems = useMemo(() => filtered.flatMap((preview) => preview.items), [filtered])
const latestCards = useMemo(() => {
return groupSeries(activeItems)
.sort((a, b) => mediaTime(b.rep) - mediaTime(a.rep))
.slice(0, 9)
}, [activeItems])
const activeLabel = activeType === 'all'
? '全部媒体'
: TYPE_LABELS[activeType] ?? activeType
if (loading) { if (loading) {
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p> return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
} }
@@ -90,7 +111,7 @@ export function LibrariesPage() {
<div className="flex flex-wrap items-end justify-between gap-4"> <div className="flex flex-wrap items-end justify-between gap-4">
<div> <div>
<h1 className="font-display text-3xl font-bold text-ink-600">媒体库</h1> <h1 className="font-display text-3xl font-bold text-ink-600">媒体库</h1>
<p className="mt-1 text-sm text-ink-50">按影视分类浏览所有目录;每个文件夹会自动使用库内海报生成封面。</p> <p className="mt-1 text-sm text-ink-50">先看每类最新入库,再通过目录入口进入完整文件夹。</p>
</div> </div>
<Link to="/admin" className="btn-outline"> <Link to="/admin" className="btn-outline">
管理媒体库 管理媒体库
@@ -105,41 +126,81 @@ export function LibrariesPage() {
</div> </div>
) : ( ) : (
<> <>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5"> <section className="overflow-x-auto pb-2">
<CategoryCard <div className="flex min-w-full gap-3">
active={activeType === 'all'}
icon={<LibraryIcon size={20} />}
label="全部媒体"
description="电影、剧集、番剧、综艺统一视图"
total={previews.reduce((sum, preview) => sum + preview.total, 0)}
libraries={previews.length}
onClick={() => setActiveType('all')}
/>
{categories.map((category) => (
<CategoryCard <CategoryCard
key={category.type} active={activeType === 'all'}
active={activeType === category.type} icon={<LibraryIcon size={18} />}
icon={TYPE_ICONS[category.type] ?? <LibraryIcon size={20} />} label="全部媒体"
label={TYPE_LABELS[category.type] ?? category.type} description="统一浏览"
description={TYPE_DESCRIPTIONS[category.type] ?? '自定义媒体分类'} total={previews.reduce((sum, preview) => sum + preview.total, 0)}
total={category.total} libraries={previews.length}
libraries={category.libraries} onClick={() => setActiveType('all')}
onClick={() => setActiveType(category.type)}
/> />
))} {categories.map((category) => (
<CategoryCard
key={category.type}
active={activeType === category.type}
icon={TYPE_ICONS[category.type] ?? <LibraryIcon size={18} />}
label={TYPE_LABELS[category.type] ?? category.type}
description={TYPE_DESCRIPTIONS[category.type] ?? '自定义分类'}
total={category.total}
libraries={category.libraries}
onClick={() => setActiveType(category.type)}
/>
))}
</div>
</section> </section>
<section className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3"> <section className="space-y-4">
{filtered.map((preview, index) => ( <div className="flex items-center justify-between gap-3">
<motion.div <div>
key={preview.library.id} <h2 className="font-display text-2xl font-bold text-ink-600">{activeLabel} · 最近更新</h2>
initial={{ opacity: 0, y: 12 }} <p className="text-sm text-ink-50">显示前 9 部最新入库或更新的影片 / 剧集合集。</p>
animate={{ opacity: 1, y: 0 }} </div>
transition={{ delay: index * 0.03 }} {filtered.length === 1 && (
> <Link to={`/library/${filtered[0].library.id}`} className="btn-outline shrink-0">
<LibraryCard preview={preview} /> 浏览全部
</motion.div> <ArrowRight size={14} />
))} </Link>
)}
</div>
{latestCards.length > 0 ? (
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 lg:grid-cols-6 2xl:grid-cols-9">
{latestCards.map((card) => (
<MediaCard
key={card.key}
media={card.rep}
count={card.count}
linkTo={mediaCardLink(card)}
/>
))}
</div>
) : (
<div className="rounded-3xl border border-dashed border-sand-200 bg-white px-6 py-12 text-center text-sm text-ink-50">
当前分类还没有入库内容,扫描媒体库后会显示在这里。
</div>
)}
</section>
<section className="space-y-4">
<div>
<h2 className="font-display text-2xl font-bold text-ink-600">目录入口</h2>
<p className="text-sm text-ink-50">按你添加的媒体库文件夹进入,适合继续按文件分类查找。</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((preview, index) => (
<motion.div
key={preview.library.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
>
<LibraryFolderCard preview={preview} />
</motion.div>
))}
</div>
</section> </section>
</> </>
)} )}
@@ -169,76 +230,85 @@ function CategoryCard({
type="button" type="button"
onClick={onClick} onClick={onClick}
className={ className={
'rounded-3xl border p-4 text-left transition-all ' + 'min-w-[9.5rem] shrink-0 rounded-2xl border p-3 text-left transition-all lg:w-[calc((100%-3.75rem)/6)] ' +
(active (active
? 'border-brand-300 bg-brand-50 shadow-card' ? 'border-brand-300 bg-brand-50 shadow-card'
: 'border-sand-200 bg-white hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card') : 'border-sand-200 bg-white hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card')
} }
> >
<div className="mb-4 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white text-brand-600 shadow-sm"> <span className="flex h-9 w-9 items-center justify-center rounded-xl bg-white text-brand-600 shadow-sm">
{icon} {icon}
</span> </span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-bold text-sand-600"> <span className="rounded-full bg-white px-2 py-0.5 text-[10px] font-bold text-sand-600">
{libraries} 库 {libraries} 库
</span> </span>
</div> </div>
<h2 className="font-display text-lg font-bold text-ink-600">{label}</h2> <h2 className="font-display text-base font-bold text-ink-600">{label}</h2>
<p className="mt-1 line-clamp-2 text-xs text-ink-50">{description}</p> <p className="mt-0.5 truncate text-[11px] text-ink-50">{description}</p>
<p className="mt-4 text-xs font-bold text-brand-600">{total.toLocaleString()} 个条目</p> <p className="mt-2 text-xs font-bold text-brand-600">{total.toLocaleString()} 项</p>
</button> </button>
) )
} }
function LibraryCard({ preview }: { preview: LibraryPreview }) { function LibraryFolderCard({ preview }: { preview: LibraryPreview }) {
const library = preview.library const library = preview.library
const artwork = preview.items const artwork = groupSeries(preview.items)
.map((item) => item.poster_url || item.backdrop_url) .sort((a, b) => artworkScore(b.rep) - artworkScore(a.rep) || mediaTime(b.rep) - mediaTime(a.rep))
.map((card) => card.rep.poster_url || card.rep.backdrop_url)
.filter(Boolean) .filter(Boolean)
.slice(0, 6) as string[] .slice(0, 4) as string[]
return ( return (
<Link <Link
to={`/library/${library.id}`} to={`/library/${library.id}`}
className="group block overflow-hidden rounded-[1.7rem] border border-sand-200 bg-white shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover" className="group flex overflow-hidden rounded-3xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
> >
<div className="relative aspect-[16/9] overflow-hidden bg-[linear-gradient(135deg,#fff7ed,#f8fafc)]"> <div className="grid h-24 w-36 shrink-0 grid-cols-2 gap-1 overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)]">
{artwork.length > 0 ? ( {artwork.length > 0 ? (
<div className="grid h-full grid-cols-3 gap-1 p-1.5"> artwork.map((src, index) => (
{artwork.map((src, index) => ( <img
<img key={`${src}-${index}`}
key={`${src}-${index}`} src={imageURL(src)}
src={imageURL(src)} alt=""
alt="" loading="lazy"
loading="lazy" referrerPolicy="no-referrer"
referrerPolicy="no-referrer" className="h-full w-full object-cover"
className={`h-full w-full rounded-xl object-cover ${index === 0 ? 'col-span-2 row-span-2' : ''}`} onError={(event) => { event.currentTarget.style.visibility = 'hidden' }}
onError={(event) => { event.currentTarget.style.visibility = 'hidden' }} />
/> ))
))}
</div>
) : ( ) : (
<div className="flex h-full items-center justify-center text-brand-500"> <div className="col-span-2 flex h-full items-center justify-center text-brand-500">
{TYPE_ICONS[library.type] ?? <LibraryIcon size={42} />} {TYPE_ICONS[library.type] ?? <FolderOpen size={34} />}
</div> </div>
)} )}
<div className="absolute inset-0 bg-gradient-to-t from-ink-600/70 via-ink-600/10 to-transparent" /> </div>
<div className="absolute left-4 top-4 rounded-full bg-white/90 px-3 py-1 text-xs font-bold text-ink-600 shadow-sm backdrop-blur"> <div className="flex min-w-0 flex-1 flex-col justify-between px-4 py-1">
{TYPE_LABELS[library.type] ?? library.type} <div>
</div> <div className="mb-1 inline-flex rounded-full bg-sand-100 px-2 py-0.5 text-[10px] font-bold text-sand-600">
<div className="absolute bottom-4 left-4 right-4"> {TYPE_LABELS[library.type] ?? library.type}
<h2 className="font-display text-2xl font-black text-white drop-shadow-sm group-hover:text-brand-100"> </div>
<h2 className="truncate font-display text-xl font-black text-ink-600 group-hover:text-brand-600">
{library.name} {library.name}
</h2> </h2>
<p className="mt-1 text-xs font-semibold text-white/85">{preview.total.toLocaleString()} 个媒体条目</p> <p className="mt-1 line-clamp-1 break-all text-xs text-ink-50">{library.path}</p>
</div>
<div className="flex items-center justify-between text-xs font-bold">
<span className="text-sand-600">{preview.total.toLocaleString()} 个条目</span>
<span className="text-brand-600">进入文件夹</span>
</div> </div>
</div>
<div className="flex items-center justify-between gap-3 p-5">
<p className="line-clamp-1 break-all text-xs text-ink-50">{library.path}</p>
<span className="shrink-0 rounded-xl bg-sand-100 px-3 py-1 text-xs font-bold text-sand-600 transition-colors group-hover:bg-brand-100 group-hover:text-brand-700">
进入
</span>
</div> </div>
</Link> </Link>
) )
} }
function mediaCardLink(card: SeriesCard): string {
if (card.count > 1) {
return `/library/${card.rep.library_id}?series=${encodeURIComponent(card.key)}`
}
return `/media/${card.rep.id}`
}
function mediaTime(media: Media): number {
return Date.parse(media.updated_at || media.created_at || '') || 0
}
+16 -4
View File
@@ -68,11 +68,11 @@ export function groupSeries(items: Media[]): SeriesCard[] {
groups.set(key, { key, rep: m, count: 1 }) groups.set(key, { key, rep: m, count: 1 })
} else { } else {
g.count += 1 g.count += 1
const repHasPoster = !!g.rep.poster_url const currentArtwork = artworkScore(m)
const curHasPoster = !!m.poster_url const representativeArtwork = artworkScore(g.rep)
if (!repHasPoster && curHasPoster) { if (currentArtwork > representativeArtwork) {
g.rep = m g.rep = m
} else if (repHasPoster === curHasPoster) { } else if (currentArtwork === representativeArtwork) {
const cur = (m.season_num ?? 0) * 10000 + (m.episode_num ?? 0) const cur = (m.season_num ?? 0) * 10000 + (m.episode_num ?? 0)
const rep = (g.rep.season_num ?? 0) * 10000 + (g.rep.episode_num ?? 0) const rep = (g.rep.season_num ?? 0) * 10000 + (g.rep.episode_num ?? 0)
if (cur > 0 && (rep === 0 || cur < rep)) g.rep = m if (cur > 0 && (rep === 0 || cur < rep)) g.rep = m
@@ -81,3 +81,15 @@ export function groupSeries(items: Media[]): SeriesCard[] {
} }
return Array.from(groups.values()) return Array.from(groups.values())
} }
export function artworkScore(media: Media): number {
const poster = (media.poster_url ?? '').toLowerCase()
const backdrop = (media.backdrop_url ?? '').toLowerCase()
if (poster) {
if (/(poster|folder|cover|movie|show|pl)(?:[._-]|\.[a-z0-9]+$|$)/.test(poster)) return 40
if (/(actor|actress|cast|avatar|sample|screenshot|screen|still|scene|fanart|backdrop|background|landscape|banner|logo|disc)/.test(poster)) return 10
if (/(thumb)/.test(poster)) return 20
return 30
}
return backdrop ? 5 : 0
}