diff --git a/internal/service/local_metadata_adult_test.go b/internal/service/local_metadata_adult_test.go index cfd9a3b..98cc1a7 100644 --- a/internal/service/local_metadata_adult_test.go +++ b/internal/service/local_metadata_adult_test.go @@ -184,6 +184,85 @@ func TestReadAdultMetadataDerivesDMMPosterFromRemoteFanart(t *testing.T) { } } +func TestReadAdultArtworkIgnoresLoosePosterInLibraryRoot(t *testing.T) { + root := t.TempDir() + // 云下载这类库把不同影片平铺在同一个根目录里,根目录中残留的松散海报 + // 属于别的影片,不能被抓来当作当前影片的封面。 + if err := os.WriteFile(filepath.Join(root, "AAA-001-poster.jpg"), []byte("jpg"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "AAA-001-fanart.jpg"), []byte("jpg"), 0o644); err != nil { + t.Fatal(err) + } + mediaPath := filepath.Join(root, "BBB-002.mp4") + if err := os.WriteFile(mediaPath, []byte("x"), 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("library-root artwork must not be inherited: poster=%q backdrop=%q", got.PosterURL, got.BackdropURL) + } +} + +func TestReadAdultArtworkIgnoresLoosePosterInSharedFolder(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "shared") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"AAA-001.mp4", "BBB-002.mp4"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "AAA-001-poster.jpg"), []byte("jpg"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "AAA-001-fanart.jpg"), []byte("jpg"), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ReadLocalMetadata(filepath.Join(dir, "BBB-002.mp4"), root, false) + if err != nil { + t.Fatal(err) + } + if got != nil && (got.PosterURL != "" || got.BackdropURL != "") { + t.Fatalf("sibling artwork must not be inherited in a shared folder: poster=%q backdrop=%q", got.PosterURL, got.BackdropURL) + } +} + +func TestReadAdultArtworkKeepsLooseFallbackInItemFolder(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "BBB-002") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + mediaPath := filepath.Join(dir, "video.mp4") + if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + poster := filepath.Join(dir, "japanese-title-cover.jpg") + fanart := filepath.Join(dir, "japanese-title-fanart.jpg") + if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fanart, []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 || got.BackdropURL != fanart { + t.Fatalf("single-item folder should keep loose artwork fallback: %+v", got) + } +} + func TestReadAdultArtworkByCodeWithoutNFO(t *testing.T) { root := t.TempDir() mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4") diff --git a/internal/service/local_metadata_artwork.go b/internal/service/local_metadata_artwork.go index e31d38a..839e353 100644 --- a/internal/service/local_metadata_artwork.go +++ b/internal/service/local_metadata_artwork.go @@ -11,23 +11,23 @@ import ( "strings" ) -func metadataFromArtwork(mediaPath, showBaseDir string) *LocalMetadata { +func metadataFromArtwork(mediaPath, showBaseDir, libraryRoot string) *LocalMetadata { meta := &LocalMetadata{} - mergeArtworkMetadata(meta, mediaPath, showBaseDir) + mergeArtworkMetadata(meta, mediaPath, showBaseDir, libraryRoot) if meta.PosterURL == "" && meta.BackdropURL == "" { return nil } return meta } -func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) { +func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir, libraryRoot string) { if meta == nil { return } mediaDir := filepath.Dir(mediaPath) if localPoster := firstLocalPoster(mediaPath, showBaseDir); localPoster != "" { meta.PosterURL = localPoster - } else if meta.PosterURL == "" { + } else if meta.PosterURL == "" && looseArtworkFallbackAllowed(mediaDir, libraryRoot) { meta.PosterURL = firstAdultLooseImage(mediaDir, "poster") } dirs := []string{mediaDir, showBaseDir} @@ -39,7 +39,7 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) { meta.BackdropURL = img break } - if meta.BackdropURL == "" { + if meta.BackdropURL == "" && looseArtworkFallbackAllowed(dir, libraryRoot) { meta.BackdropURL = firstAdultLooseImage(dir, "backdrop") } } @@ -245,6 +245,48 @@ func firstExistingPosterImage(dir string, names ...string) string { return "" } +// looseArtworkFallbackAllowed reports whether an unmatched image sitting in dir +// can be attributed to mediaPath. Such an image carries no filename relation to +// a specific video, so it is only trustworthy when the folder holds a single +// media file and that folder is not the library root. Download tools routinely +// drop unrelated titles flat into one directory (a 115 cloud-download library, +// for example); claiming the first "*-poster.jpg" there made every item show the +// same artwork. Mirrors the root-level guard findMovieNFO already applies to +// loose NFO files. +func looseArtworkFallbackAllowed(dir, libraryRoot string) bool { + if dir == "" { + return false + } + if libraryRoot != "" && samePath(dir, libraryRoot) { + return false + } + return countMediaFilesInDir(dir) <= 1 +} + +// countMediaFilesInDir counts the video files directly inside dir, stopping once +// more than one is found. It walks the real directory rather than reusing the +// loose-image glob because a shared folder may contain no images at all. +func countMediaFilesInDir(dir string) int { + entries, err := os.ReadDir(dir) + if err != nil { + return 0 + } + count := 0 + for _, entry := range entries { + if entry.IsDir() { + continue + } + if _, ok := videoExtensions[strings.ToLower(filepath.Ext(entry.Name()))]; !ok { + continue + } + count++ + if count > 1 { + return count + } + } + return count +} + func firstAdultLooseImage(dir, kind string) string { if dir == "" { return "" diff --git a/internal/service/local_metadata_read.go b/internal/service/local_metadata_read.go index 032d70c..c835a19 100644 --- a/internal/service/local_metadata_read.go +++ b/internal/service/local_metadata_read.go @@ -17,12 +17,12 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe doc, path, err := findMovieNFO(mediaPath, libraryRoot) if err != nil { if errors.Is(err, os.ErrNotExist) { - return metadataFromArtwork(mediaPath, ""), nil + return metadataFromArtwork(mediaPath, "", libraryRoot), nil } return nil, err } meta := metadataFromDoc(doc, filepath.Dir(path), false) - mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path)) + mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path), libraryRoot) // A sidecar written while resolution tokens were misread as SxxExx must not // reintroduce the bogus season/episode on rescan. dropResolutionArtifactEpisodeIdentity(meta, mediaPath) @@ -115,9 +115,9 @@ func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) { } } if meta == nil { - meta = metadataFromArtwork(mediaPath, showBaseDir) + meta = metadataFromArtwork(mediaPath, showBaseDir, libraryRoot) } else { - mergeArtworkMetadata(meta, mediaPath, showBaseDir) + mergeArtworkMetadata(meta, mediaPath, showBaseDir, libraryRoot) } // A sidecar written while resolution tokens were misread as SxxExx must not // reintroduce the bogus season/episode on rescan.