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) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-24 14:27:12 +02:00
co-authored by Claude Opus 4.7
parent 2cbb8514df
commit 42205e0e8b
7 changed files with 331 additions and 2 deletions
+97
View File
@@ -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
}
+181
View File
@@ -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
}
+41
View File
@@ -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)
}
}
}
+12 -2
View File
@@ -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)
}
Binary file not shown.
Binary file not shown.
Binary file not shown.