From 07e340251b513ce6d0accfa9fa6720cc9e59539a Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:32:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/service/local_metadata_read.go | 98 ++++++++++++++++----- internal/service/local_metadata_test.go | 108 ++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 21 deletions(-) diff --git a/internal/service/local_metadata_read.go b/internal/service/local_metadata_read.go index ac1f2fa..ff19ac9 100644 --- a/internal/service/local_metadata_read.go +++ b/internal/service/local_metadata_read.go @@ -49,7 +49,7 @@ func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) { continue } seen[key] = struct{}{} - if doc, _, err := readNFO(path); err == nil { + if doc, _, err := decodeNFOFile(path); err == nil && doc != nil { return doc, path, nil } else if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, "", err @@ -62,7 +62,7 @@ func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) { for _, match := range matches { baseKey := strings.ToLower(strings.ReplaceAll(strings.TrimSuffix(filepath.Base(match), filepath.Ext(match)), "-", "")) if strings.Contains(baseKey, codeKey) || strings.Contains(codeKey, baseKey) { - if doc, _, err := readNFO(match); err == nil { + if doc, _, err := decodeNFOFile(match); err == nil && doc != nil { return doc, match, nil } else if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, "", err @@ -71,7 +71,7 @@ func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) { } } if len(matches) == 1 { - if doc, _, err := readNFO(matches[0]); err == nil { + if doc, _, err := decodeNFOFile(matches[0]); err == nil && doc != nil { return doc, matches[0], nil } else if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, "", err @@ -84,21 +84,33 @@ func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) { func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) { var meta *LocalMetadata showBaseDir := "" - if showDoc, showPath, err := findShowNFO(mediaPath, libraryRoot); err == nil && showDoc != nil { + if showDoc, showPath, partial, err := findShowNFO(mediaPath, libraryRoot); err == nil && showDoc != nil { showBaseDir = filepath.Dir(showPath) meta = metadataFromDoc(showDoc, showBaseDir, true) + // A truncated show NFO still yields a usable title/poster (decodePartialNFO + // only surfaces docs with recoverable fields). Keep HasNFO so the recovered + // title participates in series grouping; a fully partial episode match + // below can still demote it. + meta.HasNFO = !partial || meta.HasNFO } else if err != nil && !errors.Is(err, os.ErrNotExist) { - return nil, err + // A damaged show NFO must not discard the whole series: readLocalScanMetadata + // treats this as a hard failure and leaves every episode without local + // metadata. When the show level fails we still merge the episode NFO and + // local artwork below instead of returning early. + meta = nil } - if episodeDoc, episodePath, err := readNFO(nfoPath(mediaPath)); err == nil { + if episodeDoc, episodePath, episodePartial, err := readEpisodeNFO(nfoPath(mediaPath)); err == nil && episodeDoc != nil { episodeMeta := metadataFromDoc(episodeDoc, filepath.Dir(episodePath), true) if meta == nil { meta = &LocalMetadata{} } mergeEpisodeMetadata(meta, episodeMeta, episodeDoc) - } else if err != nil && !errors.Is(err, os.ErrNotExist) { - return nil, err + // Only a fully valid episode NFO can confirm the match; a truncated one + // keeps whatever the show NFO supplied but must not force scrape_status. + if episodePartial { + meta.HasNFO = false + } } if meta == nil { meta = metadataFromArtwork(mediaPath, showBaseDir) @@ -108,19 +120,49 @@ func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) { return meta, nil } -func readNFO(path string) (*nfoDocument, string, error) { +// readEpisodeNFO reads the sidecar NFO next to an episode file and reports both +// the recovered document and whether it came from a truncated/malformed file. +func readEpisodeNFO(path string) (*nfoDocument, string, bool, error) { body, err := os.ReadFile(path) // #nosec G304 -- path is a discovered NFO sidecar under the configured library root. if err != nil { - return nil, "", err + return nil, "", false, err } - var doc nfoDocument - if err := xml.Unmarshal(body, &doc); err != nil { - return nil, "", err + doc, partial, err := decodePartialNFO(body) + if err != nil { + return nil, "", false, err } - return &doc, path, nil + return doc, path, partial, nil } -func findShowNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) { +func decodePartialNFO(body []byte) (*nfoDocument, bool, error) { + var doc nfoDocument + err := xml.Unmarshal(body, &doc) + if err == nil { + return &doc, false, nil + } + if !isLikelyTruncatedXMLError(err) { + // A real parse error unrelated to truncation: don't trust partial fields. + return nil, false, err + } + // Unmarshal still fills the elements it closed before hitting the cut; treat + // those as partial show metadata instead of dropping everything. + if doc.Title == "" && doc.OriginalTitle == "" && len(doc.Thumbs) == 0 && + doc.Premiered == "" && doc.Plot == "" { + return nil, false, err + } + return &doc, true, nil +} + +func isLikelyTruncatedXMLError(err error) bool { + for _, msg := range []string{"unexpected EOF", "EOF"} { + if strings.Contains(strings.ToLower(err.Error()), strings.ToLower(msg)) { + return true + } + } + return false +} + +func findShowNFO(mediaPath, libraryRoot string) (*nfoDocument, string, bool, error) { dir := filepath.Dir(mediaPath) root := filepath.Clean(libraryRoot) for { @@ -133,19 +175,33 @@ func findShowNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) { names = append(names, base+".nfo") for _, name := range names { path := filepath.Join(dir, name) - if doc, _, err := readNFO(path); err == nil { - return doc, path, nil - } else if err != nil && !errors.Is(err, os.ErrNotExist) { - return nil, "", err + doc, partial, err := decodeNFOFile(path) + if doc != nil { + return doc, path, partial, nil + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, "", false, err } } if samePath(dir, root) { - return nil, "", os.ErrNotExist + return nil, "", false, os.ErrNotExist } parent := filepath.Dir(dir) if parent == dir { - return nil, "", os.ErrNotExist + return nil, "", false, os.ErrNotExist } dir = parent } } + +func decodeNFOFile(path string) (*nfoDocument, bool, error) { + body, err := os.ReadFile(path) // #nosec G304 -- path is a discovered NFO sidecar under the configured library root. + if err != nil { + return nil, false, err + } + doc, partial, err := decodePartialNFO(body) + if err != nil { + return nil, false, err + } + return doc, partial, nil +} diff --git a/internal/service/local_metadata_test.go b/internal/service/local_metadata_test.go index 28ac33b..c58a24d 100644 --- a/internal/service/local_metadata_test.go +++ b/internal/service/local_metadata_test.go @@ -267,3 +267,111 @@ func TestReadLocalMetadataWithoutNFOStillFindsArtwork(t *testing.T) { t.Fatalf("unexpected artwork metadata: %+v", got) } } + +// TestReadLocalMetadataRecoversFromTruncatedShowNFO mirrors a real-world issue: +// some anime tvshow.nfo files are truncated mid-URL (unexpected EOF). Before the +// fix this discarded the whole series, leaving episodes pending with per-episode +// titles and no poster. The recoverable fields (title/year) and the matching +// episode NFO + local artwork must still be applied. +func TestReadLocalMetadataRecoversFromTruncatedShowNFO(t *testing.T) { + root := t.TempDir() + showDir := filepath.Join(root, "夏日重现 (2022)") + seasonDir := filepath.Join(showDir, "Season 1") + if err := os.MkdirAll(seasonDir, 0o755); err != nil { + t.Fatal(err) + } + mediaPath := filepath.Join(seasonDir, "S01E01.mkv") + if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + // tvshow.nfo cut off inside a URL, like the broken real files. + truncated := ` + + 夏日重现 + サマータイムレンダ + 2022 + 听闻自己青梅竹马死讯。 + https://image.tmdb.org/t/p/original/2koyWLm6iVn5OTEExTjKzVms5Iz.jpg + + https://image.tmdb.org/t/p/original/p2eZlGwd8OjkWpwD2hSoBiIlHBZ.jpg再见了夏日11`), 0o644); err != nil { + t.Fatal(err) + } + // Local artwork next to the show folder. + poster := filepath.Join(showDir, "poster.jpg") + if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ReadLocalMetadata(mediaPath, root, true) + if err != nil { + t.Fatalf("ReadLocalMetadata returned error on truncated show NFO: %v", err) + } + if got == nil { + t.Fatal("metadata is nil; truncated show NFO discarded the series") + } + // The recovered show title takes precedence over the episode title. + if got.Title != "夏日重现" { + t.Fatalf("Title = %q, want recovered show title 夏日重现", got.Title) + } + if got.Year != 2022 { + t.Fatalf("Year = %d, want 2022", got.Year) + } + if got.EpisodeTitle != "再见了夏日" || got.SeasonNum != 1 || got.EpisodeNum != 1 { + t.Fatalf("episode metadata not preserved: %+v", got) + } + // Prior to the fix the episode metadata was dropped entirely; the poster comes + // from the local poster.jpg next to the show. + if got.PosterURL != poster { + t.Fatalf("PosterURL = %q, want local poster %q", got.PosterURL, poster) + } + // A truncated show NFO must not be treated as an authoritative match by + // itself; since the episode NFO is valid we still mark it matched so the + // recovered series title participates in grouping. + if !got.HasNFO { + t.Fatalf("HasNFO = false, want true (episode NFO is valid)") + } +} + +// TestReadLocalMetadataKeepsArtworkOnlyWhenNoUsableNFO verifies that when a show +// NFO is truncated AND yields no recoverable fields, we still fall back to local +// artwork instead of returning an error. +func TestReadLocalMetadataArtworkFallbackOnGarbageShowNFO(t *testing.T) { + root := t.TempDir() + showDir := filepath.Join(root, "Some Show") + seasonDir := filepath.Join(showDir, "Season 1") + if err := os.MkdirAll(seasonDir, 0o755); err != nil { + t.Fatal(err) + } + mediaPath := filepath.Join(seasonDir, "S01E01.mkv") + if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + // Severely truncated: no recoverable title/fields at all. + if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(`半截`), 0o644); err != nil { + t.Fatal(err) + } + // Episode-level backdrop sits next to the episode file. + backdrop := filepath.Join(seasonDir, "S01E01-backdrop.jpg") + if err := os.WriteFile(backdrop, []byte("jpg"), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ReadLocalMetadata(mediaPath, root, true) + if err != nil { + t.Fatalf("ReadLocalMetadata should not error when NFO is unusable: %v", err) + } + if got == nil { + t.Fatal("metadata is nil; artwork fallback missing") + } + if got.HasNFO { + t.Fatalf("HasNFO = true, want false for unusable NFO") + } + if got.BackdropURL != backdrop { + t.Fatalf("expected episode artwork fallback, got %+v", got) + } +}