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:
1 parent
0b7ad0f376
commit
c0e6993273
6 files changed
+77
-3
No files matched your search
@@ -22,6 +22,8 @@ type Querier interface {
|
|||||||
// ============================================
|
// ============================================
|
||||||
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
|
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
|
||||||
// Library Folders queries
|
// 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)
|
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
|
||||||
// Second consecutive missing scan: hide the item from all browsing while
|
// Second consecutive missing scan: hide the item from all browsing while
|
||||||
// preserving reading history in case the file returns.
|
// preserving reading history in case the file returns.
|
||||||
|
|||||||
@@ -123,17 +123,20 @@ func (q *Queries) AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelf
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AddLibraryFolder = `-- name: AddLibraryFolder :one
|
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 {
|
type AddLibraryFolderParams struct {
|
||||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
FolderPath string `db:"folder_path" json:"folder_path"`
|
FolderPath string `db:"folder_path" json:"folder_path"`
|
||||||
|
Purpose string `db:"purpose" json:"purpose"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Library Folders queries
|
// 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) {
|
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
|
var i LibraryFolders
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
|
|||||||
@@ -97,7 +97,9 @@ DELETE FROM libraries WHERE id = $1;
|
|||||||
|
|
||||||
-- Library Folders queries
|
-- Library Folders queries
|
||||||
-- name: AddLibraryFolder :one
|
-- 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
|
-- name: GetLibraryFolders :many
|
||||||
SELECT * FROM library_folders WHERE library_id = $1 ORDER BY created_at;
|
SELECT * FROM library_folders WHERE library_id = $1 ORDER BY created_at;
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
|
|||||||
for _, folder := range libraryFolders {
|
for _, folder := range libraryFolders {
|
||||||
if folder.FolderPath == req.FolderPath {
|
if folder.FolderPath == req.FolderPath {
|
||||||
registered = true
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,10 +158,39 @@ func (s *LibraryService) DeleteLibrary(ctx context.Context, libraryID pgtype.UUI
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddLibraryFolder adds a folder to a library
|
// 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) {
|
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{
|
folder, err := s.db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
|
||||||
LibraryID: libraryID,
|
LibraryID: libraryID,
|
||||||
FolderPath: folderPath,
|
FolderPath: folderPath,
|
||||||
|
Purpose: purpose,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -512,6 +512,33 @@ func (s *MediaScanner) GetStats() (int, int, int) {
|
|||||||
// watcher (and thus no fd/inotify watches) is allocated — the watcher is never
|
// watcher (and thus no fd/inotify watches) is allocated — the watcher is never
|
||||||
// read by scan jobs and previously leaked one watcher per job.
|
// read by scan jobs and previously leaked one watcher per job.
|
||||||
func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
|
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
|
s.folders = folders
|
||||||
|
|
||||||
// Always close any previously-owned watcher so reconfiguration doesn't leak.
|
// 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.
|
// Create + populate a fresh watcher only when the caller intends to read events.
|
||||||
if watch {
|
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()
|
watcher, err := fsnotify.NewWatcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Return an error instead of panicking so a failed watcher can't
|
// Return an error instead of panicking so a failed watcher can't
|
||||||
|
|||||||
Reference in new issue
Block a user