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 ( import (
"encoding/xml" "encoding/xml"
"errors" "errors"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
@@ -41,6 +45,11 @@ type nfoFanart struct {
Thumbs []string `xml:"thumb"` Thumbs []string `xml:"thumb"`
} }
type nfoThumb struct {
Aspect string `xml:"aspect,attr"`
Value string `xml:",chardata"`
}
type nfoArt struct { type nfoArt struct {
Poster string `xml:"poster"` Poster string `xml:"poster"`
Thumb string `xml:"thumb"` Thumb string `xml:"thumb"`
@@ -68,7 +77,7 @@ type nfoDocument struct {
OriginalPlot string `xml:"originalplot"` OriginalPlot string `xml:"originalplot"`
Rating float32 `xml:"rating"` Rating float32 `xml:"rating"`
Poster string `xml:"poster"` Poster string `xml:"poster"`
Thumbs []string `xml:"thumb"` Thumbs []nfoThumb `xml:"thumb"`
Fanart nfoFanart `xml:"fanart"` Fanart nfoFanart `xml:"fanart"`
Art nfoArt `xml:"art"` Art nfoArt `xml:"art"`
TMDbID int `xml:"tmdbid"` TMDbID int `xml:"tmdbid"`
@@ -245,8 +254,8 @@ func metadataFromDoc(doc *nfoDocument, baseDir string, seriesLike bool) *LocalMe
Year: doc.Year, Year: doc.Year,
Overview: firstText(doc.Plot, doc.Outline, doc.OriginalPlot), Overview: firstText(doc.Plot, doc.Outline, doc.OriginalPlot),
Rating: doc.Rating, Rating: doc.Rating,
PosterURL: firstRemoteURL(baseDir, append([]string{doc.Poster, doc.Art.Poster, doc.Art.Thumb}, doc.Thumbs...)...), PosterURL: firstRemoteURL(baseDir, nfoPosterValues(doc)...),
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...)...), BackdropURL: firstRemoteURL(baseDir, nfoBackdropValues(doc)...),
TMDbID: doc.TMDbID, TMDbID: doc.TMDbID,
SeasonNum: doc.Season, SeasonNum: doc.Season,
EpisodeNum: doc.Episode, EpisodeNum: doc.Episode,
@@ -320,7 +329,7 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
return return
} }
mediaDir := filepath.Dir(mediaPath) mediaDir := filepath.Dir(mediaPath)
if localPoster := firstExistingImage(mediaDir, localPosterCandidates(mediaPath)...); localPoster != "" { if localPoster := firstLocalPoster(mediaPath, showBaseDir); localPoster != "" {
meta.PosterURL = localPoster meta.PosterURL = localPoster
} else if meta.PosterURL == "" { } else if meta.PosterURL == "" {
meta.PosterURL = firstAdultLooseImage(mediaDir, "poster") meta.PosterURL = firstAdultLooseImage(mediaDir, "poster")
@@ -434,19 +443,19 @@ func firstRemoteURL(baseDir string, values ...string) string {
func localPosterCandidates(mediaPath string) []string { func localPosterCandidates(mediaPath string) []string {
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath)) base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
names := []string{ names := []string{
base,
base + "-thumb",
base + ".thumb",
base + "-cover",
base + ".cover",
base + "-poster", base + "-poster",
base + ".poster", base + ".poster",
"thumb",
"poster", "poster",
"folder", "folder",
"cover", "cover",
"movie", "movie",
"show", "show",
base + "-cover",
base + ".cover",
base,
base + "-thumb",
base + ".thumb",
"thumb",
} }
return append(adultArtworkNameCandidates(mediaPath, "poster"), names...) return append(adultArtworkNameCandidates(mediaPath, "poster"), names...)
} }
@@ -523,6 +532,69 @@ func firstExistingImage(dir string, names ...string) string {
return "" 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 { func firstAdultLooseImage(dir, kind string) string {
if dir == "" { if dir == "" {
return "" return ""
@@ -537,13 +609,18 @@ func firstAdultLooseImage(dir, kind string) string {
} }
name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), ext)) name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), ext))
if kind == "poster" { 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) 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") { } 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) preferred = append(preferred, path)
} }
fallback = append(fallback, path) if kind != "poster" || likelyPosterImage(path) {
fallback = append(fallback, path)
}
} }
if len(preferred) > 0 { if len(preferred) > 0 {
return filepath.Clean(preferred[0]) return filepath.Clean(preferred[0])
@@ -554,6 +631,34 @@ func firstAdultLooseImage(dir, kind string) string {
return "" 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 { func fileExists(path string) bool {
info, err := os.Stat(path) info, err := os.Stat(path)
return err == nil && !info.IsDir() 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 { if err := os.WriteFile(filepath.Join(showDir, "哈哈哈哈哈.nfo"), []byte(`<tvshow><title>哈哈哈哈哈</title><genre>综艺</genre></tvshow>`), 0o644); err != nil {
t.Fatal(err) 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") episodeThumb := filepath.Join(seasonDir, "哈哈哈哈哈 - S06E17-thumb.jpg")
if err := os.WriteFile(episodeThumb, []byte("jpg"), 0o644); err != nil { if err := os.WriteFile(episodeThumb, []byte("jpg"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -110,14 +114,63 @@ func TestReadLocalVarietyMetadataUsesLocalArtwork(t *testing.T) {
if got.Title != "哈哈哈哈哈" || got.Genres != "综艺" { if got.Title != "哈哈哈哈哈" || got.Genres != "综艺" {
t.Fatalf("unexpected metadata: %+v", got) t.Fatalf("unexpected metadata: %+v", got)
} }
if got.PosterURL != episodeThumb { if got.PosterURL != showPoster {
t.Fatalf("PosterURL = %q, want %q", got.PosterURL, episodeThumb) t.Fatalf("PosterURL = %q, want show poster %q, not episode thumb %q", got.PosterURL, showPoster, episodeThumb)
} }
if got.BackdropURL != backdrop { if got.BackdropURL != backdrop {
t.Fatalf("BackdropURL = %q, want %q", 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) { func TestReadLocalMetadataWithoutNFOStillFindsArtwork(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mediaPath := filepath.Join(root, "Movie.mkv") 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 { Link } from 'react-router-dom'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Film, Play, Layers, Star } from 'lucide-react' import { Film, Play, Layers, Star } from 'lucide-react'
@@ -17,6 +17,11 @@ export const 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')
useEffect(() => {
setPosterFit('cover')
}, [media.poster_url])
const card = ( const card = (
<motion.div <motion.div
@@ -28,13 +33,33 @@ export const MediaCard = ({
{/* Poster Wrapper */} {/* Poster Wrapper */}
<div className="relative aspect-[2/3] w-full overflow-hidden bg-gray-50"> <div className="relative aspect-[2/3] w-full overflow-hidden bg-gray-50">
{media.poster_url ? ( {media.poster_url ? (
<img <>
src={imageURL(media.poster_url)} {posterFit === 'contain' && (
alt={media.title} <img
loading="lazy" src={imageURL(media.poster_url)}
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105" alt=""
referrerPolicy="no-referrer" 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"> <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]" /> <Film size={28} className="stroke-[1.5]" />