diff --git a/internal/database/querier.go b/internal/database/querier.go index 74d26112..43d09d7c 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -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. diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index f150dd45..d1dc1ee1 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -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, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 1f7e2907..2699d53f 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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; diff --git a/internal/handlers/scanner.go b/internal/handlers/scanner.go index 5aa526ef..fa85e96d 100644 --- a/internal/handlers/scanner.go +++ b/internal/handlers/scanner.go @@ -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 } } diff --git a/internal/services/library_service.go b/internal/services/library_service.go index afbf9b1d..112eb2a8 100644 --- a/internal/services/library_service.go +++ b/internal/services/library_service.go @@ -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 +// //...); 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 diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index 62eb3600..961f7dab 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -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