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:
co-authored by
Claude Opus 4.7
parent
2cbb8514df
commit
42205e0e8b
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
Reference in New Issue
Block a user