feat(podcasts): scanner exclusion — podcasts libraries are feed-managed (task 1.2)

WHY: podcast download roots hold fetcher-written episode files; the FS
walker must never discover them (invariant 4) — a walk would double-
ingest episodes as scanner items and fight the fetcher's sole-writer
rule (D9). Every scan/watch path funnels through MediaScanner.SetFolders
(StartScanner, ScanLibrary jobs incl. scoped single-folder scans,
watch mode), so the filter lives there once: folders resolving to a
podcasts-type library are dropped before registration, with a log line.

Belt and braces: ScanLibrary's per-folder scope refuses purpose='podcast'
folders explicitly (400) — the panel-level path that bypasses type
resolution. AddLibraryFolder now derives purpose from the library type
('podcast' for podcasts libraries, enforced single download root;
'scan' default elsewhere), so folder purpose and library type cannot
diverge through the API.
This commit is contained in:
John O'Keefe committed 2026-10-29 15:38:45 -04:00
1 parent 0b7ad0f376
commit c0e6993273
6 files changed
+77 -3

No files matched your search

+2
View File
@@ -22,6 +22,8 @@ type Querier interface {
// ============================================
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
// Library Folders queries
// purpose: 'scan' (FS-walker root, default) | 'podcast' (download root;
// scanner-skipped, written by the podcast download worker instead).
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
// Second consecutive missing scan: hide the item from all browsing while
// preserving reading history in case the file returns.
+5 -2
View File
@@ -123,17 +123,20 @@ func (q *Queries) AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelf
}
const AddLibraryFolder = `-- name: AddLibraryFolder :one
INSERT INTO library_folders (library_id, folder_path) VALUES ($1, $2) RETURNING id, library_id, folder_path, created_at, purpose
INSERT INTO library_folders (library_id, folder_path, purpose) VALUES ($1, $2, $3) RETURNING id, library_id, folder_path, created_at, purpose
`
type AddLibraryFolderParams struct {
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
FolderPath string `db:"folder_path" json:"folder_path"`
Purpose string `db:"purpose" json:"purpose"`
}
// Library Folders queries
// purpose: 'scan' (FS-walker root, default) | 'podcast' (download root;
// scanner-skipped, written by the podcast download worker instead).
func (q *Queries) AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error) {
row := q.db.QueryRow(ctx, AddLibraryFolder, arg.LibraryID, arg.FolderPath)
row := q.db.QueryRow(ctx, AddLibraryFolder, arg.LibraryID, arg.FolderPath, arg.Purpose)
var i LibraryFolders
err := row.Scan(
&i.ID,
+3 -1
View File
@@ -97,7 +97,9 @@ DELETE FROM libraries WHERE id = $1;
-- Library Folders queries
-- name: AddLibraryFolder :one
INSERT INTO library_folders (library_id, folder_path) VALUES ($1, $2) RETURNING *;
-- purpose: 'scan' (FS-walker root, default) | 'podcast' (download root;
-- scanner-skipped, written by the podcast download worker instead).
INSERT INTO library_folders (library_id, folder_path, purpose) VALUES ($1, $2, $3) RETURNING *;
-- name: GetLibraryFolders :many
SELECT * FROM library_folders WHERE library_id = $1 ORDER BY created_at;
+5
View File
@@ -85,6 +85,11 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
for _, folder := range libraryFolders {
if folder.FolderPath == req.FolderPath {
registered = true
// Podcast download roots are feed-managed (invariant
// 4): never scanner-ingestible, even scoped.
if folder.Purpose == services.FolderPurposePodcast {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "podcast download roots are feed-managed and cannot be scanned"})
}
break
}
}
+29
View File
@@ -158,10 +158,39 @@ func (s *LibraryService) DeleteLibrary(ctx context.Context, libraryID pgtype.UUI
}
// AddLibraryFolder adds a folder to a library
// Folder purposes (library_folders.purpose): 'scan' roots feed the FS
// walker; 'podcast' roots are download targets written only by the
// podcast download worker.
const (
FolderPurposeScan = "scan"
FolderPurposePodcast = "podcast"
)
// AddLibraryFolder registers a folder against a library. The purpose is
// derived from the library type: podcasts libraries register their
// download root (purpose='podcast', exactly ONE folder — feeds lay out
// <download-root>/<feed-slug>/...); every other type registers a
// scanner root (purpose='scan').
func (s *LibraryService) AddLibraryFolder(ctx context.Context, libraryID pgtype.UUID, folderPath string) (*database.LibraryFolders, error) {
purpose := FolderPurposeScan
lib, err := s.db.GetLibrary(ctx, libraryID)
if err == nil && lib.TypeName == LibraryTypePodcasts {
// Podcasts libraries own exactly one folder: the download root.
existing, err := s.db.GetLibraryFolders(ctx, libraryID)
if err != nil {
return nil, err
}
if len(existing) > 0 {
return nil, fmt.Errorf("podcasts libraries have a single download root; %s is already registered", existing[0].FolderPath)
}
purpose = FolderPurposePodcast
}
folder, err := s.db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: libraryID,
FolderPath: folderPath,
Purpose: purpose,
})
if err != nil {
return nil, err
+33
View File
@@ -512,6 +512,33 @@ func (s *MediaScanner) GetStats() (int, int, int) {
// watcher (and thus no fd/inotify watches) is allocated — the watcher is never
// read by scan jobs and previously leaked one watcher per job.
func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
// Podcasts libraries are feed-managed (plan: podcast-integration.md
// task 1.2, invariant 4): their download roots are written by the
// download worker, never discovered by the walker — including
// scoped single-folder scans (0f1d0abb), which all funnel through
// here. Filter them out before anything else touches the list.
scannable := folders[:0:0]
for _, folder := range folders {
lib, err := s.db.GetLibraryByFolder(context.Background(), folder)
if err != nil {
// Unresolvable folders keep the old behavior (scan walk
// skips them per-file; watch adds them harmlessly).
scannable = append(scannable, folder)
continue
}
libType, err := s.db.GetLibraryType(context.Background(), lib.LibraryTypeID)
if err != nil {
scannable = append(scannable, folder)
continue
}
if libType.Name == LibraryTypePodcasts {
fmt.Printf("Scanner: skipping %s (podcasts library %q — feed-managed, not scanned)\n", folder, lib.Name)
continue
}
scannable = append(scannable, folder)
}
folders = scannable
s.folders = folders
// Always close any previously-owned watcher so reconfiguration doesn't leak.
@@ -524,6 +551,12 @@ func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
// Create + populate a fresh watcher only when the caller intends to read events.
if watch {
if len(folders) == 0 {
// All requested folders filtered (e.g. a podcasts library:
// nothing scannable, nothing to watch). Fail loudly at the
// API boundary instead of panicking on folders[0] below.
return fmt.Errorf("no scannable folders (podcasts libraries are feed-managed and cannot be scanned)")
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
// Return an error instead of panicking so a failed watcher can't