diff --git a/internal/repository/repository.go b/internal/repository/repository.go index bef5201..1856b3e 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -267,6 +267,12 @@ func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error { 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 { updates["library_id"] = m.LibraryID } diff --git a/internal/service/local_metadata_test.go b/internal/service/local_metadata_test.go index 42d4efd..08b613c 100644 --- a/internal/service/local_metadata_test.go +++ b/internal/service/local_metadata_test.go @@ -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) { root := t.TempDir() seasonDir := filepath.Join(root, "哈哈哈哈哈 (2020)", "Season 06") diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index f7ec3d4..b5461a8 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -1,11 +1,13 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react' import { Link } from 'react-router-dom' 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 { libraryAPI } from '../api/library' +import { MediaCard } from '../components/MediaCard' import type { Library, Media } from '../types' +import { artworkScore, groupSeries, type SeriesCard } from '../utils/groupSeries' type LibraryPreview = { library: Library @@ -13,12 +15,19 @@ type LibraryPreview = { total: number } +type CategorySummary = { + type: string + libraries: number + total: number + items: Media[] +} + const TYPE_ICONS: Record = { - movie: , - tv: , - anime: , - variety: , - music: , + movie: , + tv: , + anime: , + variety: , + music: , } const TYPE_LABELS: Record = { @@ -30,11 +39,11 @@ const TYPE_LABELS: Record = { } const TYPE_DESCRIPTIONS: Record = { - movie: '电影、动画电影、纪录片电影', - tv: '电视剧、短剧、纪录片剧集', - anime: '番剧、动画、OVA 与剧场版', - variety: '综艺、真人秀、晚会与节目合集', - music: '音乐、MV、演唱会与音频内容', + movie: '电影 / 动画电影 / 纪录片', + tv: '国产剧 / 欧美剧 / 日韩剧', + anime: '国漫 / 日番 / OVA', + variety: '综艺 / 真人秀 / 晚会', + music: '音乐 / MV / 演唱会', } export function LibrariesPage() { @@ -50,7 +59,7 @@ export function LibrariesPage() { const libs = await libraryAPI.list() const rows = await Promise.all(libs.map(async (library) => { 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 } catch { return { library, items: [], total: 0 } satisfies LibraryPreview @@ -66,12 +75,13 @@ export function LibrariesPage() { }, []) const categories = useMemo(() => { - const map = new Map() + const map = new Map() for (const preview of previews) { 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.total += preview.total + current.items.push(...preview.items) map.set(type, current) } return Array.from(map.values()).sort((a, b) => b.total - a.total) @@ -81,6 +91,17 @@ export function LibrariesPage() { ? previews : 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) { return

媒体库加载中…

} @@ -90,7 +111,7 @@ export function LibrariesPage() {

媒体库

-

按影视分类浏览所有目录;每个文件夹会自动使用库内海报生成封面。

+

先看每类最新入库,再通过目录入口进入完整文件夹。

管理媒体库 @@ -105,41 +126,81 @@ export function LibrariesPage() {
) : ( <> -
- } - label="全部媒体" - description="电影、剧集、番剧、综艺统一视图" - total={previews.reduce((sum, preview) => sum + preview.total, 0)} - libraries={previews.length} - onClick={() => setActiveType('all')} - /> - {categories.map((category) => ( +
+
} - label={TYPE_LABELS[category.type] ?? category.type} - description={TYPE_DESCRIPTIONS[category.type] ?? '自定义媒体分类'} - total={category.total} - libraries={category.libraries} - onClick={() => setActiveType(category.type)} + active={activeType === 'all'} + icon={} + label="全部媒体" + description="统一浏览" + total={previews.reduce((sum, preview) => sum + preview.total, 0)} + libraries={previews.length} + onClick={() => setActiveType('all')} /> - ))} + {categories.map((category) => ( + } + label={TYPE_LABELS[category.type] ?? category.type} + description={TYPE_DESCRIPTIONS[category.type] ?? '自定义分类'} + total={category.total} + libraries={category.libraries} + onClick={() => setActiveType(category.type)} + /> + ))} +
-
- {filtered.map((preview, index) => ( - - - - ))} +
+
+
+

{activeLabel} · 最近更新

+

显示前 9 部最新入库或更新的影片 / 剧集合集。

+
+ {filtered.length === 1 && ( + + 浏览全部 + + + )} +
+ + {latestCards.length > 0 ? ( +
+ {latestCards.map((card) => ( + + ))} +
+ ) : ( +
+ 当前分类还没有入库内容,扫描媒体库后会显示在这里。 +
+ )} +
+ +
+
+

目录入口

+

按你添加的媒体库文件夹进入,适合继续按文件分类查找。

+
+
+ {filtered.map((preview, index) => ( + + + + ))} +
)} @@ -169,76 +230,85 @@ function CategoryCard({ type="button" onClick={onClick} 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 ? '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') } > -
- +
+ {icon} - + {libraries} 库
-

{label}

-

{description}

-

{total.toLocaleString()} 个条目

+

{label}

+

{description}

+

{total.toLocaleString()} 项

) } -function LibraryCard({ preview }: { preview: LibraryPreview }) { +function LibraryFolderCard({ preview }: { preview: LibraryPreview }) { const library = preview.library - const artwork = preview.items - .map((item) => item.poster_url || item.backdrop_url) + const artwork = groupSeries(preview.items) + .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) - .slice(0, 6) as string[] + .slice(0, 4) as string[] return ( -
+
{artwork.length > 0 ? ( -
- {artwork.map((src, index) => ( - { event.currentTarget.style.visibility = 'hidden' }} - /> - ))} -
+ artwork.map((src, index) => ( + { event.currentTarget.style.visibility = 'hidden' }} + /> + )) ) : ( -
- {TYPE_ICONS[library.type] ?? } +
+ {TYPE_ICONS[library.type] ?? }
)} -
-
- {TYPE_LABELS[library.type] ?? library.type} -
-
-

+

+
+
+
+ {TYPE_LABELS[library.type] ?? library.type} +
+

{library.name}

-

{preview.total.toLocaleString()} 个媒体条目

+

{library.path}

+
+
+ {preview.total.toLocaleString()} 个条目 + 进入文件夹
-
-
-

{library.path}

- - 进入 -
) } + +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 +} diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index 01c4e9c..58e2afc 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -68,11 +68,11 @@ export function groupSeries(items: Media[]): SeriesCard[] { groups.set(key, { key, rep: m, count: 1 }) } else { g.count += 1 - const repHasPoster = !!g.rep.poster_url - const curHasPoster = !!m.poster_url - if (!repHasPoster && curHasPoster) { + const currentArtwork = artworkScore(m) + const representativeArtwork = artworkScore(g.rep) + if (currentArtwork > representativeArtwork) { g.rep = m - } else if (repHasPoster === curHasPoster) { + } else if (currentArtwork === representativeArtwork) { const cur = (m.season_num ?? 0) * 10000 + (m.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 @@ -81,3 +81,15 @@ export function groupSeries(items: Media[]): SeriesCard[] { } 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 +}