优化缩略图

This commit is contained in:
truewhile
2026-09-20 14:13:49 +08:00
parent 9b2b592003
commit c24ebe181d
14 changed files with 119 additions and 43 deletions
@@ -1,6 +1,7 @@
package service package service
import ( import (
"bytes"
"context" "context"
"os" "os"
"path/filepath" "path/filepath"
@@ -152,6 +153,13 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []
return "" return ""
} }
dst := filepath.Join(dir, name+imageExtForContentType(ctype)) 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") tmp, err := os.CreateTemp(dir, "img-*.tmp")
if err != nil { if err != nil {
s.log.Warn("scrape artwork temp create failed", zap.String("dir", dir), zap.Error(err)) 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 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. // 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 // Unknown image types fall back to a generic ".img" so we never write an empty
// extension that could confuse media players. // extension that could confuse media players.
@@ -9,6 +9,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"go.uber.org/zap" "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) { func TestShouldCropAdultPosterWhenTMDbArtworkMatchedCodePath(t *testing.T) {
media := &model.Media{ media := &model.Media{
Path: filepath.Join(t.TempDir(), "IPX-235.mp4"), Path: filepath.Join(t.TempDir(), "IPX-235.mp4"),
+21
View File
@@ -230,6 +230,27 @@ export type ImageURLOptions =
quality?: number 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 { export function imageURL(remote?: string, version?: string, options: ImageURLOptions = false): string {
if (!remote) return '' if (!remote) return ''
const versionQuery = version ? `v=${encodeURIComponent(version)}` : '' const versionQuery = version ? `v=${encodeURIComponent(version)}` : ''
+2 -2
View File
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
import { Link, useLocation, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react' 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 { mediaAPI } from '../api/library'
import type { Media, PlayProfile, User } from '../types' import type { Media, PlayProfile, User } from '../types'
import { favouriteMediaLink } from '../utils/mediaNavigation' import { favouriteMediaLink } from '../utils/mediaNavigation'
@@ -368,7 +368,7 @@ function LayoutHeaderSearch() {
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]"> <div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
{item.poster_url ? ( {item.poster_url ? (
<img <img
src={imageURL(item.poster_url, item.updated_at)} src={imageURL(item.poster_url, item.updated_at, ARTWORK.posterCard)}
alt="" alt=""
className="h-full w-full object-cover" className="h-full w-full object-cover"
loading="lazy" loading="lazy"
@@ -1,6 +1,6 @@
import { Check, LoaderCircle, Search, Sparkles, X } from 'lucide-react' import { Check, LoaderCircle, Search, Sparkles, X } from 'lucide-react'
import { imageURL } from '../api/client' import { ARTWORK, imageURL } from '../api/client'
import type { ManualScrapeCandidate } from '../api/library' import type { ManualScrapeCandidate } from '../api/library'
import { import {
candidateIDText, candidateIDText,
@@ -202,7 +202,7 @@ function ManualScrapeCandidateRow({
<div className="flex min-w-0 max-w-full flex-col gap-4 overflow-hidden rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row"> <div className="flex min-w-0 max-w-full flex-col gap-4 overflow-hidden rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row">
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100"> <div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
{item.poster_url ? ( {item.poster_url ? (
<img src={imageURL(item.poster_url)} alt={item.title} loading="lazy" decoding="async" className="h-full w-full object-cover" referrerPolicy="no-referrer" /> <img src={imageURL(item.poster_url, undefined, ARTWORK.posterCard)} alt={item.title} loading="lazy" decoding="async" className="h-full w-full object-cover" referrerPolicy="no-referrer" />
) : ( ) : (
<div className="flex h-full items-center justify-center text-xs text-sand-500">无海报</div> <div className="flex h-full items-center justify-center text-xs text-sand-500">无海报</div>
)} )}
+4 -7
View File
@@ -1,7 +1,7 @@
import { memo, useEffect, useRef, useState, type ReactNode } from 'react' import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Film, Play, Layers, Star } from 'lucide-react' import { Film, Play, Layers, Star } from 'lucide-react'
import { imageURL } from '../api/client' import { ARTWORK, imageURL } from '../api/client'
import type { Media } from '../types' import type { Media } from '../types'
const ACTION_OVERLAY_CLASS = const ACTION_OVERLAY_CLASS =
@@ -26,12 +26,9 @@ export const MediaCard = memo(function MediaCard({
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const href = linkTo ?? `/media/${media.id}` const href = linkTo ?? `/media/${media.id}`
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover') const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
const posterSrc = imageURL(media.poster_url, media.updated_at, { // 固定档位而不是在调用点拼尺寸:紧凑布局与普通卡片共用同一份缩略图缓存。
maxWidth: compact ? 320 : 480, const posterSrc = imageURL(media.poster_url, media.updated_at, ARTWORK.posterCard)
maxHeight: compact ? 480 : 600, const blurredPosterSrc = imageURL(media.poster_url, media.updated_at, ARTWORK.posterTiny)
quality: 82,
})
const blurredPosterSrc = imageURL(media.poster_url, media.updated_at, { maxWidth: 160, quality: 60 })
const displayRating = rating ?? media.rating const displayRating = rating ?? media.rating
const versionCount = media.versions?.length ?? 0 const versionCount = media.versions?.length ?? 0
// renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效 // renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效
+2 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type DragEvent, type MouseEvent, type Reac
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Folder, GripVertical, Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react' import { Folder, GripVertical, Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
import { imageURL } from '../api/client' import { ARTWORK, imageURL } from '../api/client'
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog' import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
import type { Library, LibraryRoot } from '../types' import type { Library, LibraryRoot } from '../types'
import type { RootDraft } from './adminLibraryPanelModel' import type { RootDraft } from './adminLibraryPanelModel'
@@ -172,7 +172,7 @@ function LibraryTableRow({ library, dragging, dragOver, onDragStart, onDragOver,
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{library.cover_url && ( {library.cover_url && (
<img <img
src={imageURL(library.cover_url, library.updated_at)} src={imageURL(library.cover_url, library.updated_at, ARTWORK.backdropStrip)}
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async" decoding="async"
+5 -5
View File
@@ -17,7 +17,7 @@ import {
Tv, Tv,
} from 'lucide-react' } from 'lucide-react'
import { imageURL } from '../api/client' import { ARTWORK, imageURL } from '../api/client'
import { useInViewOnce } from '../hooks/useInViewOnce' import { useInViewOnce } from '../hooks/useInViewOnce'
import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch' import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch'
import { useRememberedListPosition } from '../hooks/useListPositionMemory' import { useRememberedListPosition } from '../hooks/useListPositionMemory'
@@ -171,7 +171,7 @@ export function HomeCarouselSection({
{visual && ( {visual && (
<img <img
key={visual + currentItem.id} key={visual + currentItem.id}
src={imageURL(visual, currentItem.updated_at, { maxWidth: 1920, maxHeight: 1080, quality: 78 })} src={imageURL(visual, currentItem.updated_at, ARTWORK.backdropHero)}
alt="" alt=""
fetchPriority="high" fetchPriority="high"
decoding="async" decoding="async"
@@ -283,7 +283,7 @@ export function HomeCarouselSection({
</div> </div>
{poster && ( {poster && (
<img <img
src={imageURL(poster, currentItem.updated_at, { maxWidth: 560, quality: 84 })} src={imageURL(poster, currentItem.updated_at, ARTWORK.posterDetail)}
alt={currentItem.title} alt={currentItem.title}
fetchPriority="high" fetchPriority="high"
decoding="async" decoding="async"
@@ -516,7 +516,7 @@ export function HomeLibraryCard({
src={imageURL( src={imageURL(
src, src,
version, version,
library.cover_url ? undefined : { maxWidth: 480, maxHeight: 320, quality: 78 }, library.cover_url ? undefined : ARTWORK.backdropStrip,
)} )}
alt="" alt=""
loading="lazy" loading="lazy"
@@ -750,7 +750,7 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-xl bg-[var(--app-panel-soft)]"> <div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-xl bg-[var(--app-panel-soft)]">
{media.poster_url ? ( {media.poster_url ? (
<img <img
src={imageURL(media.poster_url, media.updated_at, { maxWidth: 180, maxHeight: 240, quality: 78 })} src={imageURL(media.poster_url, media.updated_at, ARTWORK.posterCard)}
alt="" alt=""
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy" loading="lazy"
+2 -2
View File
@@ -2,7 +2,7 @@ import { isRemoteEmbyID } from '../utils/remoteEmby'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ArrowLeft, Database, FileText, Film, FolderInput, Pencil, Play, Search, Sparkles, Trash2 } from 'lucide-react' import { ArrowLeft, Database, FileText, Film, FolderInput, Pencil, Play, Search, Sparkles, Trash2 } from 'lucide-react'
import { imageURL } from '../api/client' import { ARTWORK, imageURL } from '../api/client'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton' import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { MediaFavouriteButton } from '../components/MediaFavouriteButton' import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
import type { Media } from '../types' import type { Media } from '../types'
@@ -70,7 +70,7 @@ export function LibrarySeriesDetailHeader({
<div className="w-40 shrink-0 overflow-hidden rounded-xl bg-sand-200 shadow-card"> <div className="w-40 shrink-0 overflow-hidden rounded-xl bg-sand-200 shadow-card">
{series.rep.poster_url ? ( {series.rep.poster_url ? (
<img <img
src={imageURL(series.rep.poster_url, series.rep.updated_at)} src={imageURL(series.rep.poster_url, series.rep.updated_at, ARTWORK.posterDetail)}
alt={series.rep.title} alt={series.rep.title}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
+2 -6
View File
@@ -1,7 +1,7 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Play, Search } from 'lucide-react' import { Play, Search } from 'lucide-react'
import { imageURL } from '../api/client' import { ARTWORK, imageURL } from '../api/client'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton' import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import type { Media } from '../types' import type { Media } from '../types'
import { import {
@@ -87,11 +87,7 @@ export function LibrarySeriesEpisodes({
> >
{ep.backdrop_url || ep.poster_url ? ( {ep.backdrop_url || ep.poster_url ? (
<img <img
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at, { src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at, ARTWORK.backdropStrip)}
maxWidth: 320,
maxHeight: 180,
quality: 76,
})}
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async" decoding="async"
+3 -11
View File
@@ -2,7 +2,7 @@ import { motion } from 'framer-motion'
import { FileText, Play } from 'lucide-react' import { FileText, Play } from 'lucide-react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { imageURL } from '../api/client' import { ARTWORK, imageURL } from '../api/client'
import type { Media } from '../types' import type { Media } from '../types'
type MediaDetailArtworkProps = { type MediaDetailArtworkProps = {
@@ -14,11 +14,7 @@ export function MediaDetailBackdrop({ media }: MediaDetailArtworkProps) {
<div className="absolute inset-0 h-[480px] z-0 overflow-hidden"> <div className="absolute inset-0 h-[480px] z-0 overflow-hidden">
{media.backdrop_url || media.poster_url ? ( {media.backdrop_url || media.poster_url ? (
<img <img
src={imageURL(media.backdrop_url || media.poster_url || '', media.updated_at, { src={imageURL(media.backdrop_url || media.poster_url || '', media.updated_at, ARTWORK.backdropHero)}
maxWidth: 1920,
maxHeight: 1080,
quality: 76,
})}
alt="" alt=""
decoding="async" decoding="async"
fetchPriority="low" fetchPriority="low"
@@ -42,11 +38,7 @@ export function MediaDetailPoster({ media }: MediaDetailArtworkProps) {
> >
{media.poster_url ? ( {media.poster_url ? (
<img <img
src={imageURL(media.poster_url, media.updated_at, { src={imageURL(media.poster_url, media.updated_at, ARTWORK.posterDetail)}
maxWidth: 560,
maxHeight: 840,
quality: 84,
})}
alt={media.title} alt={media.title}
decoding="async" decoding="async"
fetchPriority="high" fetchPriority="high"
+2 -2
View File
@@ -19,7 +19,7 @@ import {
X, X,
} from 'lucide-react' } from 'lucide-react'
import { imageURL } from '../api/client' import { ARTWORK, imageURL } from '../api/client'
import { scraperAPI } from '../api/scraper' import { scraperAPI } from '../api/scraper'
import type { ScrapeQueueSnapshot, ScrapeTask, ScrapeTaskStatus } from '../types/scraper' import type { ScrapeQueueSnapshot, ScrapeTask, ScrapeTaskStatus } from '../types/scraper'
import { apiErrorMessage, formatTime, taskStatusMeta } from './StrmManagePage' import { apiErrorMessage, formatTime, taskStatusMeta } from './StrmManagePage'
@@ -756,7 +756,7 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{task.poster_url ? ( {task.poster_url ? (
<img <img
src={imageURL(task.poster_url)} src={imageURL(task.poster_url, undefined, ARTWORK.posterTiny)}
alt="" alt=""
className="h-10 w-7 rounded object-cover border border-gray-200 shrink-0" className="h-10 w-7 rounded object-cover border border-gray-200 shrink-0"
onError={(e) => { onError={(e) => {
+2 -2
View File
@@ -4,7 +4,7 @@ import { Clock, Play, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { historyAPI } from '../api/history' import { historyAPI } from '../api/history'
import { imageURL } from '../api/client' import { ARTWORK, imageURL } from '../api/client'
import { confirmAction } from '../components/confirmAction' import { confirmAction } from '../components/confirmAction'
import { isRemoteEmbyID } from '../utils/remoteEmby' import { isRemoteEmbyID } from '../utils/remoteEmby'
import type { HistoryItem, Media } from '../types' import type { HistoryItem, Media } from '../types'
@@ -117,7 +117,7 @@ export function WatchHistoryPage() {
<div className="h-16 w-12 shrink-0 overflow-hidden rounded-lg bg-surface-900"> <div className="h-16 w-12 shrink-0 overflow-hidden rounded-lg bg-surface-900">
{m.poster_url ? ( {m.poster_url ? (
<img <img
src={imageURL(m.poster_url, m.updated_at)} src={imageURL(m.poster_url, m.updated_at, ARTWORK.posterCard)}
alt={m.title} alt={m.title}
className="h-full w-full object-cover" className="h-full w-full object-cover"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
@@ -1,7 +1,7 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { AlertCircle, Ban, Copy, ExternalLink, RefreshCw, Sparkles, Trash2, X } from 'lucide-react' import { AlertCircle, Ban, Copy, ExternalLink, RefreshCw, Sparkles, Trash2, X } from 'lucide-react'
import { imageURL } from '../../api/client' import { ARTWORK, imageURL } from '../../api/client'
import type { ScrapeTask } from '../../types/scraper' import type { ScrapeTask } from '../../types/scraper'
import { formatTime, taskStatusMeta } from '../StrmManagePage' import { formatTime, taskStatusMeta } from '../StrmManagePage'
import { PROVIDER_LABELS, TYPE_LABELS } from './scrapeLabels' import { PROVIDER_LABELS, TYPE_LABELS } from './scrapeLabels'
@@ -52,7 +52,7 @@ export function ScrapeDetailModal({
<div className="flex gap-4 rounded-2xl border border-brand-500/20 bg-primary-400/5 p-4"> <div className="flex gap-4 rounded-2xl border border-brand-500/20 bg-primary-400/5 p-4">
{task.poster_url && ( {task.poster_url && (
<img <img
src={imageURL(task.poster_url)} src={imageURL(task.poster_url, undefined, ARTWORK.posterTiny)}
alt="" alt=""
className="h-28 w-20 rounded-xl object-cover border border-brand-500/30 shadow-md shrink-0" className="h-28 w-20 rounded-xl object-cover border border-brand-500/30 shadow-md shrink-0"
/> />