From c24ebe181d9074c4aa8f5b7ea9e80f46bfd23934 Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Sun, 20 Sep 2026 14:13:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BC=A9=E7=95=A5=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/scraper_write_metadata_files.go | 28 +++++++++++++ .../scraper_write_metadata_files_test.go | 42 +++++++++++++++++++ web/src/api/client.ts | 21 ++++++++++ web/src/components/LayoutHeaderSections.tsx | 4 +- .../components/ManualScrapeDialogSections.tsx | 4 +- web/src/components/MediaCard.tsx | 11 ++--- web/src/pages/AdminLibraryTable.tsx | 4 +- web/src/pages/HomePageSections.tsx | 10 ++--- web/src/pages/LibrarySeriesDetailHeader.tsx | 4 +- web/src/pages/LibrarySeriesEpisodes.tsx | 8 +--- web/src/pages/MediaDetailArtwork.tsx | 14 ++----- web/src/pages/ScraperQueuePage.tsx | 4 +- web/src/pages/WatchHistoryPage.tsx | 4 +- .../pages/scraper-queue/ScrapeDetailModal.tsx | 4 +- 14 files changed, 119 insertions(+), 43 deletions(-) diff --git a/internal/service/scraper_write_metadata_files.go b/internal/service/scraper_write_metadata_files.go index e23a311..5b335ee 100644 --- a/internal/service/scraper_write_metadata_files.go +++ b/internal/service/scraper_write_metadata_files.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "context" "os" "path/filepath" @@ -152,6 +153,13 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data [] return "" } dst := filepath.Join(dir, name+imageExtForContentType(ctype)) + // 内容与现有文件完全一致时不重写:重写会刷新源文件 mtime,而缩略图缓存键 + // 包含源文件大小与 mtime,一次「内容没变」的重新刮削会让该条目所有尺寸的 + // 缩略图一并作废,下次浏览全部重新解码。 + if artworkFileUnchanged(dst, data) { + s.log.Debug("scrape artwork unchanged", zap.String("dst", dst)) + return dst + } tmp, err := os.CreateTemp(dir, "img-*.tmp") if err != nil { s.log.Warn("scrape artwork temp create failed", zap.String("dir", dir), zap.Error(err)) @@ -186,6 +194,26 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data [] return dst } +// artworkFileUnchanged 报告 path 是否已经就是 data 这些字节。 +// +// 刮削在刷新海报地址时会无条件重写 sidecar 图片,即使下载回来的字节一模一样。 +// 缩略图缓存键包含源文件的大小与 mtime,因此这种无谓的重写会让该条目所有尺寸 +// 的缩略图一起失效;保持一致时直接复用原文件可以保住 mtime。 +func artworkFileUnchanged(path string, data []byte) bool { + if len(data) == 0 { + return false + } + info, err := os.Stat(path) + if err != nil || info.IsDir() || info.Size() != int64(len(data)) { + return false + } + existing, err := os.ReadFile(path) // #nosec G304 -- path is built from a sanitized media directory. + if err != nil { + return false + } + return bytes.Equal(existing, data) +} + // imageExtForContentType maps a detected image MIME type to a file extension. // Unknown image types fall back to a generic ".img" so we never write an empty // extension that could confuse media players. diff --git a/internal/service/scraper_write_metadata_files_test.go b/internal/service/scraper_write_metadata_files_test.go index f80e8e7..ba9cfc9 100644 --- a/internal/service/scraper_write_metadata_files_test.go +++ b/internal/service/scraper_write_metadata_files_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/glebarez/sqlite" "go.uber.org/zap" @@ -62,6 +63,47 @@ func TestWriteArtworkDataToPathReplacesExistingSidecar(t *testing.T) { } } +// TestWriteArtworkDataToPathSkipsUnchangedSidecar 验证"内容一致就不重写": +// 缩略图缓存键包含源文件的大小与 mtime,无谓的重写会让该条目所有尺寸的缩略图 +// 一起失效,下次浏览全部重新解码。 +func TestWriteArtworkDataToPathSkipsUnchangedSidecar(t *testing.T) { + scraper := &ScraperService{log: zap.NewNop()} + mediaDir := t.TempDir() + dst := scraper.writeArtworkDataToPath(mediaDir, "poster", "image/jpeg", testJPEG) + if dst == "" { + t.Fatal("expected a written destination path") + } + // 把 mtime 拨回过去,再写入完全相同的字节:文件必须原样保留。 + past := time.Now().Add(-2 * time.Hour) + if err := os.Chtimes(dst, past, past); err != nil { + t.Fatal(err) + } + if got := scraper.writeArtworkDataToPath(mediaDir, "poster", "image/jpeg", testJPEG); got != dst { + t.Fatalf("destination = %q, want %q", got, dst) + } + info, err := os.Stat(dst) + if err != nil { + t.Fatal(err) + } + if time.Since(info.ModTime()) < time.Minute { + t.Fatalf("mtime = %v, want the original past timestamp (identical artwork was rewritten)", info.ModTime()) + } + + // 内容变化时仍然必须覆盖。 + changed := append([]byte(nil), testJPEG...) + changed[len(changed)-1] = 0x00 + if got := scraper.writeArtworkDataToPath(mediaDir, "poster", "image/jpeg", changed); got != dst { + t.Fatalf("destination = %q, want %q", got, dst) + } + data, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(data, changed) { + t.Fatal("changed artwork was not written") + } +} + func TestShouldCropAdultPosterWhenTMDbArtworkMatchedCodePath(t *testing.T) { media := &model.Media{ Path: filepath.Join(t.TempDir(), "IPX-235.mp4"), diff --git a/web/src/api/client.ts b/web/src/api/client.ts index faec0dd..135b898 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -230,6 +230,27 @@ export type ImageURLOptions = quality?: number } +// ARTWORK is the shared set of thumbnail sizes the whole app requests. +// +// The server caches thumbnails per (source file, maxWidth, maxHeight, quality): +// every extra combination is another full decode of the original image (measured +// at 60-120ms for a 1920x1080 backdrop, up to ~800ms for a large poster) plus +// another cache file, so do not invent sizes at the call site — pick a preset. +// Keeping this list short is what stops a single page from triggering a dozen +// different decodes for the same artwork. +export const ARTWORK = { + /** 最小一档(160px):卡片的模糊占位图,以及 30-40px 级别的列表缩略图。 */ + posterTiny: { maxWidth: 160, quality: 60 }, + /** 海报列表与卡片(2:3 容器,紧凑布局共用同一份缓存)。 */ + posterCard: { maxWidth: 480, maxHeight: 600, quality: 80 }, + /** 海报详情页、剧集详情页头部与首页大图。 */ + posterDetail: { maxWidth: 560, maxHeight: 840, quality: 80 }, + /** 剧照大图:首页 hero、影片详情页背景。 */ + backdropHero: { maxWidth: 1920, maxHeight: 1080, quality: 80 }, + /** 剧照小图:媒体库封面、剧集条目、搜索结果等。 */ + backdropStrip: { maxWidth: 480, maxHeight: 320, quality: 80 }, +} + export function imageURL(remote?: string, version?: string, options: ImageURLOptions = false): string { if (!remote) return '' const versionQuery = version ? `v=${encodeURIComponent(version)}` : '' diff --git a/web/src/components/LayoutHeaderSections.tsx b/web/src/components/LayoutHeaderSections.tsx index e9204c3..1470db3 100644 --- a/web/src/components/LayoutHeaderSections.tsx +++ b/web/src/components/LayoutHeaderSections.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom' import { Link, useLocation, useNavigate } from 'react-router-dom' import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react' -import { imageURL } from '../api/client' +import { ARTWORK, imageURL } from '../api/client' import { mediaAPI } from '../api/library' import type { Media, PlayProfile, User } from '../types' import { favouriteMediaLink } from '../utils/mediaNavigation' @@ -368,7 +368,7 @@ function LayoutHeaderSearch() {