From 6b45cfd8555bee0055e84e2db75faf2336f54661 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:34:18 -0400 Subject: [PATCH] fix(scanner): track podcast show audio paths during folder reconcile Extract listPodcastShowAudioFiles and record each show's audio paths in seenPaths during ScanPodcastFolder so reconciliation does not treat still-present episodes as removed. Co-Authored-By: Claude Opus 4.8 --- internal/scanner/podcast.go | 38 +++++++++++++++++++------------- internal/scanner/podcast_scan.go | 5 +++++ internal/scanner/podcast_test.go | 35 +++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/internal/scanner/podcast.go b/internal/scanner/podcast.go index 64a084db..c7bc22b0 100644 --- a/internal/scanner/podcast.go +++ b/internal/scanner/podcast.go @@ -32,23 +32,10 @@ type parsedPodcastEpisode struct { // Returns an os.ErrNotExist-wrapped error if the folder contains zero // audio files. func parsePodcastShow(ctx context.Context, ffprobePath string, folderPath string) (*parsedPodcastShow, error) { - entries, err := os.ReadDir(folderPath) + audioFiles, err := listPodcastShowAudioFiles(folderPath) if err != nil { - return nil, fmt.Errorf("read podcast folder %s: %w", folderPath, err) + return nil, err } - var audioFiles []string - for _, entry := range entries { - if entry.IsDir() { - continue - } - if SupportsAudioFile(entry.Name()) { - audioFiles = append(audioFiles, filepath.Join(folderPath, entry.Name())) - } - } - if len(audioFiles) == 0 { - return nil, fmt.Errorf("podcast show %s: %w", folderPath, os.ErrNotExist) - } - sort.Strings(audioFiles) show := &parsedPodcastShow{} for idx, path := range audioFiles { @@ -83,6 +70,27 @@ func parsePodcastShow(ctx context.Context, ffprobePath string, folderPath string return show, nil } +func listPodcastShowAudioFiles(folderPath string) ([]string, error) { + entries, err := os.ReadDir(folderPath) + if err != nil { + return nil, fmt.Errorf("read podcast folder %s: %w", folderPath, err) + } + var audioFiles []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + if SupportsAudioFile(entry.Name()) { + audioFiles = append(audioFiles, filepath.Join(folderPath, entry.Name())) + } + } + if len(audioFiles) == 0 { + return nil, fmt.Errorf("podcast show %s: %w", folderPath, os.ErrNotExist) + } + sort.Strings(audioFiles) + return audioFiles, nil +} + // parseTrackNumber accepts ID3-style track values which may be "5" or // "5/12". Returns 0 if no leading integer is present. func parseTrackNumber(s string) int { diff --git a/internal/scanner/podcast_scan.go b/internal/scanner/podcast_scan.go index 7dff70b8..18d211e4 100644 --- a/internal/scanner/podcast_scan.go +++ b/internal/scanner/podcast_scan.go @@ -56,6 +56,11 @@ func (s *Scanner) ScanPodcastFolder(ctx context.Context, folder *models.MediaFol return err } attempted++ + if paths, err := listPodcastShowAudioFiles(subPath); err == nil { + for _, path := range paths { + seenPaths[path] = true + } + } episodePaths, err := s.reconcilePodcastShow(ctx, folder, subPath) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { diff --git a/internal/scanner/podcast_test.go b/internal/scanner/podcast_test.go index 6b8854f9..9865d0cc 100644 --- a/internal/scanner/podcast_test.go +++ b/internal/scanner/podcast_test.go @@ -98,6 +98,41 @@ func TestScanPodcastFolderReturnsCanceledContext(t *testing.T) { } } +func TestListPodcastShowAudioFilesReturnsSortedAudioPaths(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "02.mp3"), []byte("audio"), 0o644); err != nil { + t.Fatalf("write audio: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("notes"), 0o644); err != nil { + t.Fatalf("write notes: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "01.flac"), []byte("audio"), 0o644); err != nil { + t.Fatalf("write audio: %v", err) + } + if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + + got, err := listPodcastShowAudioFiles(root) + if err != nil { + t.Fatalf("listPodcastShowAudioFiles: %v", err) + } + want := []string{ + filepath.Join(root, "01.flac"), + filepath.Join(root, "02.mp3"), + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("audio files = %#v, want %#v", got, want) + } +} + +func TestListPodcastShowAudioFilesReturnsNotExistForEmptyShow(t *testing.T) { + _, err := listPodcastShowAudioFiles(t.TempDir()) + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("empty podcast show error = %v, want os.ErrNotExist", err) + } +} + func TestResolvePodcastMediaItemReusesRootScopedContentID(t *testing.T) { finder := &fakeRootContentFinder{contentID: "podcast-root-id"} writer := &fakeFilesystemItemWriter{}