From 42205e0e8b9ad6cb38ea9792aabe93cb0da02af8 Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Sun, 24 May 2026 14:27:12 +0200 Subject: [PATCH] feat(audiobooks): filesystem podcast scanner ScanPodcastFolder walks a podcasts-typed media folder, treating each subdirectory as a podcast show and each audio file inside as an episode. Writes media_items.type='podcast' + episodes rows + media_files rows. RSS-subscribed feeds (podcast_feeds table) arrive in sub-plan 5; this task covers filesystem-only ingestion. ScanFolder dispatches to this path when folder.Type='podcasts'. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/scanner/podcast.go | 97 ++++++++++ internal/scanner/podcast_scan.go | 181 ++++++++++++++++++ internal/scanner/podcast_test.go | 41 ++++ internal/scanner/scanner.go | 14 +- .../testdata/podcast_fixtures/show_a/ep1.mp3 | Bin 0 -> 12550 bytes .../testdata/podcast_fixtures/show_a/ep2.mp3 | Bin 0 -> 12550 bytes .../testdata/podcast_fixtures/show_a/ep3.mp3 | Bin 0 -> 12550 bytes 7 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 internal/scanner/podcast.go create mode 100644 internal/scanner/podcast_scan.go create mode 100644 internal/scanner/podcast_test.go create mode 100644 internal/scanner/testdata/podcast_fixtures/show_a/ep1.mp3 create mode 100644 internal/scanner/testdata/podcast_fixtures/show_a/ep2.mp3 create mode 100644 internal/scanner/testdata/podcast_fixtures/show_a/ep3.mp3 diff --git a/internal/scanner/podcast.go b/internal/scanner/podcast.go new file mode 100644 index 00000000..64a084db --- /dev/null +++ b/internal/scanner/podcast.go @@ -0,0 +1,97 @@ +package scanner + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" +) + +// parsedPodcastShow is what parsePodcastShow returns. The scanner write +// path converts this into media_items (type='podcast') + episodes + +// media_files rows. +type parsedPodcastShow struct { + Title string + Author string + Year int + Episodes []parsedPodcastEpisode +} + +type parsedPodcastEpisode struct { + Path string + Title string + Track int +} + +// parsePodcastShow walks a single subdirectory of a podcast library and +// returns the show's metadata + episode list. Each audio file inside +// becomes one episode; tags are read from each file individually so +// per-episode titles surface correctly. +// +// 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) + 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) + + show := &parsedPodcastShow{} + for idx, path := range audioFiles { + probed, err := ProbeFile(ctx, ffprobePath, path) + if err != nil { + return nil, fmt.Errorf("probe podcast file %s: %w", path, err) + } + tags := probed.FormatTags + if show.Title == "" { + show.Title = firstNonEmpty(tags["album"], tags["show"], filepath.Base(folderPath)) + show.Author = firstNonEmpty(tags["artist"], tags["album_artist"]) + if year := firstNonEmpty(tags["date"], tags["year"]); year != "" { + if y := parseTagYear(year); y > 0 { + show.Year = y + } + } + } + stem := filepath.Base(path) + stem = stem[:len(stem)-len(filepath.Ext(stem))] + track := idx + 1 + if t := tags["track"]; t != "" { + if parsed := parseTrackNumber(t); parsed > 0 { + track = parsed + } + } + show.Episodes = append(show.Episodes, parsedPodcastEpisode{ + Path: path, + Title: firstNonEmpty(tags["title"], stem), + Track: track, + }) + } + return show, 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 { + n := 0 + for _, c := range s { + if c < '0' || c > '9' { + break + } + n = n*10 + int(c-'0') + } + return n +} diff --git a/internal/scanner/podcast_scan.go b/internal/scanner/podcast_scan.go new file mode 100644 index 00000000..f63bdb46 --- /dev/null +++ b/internal/scanner/podcast_scan.go @@ -0,0 +1,181 @@ +package scanner + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/idgen" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/titleutil" +) + +// ScanPodcastFolder walks a podcasts-typed media folder and writes one +// media_items row (type='podcast') per immediate subdirectory it can parse +// as a podcast show, plus one episodes row and one media_files row per +// audio file inside. +// +// RSS-subscribed feeds (podcast_feeds table) are handled by sub-plan 5; +// this method covers filesystem-only ingestion. +func (s *Scanner) ScanPodcastFolder(ctx context.Context, folder *models.MediaFolder) error { + if s == nil || folder == nil { + return fmt.Errorf("ScanPodcastFolder: nil scanner or folder") + } + + for _, root := range folder.Paths { + entries, err := os.ReadDir(root) + if err != nil { + slog.Warn("podcast scan: read root failed", "root", root, "error", err) + continue + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + subPath := filepath.Join(root, entry.Name()) + if err := s.reconcilePodcastShow(ctx, folder, subPath); err != nil { + slog.Warn("podcast scan: show failed", + "folder_id", folder.ID, + "path", subPath, + "error", err, + ) + // Continue with siblings — one bad show should not stop the scan. + } + } + } + return nil +} + +func (s *Scanner) reconcilePodcastShow(ctx context.Context, folder *models.MediaFolder, folderPath string) error { + parsed, err := parsePodcastShow(ctx, s.ffprobePath, folderPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("parse podcast show %s: %w", folderPath, err) + } + + showContentID, err := s.upsertPodcastMediaItem(ctx, parsed) + if err != nil { + return fmt.Errorf("upsert podcast item: %w", err) + } + if err := s.upsertPodcastEpisodesAndFiles(ctx, folder, showContentID, folderPath, parsed); err != nil { + return fmt.Errorf("upsert podcast episodes+files: %w", err) + } + + slog.Info("podcast scan: indexed", + "folder_id", folder.ID, + "content_id", showContentID, + "title", parsed.Title, + "author", parsed.Author, + "episodes", len(parsed.Episodes), + ) + return nil +} + +// upsertPodcastMediaItem looks up an existing media_items row by +// title+year+type='podcast', updates it if found, or creates a new row. +// Returns the content_id used. +func (s *Scanner) upsertPodcastMediaItem(ctx context.Context, show *parsedPodcastShow) (string, error) { + if s.itemRepo == nil { + return "", fmt.Errorf("itemRepo not configured on Scanner") + } + + existing, err := s.itemRepo.GetByTitleYearType(ctx, show.Title, show.Year, "podcast") + if err == nil && existing != nil { + existing.Title = show.Title + existing.Year = show.Year + existing.Type = "podcast" + if existing.SortTitle == "" { + existing.SortTitle = titleutil.DeriveDefaultSortTitle(show.Title) + } + if err := s.itemRepo.Upsert(ctx, existing); err != nil { + return "", err + } + return existing.ContentID, nil + } + if err != nil && !errors.Is(err, catalog.ErrItemNotFound) { + return "", fmt.Errorf("GetByTitleYearType: %w", err) + } + + // Not found — create. + id, err := idgen.NextID() + if err != nil { + return "", fmt.Errorf("generate content_id: %w", err) + } + item := &models.MediaItem{ + ContentID: id, + Type: "podcast", + Title: show.Title, + SortTitle: titleutil.DeriveDefaultSortTitle(show.Title), + Year: show.Year, + } + if err := s.itemRepo.Upsert(ctx, item); err != nil { + return "", err + } + return id, nil +} + +// upsertPodcastEpisodesAndFiles writes one episodes row and one media_files +// row per episode in the parsed show. The episode's content_id is linked back +// through media_files.episode_id so playback and library queries can resolve +// it. +// +// Podcast episodes use season_number=0 (no season concept) and +// episode_number=track. +func (s *Scanner) upsertPodcastEpisodesAndFiles( + ctx context.Context, + folder *models.MediaFolder, + showContentID string, + folderPath string, + show *parsedPodcastShow, +) error { + if s.episodeRepo == nil { + return fmt.Errorf("episodeRepo not configured on Scanner") + } + + for _, ep := range show.Episodes { + episodeID, err := idgen.NextID() + if err != nil { + return fmt.Errorf("generate episode content_id: %w", err) + } + + episode := &models.Episode{ + ContentID: episodeID, + SeriesID: showContentID, + SeasonNumber: 0, + EpisodeNumber: ep.Track, + Title: ep.Title, + } + // Upsert writes back the stored content_id (preserving existing on conflict). + if err := s.episodeRepo.Upsert(ctx, episode); err != nil { + return fmt.Errorf("upsert episode %q: %w", ep.Title, err) + } + + mf := models.MediaFile{ + ContentID: showContentID, + EpisodeID: episode.ContentID, + SeasonNumber: 0, + EpisodeNumber: ep.Track, + MediaFolderID: folder.ID, + CanonicalRootPath: folderPath, + ObservedRootPath: folderPath, + ContentGroupKey: showContentID, + GroupKeyVersion: 1, + BaseTitle: show.Title, + BaseYear: show.Year, + BaseType: "podcast", + IdentityConfidence: "high", + FilePath: ep.Path, + ProbeSource: "local", + } + if _, err := s.fileRepo.Upsert(ctx, mf); err != nil { + return fmt.Errorf("upsert media file %s: %w", ep.Path, err) + } + } + return nil +} diff --git a/internal/scanner/podcast_test.go b/internal/scanner/podcast_test.go new file mode 100644 index 00000000..e9280549 --- /dev/null +++ b/internal/scanner/podcast_test.go @@ -0,0 +1,41 @@ +package scanner + +import ( + "context" + "os/exec" + "testing" +) + +func TestParsePodcastShow(t *testing.T) { + ffprobePath := FFprobePathFromFFmpeg("ffmpeg") + if _, err := exec.LookPath(ffprobePath); err != nil { + ffprobePath = "ffprobe" + if _, err := exec.LookPath(ffprobePath); err != nil { + t.Skip("ffprobe not available") + } + } + + ctx := context.Background() + got, err := parsePodcastShow(ctx, ffprobePath, "testdata/podcast_fixtures/show_a") + if err != nil { + t.Fatalf("parsePodcastShow: %v", err) + } + if got.Title != "Show A" { + t.Errorf("Title = %q, want %q", got.Title, "Show A") + } + if got.Author != "Show A Host" { + t.Errorf("Author = %q, want %q", got.Author, "Show A Host") + } + if got.Year != 2024 { + t.Errorf("Year = %d, want 2024", got.Year) + } + if len(got.Episodes) != 3 { + t.Fatalf("got %d episodes, want 3", len(got.Episodes)) + } + for i, ep := range got.Episodes { + wantTrack := i + 1 + if ep.Track != wantTrack { + t.Errorf("episode %d Track = %d, want %d", i, ep.Track, wantTrack) + } + } +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 0fdc8b4b..243d7325 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -110,6 +110,7 @@ type Scanner struct { episodeLibraryRepo *catalog.EpisodeLibraryRepository itemRepo *catalog.ItemRepository personRepo *catalog.PersonRepository + episodeRepo *catalog.EpisodeRepository ffprobePath string s3Client *s3client.Client // public assets bucket (may be nil) workers int @@ -161,6 +162,7 @@ func NewScanner(fileRepo *FileRepository, ffprobePath string, s3Client *s3client episodeLibraryRepo: catalog.NewEpisodeLibraryRepository(fileRepo.Pool()), itemRepo: catalog.NewItemRepository(fileRepo.Pool()), personRepo: catalog.NewPersonRepository(fileRepo.Pool()), + episodeRepo: catalog.NewEpisodeRepository(fileRepo.Pool()), ffprobePath: ffprobePath, s3Client: s3Client, workers: workers, @@ -197,8 +199,9 @@ func (s *Scanner) SetMetadataQueueProducer(producer MetadataQueueProducer) { // probes them for technical data, and upserts them into the database. // Files previously in the DB that no longer exist on disk are marked as missing. // -// Audiobook libraries are handled by ScanAudiobookFolder and bypass the -// per-file movie/TV pipeline entirely. +// Audiobook libraries are handled by ScanAudiobookFolder and podcast +// libraries by ScanPodcastFolder; both bypass the per-file movie/TV +// pipeline entirely. func (s *Scanner) ScanFolder(ctx context.Context, folder *models.MediaFolder) (*ScanResult, error) { watchCtx, stopWatch := s.watchFolderContext(ctx, folder.ID) defer stopWatch() @@ -210,6 +213,13 @@ func (s *Scanner) ScanFolder(ctx context.Context, folder *models.MediaFolder) (* return &ScanResult{}, nil } + if isPodcastLibraryType(folder.Type) { + if err := s.ScanPodcastFolder(watchCtx, folder); err != nil { + return nil, err + } + return &ScanResult{}, nil + } + return s.scanPaths(watchCtx, folder, folder.Paths, folder.Paths, true) } diff --git a/internal/scanner/testdata/podcast_fixtures/show_a/ep1.mp3 b/internal/scanner/testdata/podcast_fixtures/show_a/ep1.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..3b416c2ba14a34e7dfc425ab8c856769ebf4f909 GIT binary patch literal 12550 zcmeZtF=k<4U|?Wm4e<;yVqjnpVqjnn&d4uUa8z(D$SlrJNmVdp2nldCWME+6MaX&N z7nd-EIQlp-Ffeeyg&9Izf}9x`7}yvXn2ijKOc+9foV^(s7?>Ftm<<_1f`eTd7#R2& z7?^z$%hF6O^^DB*3=IrG_CxXi&jkmdOa=zeytI6XdWKR427_`24lW)(0a0;DX<2y{ zH4QBtJ!4aIOKV#v7dHM+DGjHMI zr7Ksj+puZNj@^6rA3S{g)R}V^E?vKQ`|kaR&tANG^X|jf?>~S41zGNsSe9&Rsb_2s z;=r&9(<}(npuBlOYdeGuCI5eRI0Eve2Z-@>VDuU=f1Lk;3@@+383$0|PVI z9&8$^XOY8^C3q~No;^-S7UQuelzJA8dI(#BAt(k)a-%UsFqp6?02K9wiJd&eH z4o~3VlNwEO_(BJt+-Q=+7dm+4Mw1+#z`-juSdtvI27N;5=b_QjCA=vMm*i+(#T7Dy z#Oar`M@MK0#RYcx(HO!WE(AqK3k8BfghRn-lEV=&goHsL4#jnG|Ax)AKbE|Ne*||;FTFoa(IIWkK|~Q!xK38q(+k*zRQ%gM~b3H=?1Caet{Qq;o0VtD!!80!{AEKV2l!3vZoPmRjhfhFMTvA$AUPVnq zOGnSx)ZEhA*2%@q!^<}?I5a#eCLt*$Ei*g6u(+(Es;;4_wY|Hyf6|m`v*yfOxOnNx z)$2BF+OlK!-u(v;A3t^G+=WZmZ{EIp|KYP2uim`-@b&x8-+w`t`y`enn_B7_n}awo ztim)4!ZavvUeMYOVMEFPpB;{XeCYw=`#Ac#8iTwQ3I|w(;Bure{(p8l63xKC47LZG zM(SDQaAXM{i>POh(~-q^>Mw1+& z5W*!tn&fbW3<2@cBu5~Ca7vFRIh;X*Ut%=L;SV3&vZF~3ci7;S8BKC{g9ne~Xp+Md zIQXPSlN`R#!6!GGT0(JwU4Ar%u!jpl(a}PIU=ZO@Fq-6W1PmeJ(IiJGgmB4^COKRoLqL2q$q@)3 zoYJF74rkEdml#cQ_`?Uc>}Zn19X5DnMw1-g;K3s~n&j{V4nC>TB!@3_@W~C9BsV(# zha;9olQxckAt*dplJ@9=MqE)f8bi23hJg5Jp+F#ja7vFRIh;X*UxHwg!>^rqSwb;H Jyh->~0szX0l*s@9 literal 0 HcmV?d00001 diff --git a/internal/scanner/testdata/podcast_fixtures/show_a/ep3.mp3 b/internal/scanner/testdata/podcast_fixtures/show_a/ep3.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..5b327c54840f3c54d1c58875e7ba8ba6e361addc GIT binary patch literal 12550 zcmeZtF=k<4U|?Wm4e<;yVqjnpVqjnn&d4uUa8z(D$SlrJNmVdr2nldCWME+6MaX&N z7nd-EIQlp-Ffeeyg&9Izf}9x`7}yvXn2ijKOc+9foV^(s7?>Ftn2i}if`eTd7#R2& z7?^z$%hF6O^^DB*3=IrG_CxXi&jkmdOa=zeytI6XdWKR427_`24lW)(0a0;DX<2y{ zH4QBtJ!4aIOKV#v7dHM+DGjHMI zr7Ksj+puZNj@^6rA3S{g)R}V^E?vKQ`|kaR&tANG^X|jf?>~S41zGNsSe9&Rsb_2s z;=r&9(<}(npuBlOYdeGuCI5eRI0Eve2Z-@>VDuU=f1Lk;3@@+383$0|PVI z9&8$^XOY8^C3q~No;^-S7UQuelzJA8dI(#BAt(k)a-%UsFqp6?02K9wiJd&eH z4o~3VlNwEO_(BJt+-Q=+7dm+4Mw1+#z`-juSdtvI27N;5=b_QjCA=vMm*i+(#T7Dy z#Oar`M@MK0#RYcx(HO!WE(AqK3k8BfghRn-lEV=&goHsL4#jnG|Ax)AKbE|Ne*||;FTFoa(IIWkK|~Q!xK38q(+k*zR