fix: prioritize local poster artwork

This commit is contained in:
ShukeBta
2026-05-29 09:52:16 +08:00
parent 12e12bfa28
commit dfe2b3f017
3 changed files with 205 additions and 22 deletions
+117 -12
View File
@@ -3,6 +3,10 @@ package service
import (
"encoding/xml"
"errors"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/url"
"os"
"path/filepath"
@@ -41,6 +45,11 @@ type nfoFanart struct {
Thumbs []string `xml:"thumb"`
}
type nfoThumb struct {
Aspect string `xml:"aspect,attr"`
Value string `xml:",chardata"`
}
type nfoArt struct {
Poster string `xml:"poster"`
Thumb string `xml:"thumb"`
@@ -68,7 +77,7 @@ type nfoDocument struct {
OriginalPlot string `xml:"originalplot"`
Rating float32 `xml:"rating"`
Poster string `xml:"poster"`
Thumbs []string `xml:"thumb"`
Thumbs []nfoThumb `xml:"thumb"`
Fanart nfoFanart `xml:"fanart"`
Art nfoArt `xml:"art"`
TMDbID int `xml:"tmdbid"`
@@ -245,8 +254,8 @@ func metadataFromDoc(doc *nfoDocument, baseDir string, seriesLike bool) *LocalMe
Year: doc.Year,
Overview: firstText(doc.Plot, doc.Outline, doc.OriginalPlot),
Rating: doc.Rating,
PosterURL: firstRemoteURL(baseDir, append([]string{doc.Poster, doc.Art.Poster, doc.Art.Thumb}, doc.Thumbs...)...),
BackdropURL: firstRemoteURL(baseDir, append([]string{doc.Fanart.Value, doc.Art.Fanart, doc.Art.Backdrop, doc.Art.Background, doc.Art.Banner, doc.Art.Landscape}, doc.Fanart.Thumbs...)...),
PosterURL: firstRemoteURL(baseDir, nfoPosterValues(doc)...),
BackdropURL: firstRemoteURL(baseDir, nfoBackdropValues(doc)...),
TMDbID: doc.TMDbID,
SeasonNum: doc.Season,
EpisodeNum: doc.Episode,
@@ -320,7 +329,7 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
return
}
mediaDir := filepath.Dir(mediaPath)
if localPoster := firstExistingImage(mediaDir, localPosterCandidates(mediaPath)...); localPoster != "" {
if localPoster := firstLocalPoster(mediaPath, showBaseDir); localPoster != "" {
meta.PosterURL = localPoster
} else if meta.PosterURL == "" {
meta.PosterURL = firstAdultLooseImage(mediaDir, "poster")
@@ -434,19 +443,19 @@ func firstRemoteURL(baseDir string, values ...string) string {
func localPosterCandidates(mediaPath string) []string {
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
names := []string{
base,
base + "-thumb",
base + ".thumb",
base + "-cover",
base + ".cover",
base + "-poster",
base + ".poster",
"thumb",
"poster",
"folder",
"cover",
"movie",
"show",
base + "-cover",
base + ".cover",
base,
base + "-thumb",
base + ".thumb",
"thumb",
}
return append(adultArtworkNameCandidates(mediaPath, "poster"), names...)
}
@@ -523,6 +532,69 @@ func firstExistingImage(dir string, names ...string) string {
return ""
}
func nfoPosterValues(doc *nfoDocument) []string {
if doc == nil {
return nil
}
values := []string{doc.Poster, doc.Art.Poster}
for _, thumb := range doc.Thumbs {
aspect := strings.ToLower(strings.TrimSpace(thumb.Aspect))
if aspect == "" || aspect == "poster" || aspect == "cover" || aspect == "default" {
values = append(values, thumb.Value)
}
}
values = append(values, doc.Art.Thumb)
return values
}
func nfoBackdropValues(doc *nfoDocument) []string {
if doc == nil {
return nil
}
values := []string{doc.Fanart.Value, doc.Art.Fanart, doc.Art.Backdrop, doc.Art.Background, doc.Art.Landscape, doc.Art.Banner}
for _, thumb := range doc.Thumbs {
aspect := strings.ToLower(strings.TrimSpace(thumb.Aspect))
if aspect == "fanart" || aspect == "backdrop" || aspect == "background" || aspect == "landscape" {
values = append(values, thumb.Value)
}
}
values = append(values, doc.Fanart.Thumbs...)
return values
}
func firstLocalPoster(mediaPath, showBaseDir string) string {
mediaDir := filepath.Dir(mediaPath)
dirs := []string{}
if showBaseDir != "" && !samePath(showBaseDir, mediaDir) {
dirs = append(dirs, showBaseDir)
}
dirs = append(dirs, mediaDir)
for _, dir := range dirs {
if localPoster := firstExistingPosterImage(dir, localPosterCandidates(mediaPath)...); localPoster != "" {
return localPoster
}
}
return ""
}
func firstExistingPosterImage(dir string, names ...string) string {
if dir == "" {
return ""
}
for _, name := range names {
if isRejectedPosterName(name) {
continue
}
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
path := filepath.Join(dir, name+ext)
if fileExists(path) && likelyPosterImage(path) {
return filepath.Clean(path)
}
}
}
return ""
}
func firstAdultLooseImage(dir, kind string) string {
if dir == "" {
return ""
@@ -537,13 +609,18 @@ func firstAdultLooseImage(dir, kind string) string {
}
name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), ext))
if kind == "poster" {
if strings.Contains(name, "poster") || strings.Contains(name, "cover") || strings.Contains(name, "thumb") || strings.HasSuffix(name, "pl") {
if isRejectedPosterName(name) {
continue
}
if strings.Contains(name, "poster") || strings.Contains(name, "cover") || strings.Contains(name, "folder") || strings.Contains(name, "movie") || strings.HasSuffix(name, "pl") {
preferred = append(preferred, path)
}
} else if strings.Contains(name, "fanart") || strings.Contains(name, "backdrop") || strings.Contains(name, "background") || strings.Contains(name, "landscape") || strings.Contains(name, "jp") {
preferred = append(preferred, path)
}
fallback = append(fallback, path)
if kind != "poster" || likelyPosterImage(path) {
fallback = append(fallback, path)
}
}
if len(preferred) > 0 {
return filepath.Clean(preferred[0])
@@ -554,6 +631,34 @@ func firstAdultLooseImage(dir, kind string) string {
return ""
}
func isRejectedPosterName(name string) bool {
name = strings.ToLower(name)
rejected := []string{
"actor", "actors", "actress", "cast", "avatar", "portrait", "person",
"sample", "screenshot", "screen", "still", "scene", "extrafanart", "extrathumb",
"fanart", "backdrop", "background", "landscape", "banner", "clearlogo", "clearart", "logo", "disc",
}
for _, token := range rejected {
if strings.Contains(name, token) {
return true
}
}
return false
}
func likelyPosterImage(path string) bool {
file, err := os.Open(path)
if err != nil {
return false
}
defer file.Close()
cfg, _, err := image.DecodeConfig(file)
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
return true
}
return cfg.Height >= cfg.Width
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
+55 -2
View File
@@ -91,6 +91,10 @@ func TestReadLocalVarietyMetadataUsesLocalArtwork(t *testing.T) {
if err := os.WriteFile(filepath.Join(showDir, "哈哈哈哈哈.nfo"), []byte(`<tvshow><title>哈哈哈哈哈</title><genre>综艺</genre></tvshow>`), 0o644); err != nil {
t.Fatal(err)
}
showPoster := filepath.Join(showDir, "poster.jpg")
if err := os.WriteFile(showPoster, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
episodeThumb := filepath.Join(seasonDir, "哈哈哈哈哈 - S06E17-thumb.jpg")
if err := os.WriteFile(episodeThumb, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
@@ -110,14 +114,63 @@ func TestReadLocalVarietyMetadataUsesLocalArtwork(t *testing.T) {
if got.Title != "哈哈哈哈哈" || got.Genres != "综艺" {
t.Fatalf("unexpected metadata: %+v", got)
}
if got.PosterURL != episodeThumb {
t.Fatalf("PosterURL = %q, want %q", got.PosterURL, episodeThumb)
if got.PosterURL != showPoster {
t.Fatalf("PosterURL = %q, want show poster %q, not episode thumb %q", got.PosterURL, showPoster, episodeThumb)
}
if got.BackdropURL != backdrop {
t.Fatalf("BackdropURL = %q, want %q", got.BackdropURL, backdrop)
}
}
func TestReadLocalMetadataPrioritizesPosterOverThumbAndStills(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)
}
thumb := filepath.Join(root, "Movie-thumb.jpg")
if err := os.WriteFile(thumb, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
still := filepath.Join(root, "Movie-still.jpg")
if err := os.WriteFile(still, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
poster := filepath.Join(root, "poster.jpg")
if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(mediaPath, root, false)
if err != nil {
t.Fatal(err)
}
if got == nil || got.PosterURL != poster {
t.Fatalf("PosterURL = %q, want poster %q", got.PosterURL, poster)
}
}
func TestReadLocalMetadataIgnoresActorAndStillArtworkOnly(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "SSIS-001.mp4")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
for _, name := range []string{"actor.jpg", "sample.jpg", "fanart.jpg"} {
if err := os.WriteFile(filepath.Join(root, name), []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
}
got, err := ReadLocalMetadata(mediaPath, root, false)
if err != nil {
t.Fatal(err)
}
if got == nil || got.PosterURL != "" || got.BackdropURL == "" {
t.Fatalf("expected backdrop-only metadata without actor/still poster, got %+v", got)
}
}
func TestReadLocalMetadataWithoutNFOStillFindsArtwork(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "Movie.mkv")
+33 -8
View File
@@ -1,4 +1,4 @@
import { useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Film, Play, Layers, Star } from 'lucide-react'
@@ -17,6 +17,11 @@ export const MediaCard = ({
}) => {
const ref = useRef<HTMLDivElement>(null)
const href = linkTo ?? `/media/${media.id}`
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
useEffect(() => {
setPosterFit('cover')
}, [media.poster_url])
const card = (
<motion.div
@@ -28,13 +33,33 @@ export const MediaCard = ({
{/* Poster Wrapper */}
<div className="relative aspect-[2/3] w-full overflow-hidden bg-gray-50">
{media.poster_url ? (
<img
src={imageURL(media.poster_url)}
alt={media.title}
loading="lazy"
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
referrerPolicy="no-referrer"
/>
<>
{posterFit === 'contain' && (
<img
src={imageURL(media.poster_url)}
alt=""
aria-hidden="true"
loading="lazy"
className="absolute inset-0 h-full w-full scale-110 object-cover object-center opacity-25 blur-xl"
referrerPolicy="no-referrer"
/>
)}
<img
src={imageURL(media.poster_url)}
alt={media.title}
loading="lazy"
decoding="async"
onLoad={(event) => {
const img = event.currentTarget
setPosterFit(img.naturalWidth > img.naturalHeight ? 'contain' : 'cover')
}}
className={
'relative block h-full w-full object-center transition-transform duration-700 ease-out group-hover:scale-105 ' +
(posterFit === 'contain' ? 'object-contain p-1.5' : 'object-cover')
}
referrerPolicy="no-referrer"
/>
</>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-gray-500 bg-gray-50">
<Film size={28} className="stroke-[1.5]" />