package library import ( "context" "database/sql" "errors" "fmt" "os" "path/filepath" "strings" "time" ) var ( // ErrNotFound means no library matched. ErrNotFound = errors.New("library: not found") // ErrNameExists means the library name is taken. ErrNameExists = errors.New("library: name already in use") // ErrInvalidRoot means the root path is missing, not a directory, or not // readable. ErrInvalidRoot = errors.New("library: invalid root path") ) // Library is a media root of a single kind. type Library struct { ID int64 `json:"id"` Name string `json:"name"` Kind string `json:"kind"` RootPath string `json:"root_path"` CreatedAt time.Time `json:"created_at"` } const librarySelect = `SELECT id, name, kind, root_path, created_at FROM libraries` // Create validates and inserts a library. // // The root path is resolved to an absolute, symlink-free path and must exist. // Storing the resolved form matters: the scanner compares filesystem paths // against this value, and "/media/../media" would silently match nothing. func Create(ctx context.Context, db *sql.DB, name, kind, rootPath string) (Library, error) { name = strings.TrimSpace(name) if name == "" { return Library{}, errors.New("library: name must not be empty") } if err := ValidateKind(kind); err != nil { return Library{}, err } resolved, err := resolveRoot(rootPath) if err != nil { return Library{}, err } var lib Library var created string err = db.QueryRowContext(ctx, ` INSERT INTO libraries (name, kind, root_path) VALUES (?, ?, ?) RETURNING id, created_at`, name, strings.ToLower(kind), resolved, ).Scan(&lib.ID, &created) if err != nil { if isUnique(err) { return Library{}, ErrNameExists } return Library{}, fmt.Errorf("library: create: %w", err) } lib.Name, lib.Kind, lib.RootPath = name, strings.ToLower(kind), resolved lib.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) return lib, nil } // Get loads one library by id. func Get(ctx context.Context, db *sql.DB, id int64) (Library, error) { return scanLibrary(db.QueryRowContext(ctx, librarySelect+` WHERE id = ?`, id)) } // List returns all libraries. func List(ctx context.Context, db *sql.DB) ([]Library, error) { return queryLibraries(ctx, db, librarySelect+` ORDER BY name`) } // ListVisible returns the libraries in the given id set. An empty set yields an // empty slice, never "all" — that distinction is the whole point of the // access-control model. func ListVisible(ctx context.Context, db *sql.DB, ids []int64) ([]Library, error) { if len(ids) == 0 { return []Library{}, nil } placeholders := make([]string, len(ids)) args := make([]any, len(ids)) for i, id := range ids { placeholders[i] = "?" args[i] = id } q := librarySelect + ` WHERE id IN (` + strings.Join(placeholders, ",") + `) ORDER BY name` return queryLibraries(ctx, db, q, args...) } // Delete removes a library. Items, files, and grants cascade. func Delete(ctx context.Context, db *sql.DB, id int64) error { res, err := db.ExecContext(ctx, `DELETE FROM libraries WHERE id = ?`, id) if err != nil { return fmt.Errorf("library: delete: %w", err) } n, _ := res.RowsAffected() if n == 0 { return ErrNotFound } return nil } // CountItems returns how many items a library holds (excluding missing). func CountItems(ctx context.Context, db *sql.DB, id int64) (int, error) { var n int err := db.QueryRowContext(ctx, `SELECT count(*) FROM media_items WHERE library_id = ? AND missing = 0`, id).Scan(&n) return n, err } // MissingEntry is one missing file, surfaced so an admin can see what the // scanner has flagged and decide whether to purge it. // // It carries both the file (file_id) and its owning item (item_id) because the // file is what actually vanished, while the item is what a purge deletes. A // multi-file item (an audiobook of tracks) produces one entry per missing file. type MissingEntry struct { ItemID int64 `json:"item_id"` FileID int64 `json:"file_id"` LibraryID int64 `json:"library_id"` Kind string `json:"kind"` Title string `json:"title"` Path string `json:"path"` MissingSince string `json:"missing_since"` } // ListMissing returns the missing files in the given libraries, grouped by // library and then by title. // // It is keyed on media_files because a missing file is the thing the scanner // saw disappear; an item whose whole group is gone has each of its files // flagged, so every missing item still appears. ids is the caller's visible // set, resolved by the auth layer — an empty set yields nothing, never "all", // which is the same rule ListVisible follows. func ListMissing(ctx context.Context, db *sql.DB, ids []int64) ([]MissingEntry, error) { if len(ids) == 0 { return []MissingEntry{}, nil } placeholders := make([]string, len(ids)) args := make([]any, len(ids)) for i, id := range ids { placeholders[i] = "?" args[i] = id } rows, err := db.QueryContext(ctx, ` SELECT i.id, f.id, i.library_id, i.kind, i.title, f.path, CASE WHEN f.missing_since <> '' THEN f.missing_since ELSE i.missing_since END FROM media_files f JOIN media_items i ON i.id = f.item_id WHERE f.missing = 1 AND i.library_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY i.library_id, i.sort_title, f.path`, args...) if err != nil { return nil, fmt.Errorf("library: list missing: %w", err) } defer rows.Close() out := []MissingEntry{} for rows.Next() { var e MissingEntry if err := rows.Scan(&e.ItemID, &e.FileID, &e.LibraryID, &e.Kind, &e.Title, &e.Path, &e.MissingSince); err != nil { return nil, err } out = append(out, e) } return out, rows.Err() } // PurgeMissing deletes the items in a library that are genuinely missing: // flagged missing and with no file left present. It returns how many items were // removed. // // This is the ONLY place media rows are deleted on purpose, and it is an // explicit admin action rather than part of a scan because the delete is a // one-way door. Deleting a media_items row cascades (foreign_keys is on) into: // // - media_files.item_id — the files themselves // - progress.item_id — watch/read positions, which cannot be // regenerated; this is why a scan never deletes // - progress.file_id — via the cascaded media_files rows // - item_people.item_id — cast and author credits // - provider_ids.item_id — external metadata matches // - metadata_fetch.item_id — provider attempt history // - assets.item_id — covers, thumbnails, extracted pages // - collection_items.item_id — collection membership // - favorites.item_id // - item_views.item_id — per-user view counts // - game_saves.item_id — emulator save states // - child media_items — the parent_id self-reference // // The WHERE clause is deliberately narrow: an item is purged only when it is // flagged missing AND has no present file, so an item missing one track while // its other tracks remain on disk is never touched. func PurgeMissing(ctx context.Context, db *sql.DB, libraryID int64) (int64, error) { res, err := db.ExecContext(ctx, ` DELETE FROM media_items WHERE library_id = ? AND missing = 1 AND NOT EXISTS ( SELECT 1 FROM media_files f WHERE f.item_id = media_items.id AND f.missing = 0 )`, libraryID) if err != nil { return 0, fmt.Errorf("library: purge missing: %w", err) } n, err := res.RowsAffected() if err != nil { return 0, fmt.Errorf("library: purge missing: %w", err) } return n, nil } // ── helpers ──────────────────────────────────────────────────────────── func resolveRoot(rootPath string) (string, error) { if strings.TrimSpace(rootPath) == "" { return "", fmt.Errorf("%w: path is empty", ErrInvalidRoot) } abs, err := filepath.Abs(rootPath) if err != nil { return "", fmt.Errorf("%w: %v", ErrInvalidRoot, err) } if resolved, err := filepath.EvalSymlinks(abs); err == nil { abs = resolved } else { return "", fmt.Errorf("%w: %v", ErrInvalidRoot, err) } info, err := os.Stat(abs) if err != nil { return "", fmt.Errorf("%w: %v", ErrInvalidRoot, err) } if !info.IsDir() { return "", fmt.Errorf("%w: %s is not a directory", ErrInvalidRoot, abs) } // Confirm readability; a directory can exist and still deny access. f, err := os.Open(abs) if err != nil { return "", fmt.Errorf("%w: %v", ErrInvalidRoot, err) } _ = f.Close() return abs, nil } func queryLibraries(ctx context.Context, db *sql.DB, q string, args ...any) ([]Library, error) { rows, err := db.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("library: query: %w", err) } defer rows.Close() out := []Library{} for rows.Next() { lib, err := scanLibrary(rows) if err != nil { return nil, err } out = append(out, lib) } return out, rows.Err() } type scanner interface{ Scan(dest ...any) error } func scanLibrary(row scanner) (Library, error) { var lib Library var created string if err := row.Scan(&lib.ID, &lib.Name, &lib.Kind, &lib.RootPath, &created); err != nil { if errors.Is(err, sql.ErrNoRows) { return Library{}, ErrNotFound } return Library{}, err } lib.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) return lib, nil } func isUnique(err error) bool { if err == nil { return false } msg := strings.ToUpper(err.Error()) return strings.Contains(msg, "UNIQUE CONSTRAINT FAILED") || strings.Contains(msg, "CONSTRAINT FAILED: UNIQUE") }