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 00000000..3b416c2b Binary files /dev/null and b/internal/scanner/testdata/podcast_fixtures/show_a/ep1.mp3 differ diff --git a/internal/scanner/testdata/podcast_fixtures/show_a/ep2.mp3 b/internal/scanner/testdata/podcast_fixtures/show_a/ep2.mp3 new file mode 100644 index 00000000..1c144ff3 Binary files /dev/null and b/internal/scanner/testdata/podcast_fixtures/show_a/ep2.mp3 differ 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 00000000..5b327c54 Binary files /dev/null and b/internal/scanner/testdata/podcast_fixtures/show_a/ep3.mp3 differ