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 ? (
-
+ <>
+ {posterFit === 'contain' && (
+
+ )}
+
{
+ 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"
+ />
+ >
) : (