mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
fix: prioritize local poster artwork
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
Reference in New Issue
Block a user