mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
优化缩略图
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)}` : ''
|
||||
|
||||
@@ -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() {
|
||||
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={imageURL(item.poster_url, item.updated_at)}
|
||||
src={imageURL(item.poster_url, item.updated_at, ARTWORK.posterCard)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 {
|
||||
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="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Film, Play, Layers, Star } from 'lucide-react'
|
||||
import { imageURL } from '../api/client'
|
||||
import { ARTWORK, imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
const ACTION_OVERLAY_CLASS =
|
||||
@@ -26,12 +26,9 @@ export const MediaCard = memo(function MediaCard({
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const href = linkTo ?? `/media/${media.id}`
|
||||
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
|
||||
const posterSrc = imageURL(media.poster_url, media.updated_at, {
|
||||
maxWidth: compact ? 320 : 480,
|
||||
maxHeight: compact ? 480 : 600,
|
||||
quality: 82,
|
||||
})
|
||||
const blurredPosterSrc = imageURL(media.poster_url, media.updated_at, { maxWidth: 160, quality: 60 })
|
||||
// 固定档位而不是在调用点拼尺寸:紧凑布局与普通卡片共用同一份缩略图缓存。
|
||||
const posterSrc = imageURL(media.poster_url, media.updated_at, ARTWORK.posterCard)
|
||||
const blurredPosterSrc = imageURL(media.poster_url, media.updated_at, ARTWORK.posterTiny)
|
||||
const displayRating = rating ?? media.rating
|
||||
const versionCount = media.versions?.length ?? 0
|
||||
// renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type DragEvent, type MouseEvent, type Reac
|
||||
import { createPortal } from 'react-dom'
|
||||
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 type { Library, LibraryRoot } from '../types'
|
||||
import type { RootDraft } from './adminLibraryPanelModel'
|
||||
@@ -172,7 +172,7 @@ function LibraryTableRow({ library, dragging, dragOver, onDragStart, onDragOver,
|
||||
<div className="flex items-center gap-2">
|
||||
{library.cover_url && (
|
||||
<img
|
||||
src={imageURL(library.cover_url, library.updated_at)}
|
||||
src={imageURL(library.cover_url, library.updated_at, ARTWORK.backdropStrip)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Tv,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { ARTWORK, imageURL } from '../api/client'
|
||||
import { useInViewOnce } from '../hooks/useInViewOnce'
|
||||
import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch'
|
||||
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
|
||||
@@ -171,7 +171,7 @@ export function HomeCarouselSection({
|
||||
{visual && (
|
||||
<img
|
||||
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=""
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
@@ -283,7 +283,7 @@ export function HomeCarouselSection({
|
||||
</div>
|
||||
{poster && (
|
||||
<img
|
||||
src={imageURL(poster, currentItem.updated_at, { maxWidth: 560, quality: 84 })}
|
||||
src={imageURL(poster, currentItem.updated_at, ARTWORK.posterDetail)}
|
||||
alt={currentItem.title}
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
@@ -516,7 +516,7 @@ export function HomeLibraryCard({
|
||||
src={imageURL(
|
||||
src,
|
||||
version,
|
||||
library.cover_url ? undefined : { maxWidth: 480, maxHeight: 320, quality: 78 },
|
||||
library.cover_url ? undefined : ARTWORK.backdropStrip,
|
||||
)}
|
||||
alt=""
|
||||
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)]">
|
||||
{media.poster_url ? (
|
||||
<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=""
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import { Link } from 'react-router-dom'
|
||||
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 { MediaFavouriteButton } from '../components/MediaFavouriteButton'
|
||||
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">
|
||||
{series.rep.poster_url ? (
|
||||
<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}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Play, Search } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { ARTWORK, imageURL } from '../api/client'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import type { Media } from '../types'
|
||||
import {
|
||||
@@ -87,11 +87,7 @@ export function LibrarySeriesEpisodes({
|
||||
>
|
||||
{ep.backdrop_url || ep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at, {
|
||||
maxWidth: 320,
|
||||
maxHeight: 180,
|
||||
quality: 76,
|
||||
})}
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at, ARTWORK.backdropStrip)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { motion } from 'framer-motion'
|
||||
import { FileText, Play } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { ARTWORK, imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
type MediaDetailArtworkProps = {
|
||||
@@ -14,11 +14,7 @@ export function MediaDetailBackdrop({ media }: MediaDetailArtworkProps) {
|
||||
<div className="absolute inset-0 h-[480px] z-0 overflow-hidden">
|
||||
{media.backdrop_url || media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.backdrop_url || media.poster_url || '', media.updated_at, {
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1080,
|
||||
quality: 76,
|
||||
})}
|
||||
src={imageURL(media.backdrop_url || media.poster_url || '', media.updated_at, ARTWORK.backdropHero)}
|
||||
alt=""
|
||||
decoding="async"
|
||||
fetchPriority="low"
|
||||
@@ -42,11 +38,7 @@ export function MediaDetailPoster({ media }: MediaDetailArtworkProps) {
|
||||
>
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url, media.updated_at, {
|
||||
maxWidth: 560,
|
||||
maxHeight: 840,
|
||||
quality: 84,
|
||||
})}
|
||||
src={imageURL(media.poster_url, media.updated_at, ARTWORK.posterDetail)}
|
||||
alt={media.title}
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { ARTWORK, imageURL } from '../api/client'
|
||||
import { scraperAPI } from '../api/scraper'
|
||||
import type { ScrapeQueueSnapshot, ScrapeTask, ScrapeTaskStatus } from '../types/scraper'
|
||||
import { apiErrorMessage, formatTime, taskStatusMeta } from './StrmManagePage'
|
||||
@@ -756,7 +756,7 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
<div className="flex items-center gap-2">
|
||||
{task.poster_url ? (
|
||||
<img
|
||||
src={imageURL(task.poster_url)}
|
||||
src={imageURL(task.poster_url, undefined, ARTWORK.posterTiny)}
|
||||
alt=""
|
||||
className="h-10 w-7 rounded object-cover border border-gray-200 shrink-0"
|
||||
onError={(e) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Clock, Play, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { historyAPI } from '../api/history'
|
||||
import { imageURL } from '../api/client'
|
||||
import { ARTWORK, imageURL } from '../api/client'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
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">
|
||||
{m.poster_url ? (
|
||||
<img
|
||||
src={imageURL(m.poster_url, m.updated_at)}
|
||||
src={imageURL(m.poster_url, m.updated_at, ARTWORK.posterCard)}
|
||||
alt={m.title}
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
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 { formatTime, taskStatusMeta } from '../StrmManagePage'
|
||||
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">
|
||||
{task.poster_url && (
|
||||
<img
|
||||
src={imageURL(task.poster_url)}
|
||||
src={imageURL(task.poster_url, undefined, ARTWORK.posterTiny)}
|
||||
alt=""
|
||||
className="h-28 w-20 rounded-xl object-cover border border-brand-500/30 shadow-md shrink-0"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user