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
|
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"),
|
||||||
|
|||||||
@@ -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)}` : ''
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,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"
|
||||||
|
|||||||
@@ -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,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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user