From dfe2b3f017a21d829e9c6287d36d46dd815ac91f Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Fri, 29 May 2026 09:52:16 +0800 Subject: [PATCH] fix: prioritize local poster artwork --- internal/service/local_metadata.go | 129 +++++++++++++++++++++--- internal/service/local_metadata_test.go | 57 ++++++++++- web/src/components/MediaCard.tsx | 41 ++++++-- 3 files changed, 205 insertions(+), 22 deletions(-) diff --git a/internal/service/local_metadata.go b/internal/service/local_metadata.go index fb6ed58..5a3f396 100644 --- a/internal/service/local_metadata.go +++ b/internal/service/local_metadata.go @@ -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() diff --git a/internal/service/local_metadata_test.go b/internal/service/local_metadata_test.go index 3ee453c..42d4efd 100644 --- a/internal/service/local_metadata_test.go +++ b/internal/service/local_metadata_test.go @@ -91,6 +91,10 @@ func TestReadLocalVarietyMetadataUsesLocalArtwork(t *testing.T) { if err := os.WriteFile(filepath.Join(showDir, "哈哈哈哈哈.nfo"), []byte(`哈哈哈哈哈综艺`), 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") diff --git a/web/src/components/MediaCard.tsx b/web/src/components/MediaCard.tsx index 11587a5..ba868f7 100644 --- a/web/src/components/MediaCard.tsx +++ b/web/src/components/MediaCard.tsx @@ -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(null) const href = linkTo ?? `/media/${media.id}` + const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover') + + useEffect(() => { + setPosterFit('cover') + }, [media.poster_url]) const card = ( {media.poster_url ? ( - {media.title} + <> + {posterFit === 'contain' && ( + + )} + {media.title} { + 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" + /> + ) : (