Files
bookhoard/internal/services/library_service.go
T
John O'Keefe 6216bd9bcf feat(audiobooks): multi-file books (folder-of-tracks + zip) + exhaustive metadata ingestion
Multi-file book ingest: a directory of ≥2 audio files (recursively —
disc-part folders like CD1/Disc 2 merge upward, siblings never merge)
ingests as ONE book whose file_path is the book-root directory; a zip
of audio tracks is its own book (file_path = the archive). Both write
the documented chapters abstraction {index,title,start,end} into
audio_metadata and add additive keys: layout ("folder"|"zip") and a
tracks[] manifest (per-track path, global-seconds start/end, duration,
size). Pre-grouping one-track strays under a book root are absorbed on
rescan (annotations cascade). Rescans take a stat-only fast path
(track count + total size + known durations unchanged → no re-probe —
a 1189-track book must not re-ffprobe every auto-scan); a zero-duration
book counts as changed so a ffprobe-equipped rescan fills durations in.
Folder-book roots count as present in the archive lifecycle pass, and
per-item rescan (RescanMediaItem) routes multi-file books through a
book-level refresh — the generic updater would read the root/zip as if
it were the book file and clobber format_group with "unknown".

Serving: GET /api/media-items/:id/chapters gains additive layout +
tracks keys (urls → the new endpoint). New GET
/api/media-items/:id/audio/:trackIndex streams one track — folder
tracks served from the real file (full Range via ServeContent), zip
tracks via a reopen-and-skip ReadSeeker over the entry (deflate has no
random access; acceptable at audio bitrates; stored entries stay
section readers). Both endpoints gate on library visibility (403 on
hidden libraries — family-lens rule). ServeFile and OPDS DownloadBook
refuse directory paths (folder books have no single-file download).

Web player (next commit) consumes the manifest; progress/bookmarks/
deep-links keep their global-seconds coordinates, so the server-side
progress model is untouched. sync learns FormatGroupAudiobook
(percentage passthrough in ConvertProgress); detectFormatType stops
reporting "unknown" for audio.

Metadata ingestion (Batch B) — read everything available, degrade
gracefully, documented precedence sidecars → folder structure →
embedded tags → filename: ffprobe tags are now a case-insensitive
lookup map (freeform keys pass through verbatim); composer fills
narrators (Audible's own convention, kept as ONE string — no reliable
multi-narrator delimiter); grouping + Audible freeform atoms
(SERIES/SERIES-PART/SUBTITLE/ASIN/ABRIDGED) fill series/index/
subtitle-join/ASIN/abridged (explicit sidecar abridged-false is
guarded); date/genre fall back to tags; album/album_artist/artist
fill title/author. Genre persists at create for all formats.
Embedded cover art (mp3 APIC, m4b covr, flac picture) extracts via one
optional ffmpeg call as a "<book>.cover.jpg" sibling — sidecar covers
win. CUE sheets (FILE-matched, INDEX 01 at 75fps) are the lowest-
priority chapter source under sidecars and embedded m4b chapters. A
lone m3u/m3u8 in a book root imposes playlist ordering + #EXTINF
names; (disc, track) tags reorder tracks when coverage is complete.
extractMetadata's default branch no longer pre-fills the filename as
title (tags must outrank it) — final filename fallbacks now exist on
BOTH the create and rescan paths (TestRescanKeepsFilenameTitle pins
that rescans cannot blank a title); tagless zip books take the zip's
own name, not their parent folder's.

Accepted containers widen to .m4b .m4a .mp3 .flac .zip .opus .ogg .oga
.aac .wav with real media types registered (audio/ogg per RFC 7845 for
Opus-in-Ogg) — all browser-playable; .wma deliberately excluded.

Tests: unit tests for CUE parsing, playlist hints, tag fallbacks
(synthetic ffprobe reports — ffmpeg cannot write Audible freeform
atoms), ordinal parsing, tag ordering, natural sort, grouping rules;
integration suite (11 tests) over the live dev DB: folder/zip ingest +
idempotency, stray absorption, chapters manifest, per-track streaming
with Range, hidden-library 403s, embedded covers + sidecar-wins, CUE
chapters, embedded m4b chapters, playlist ordering, tag fallbacks, all
five new containers with content-type assertions, rescan title pin.
Fixture media is real committed-format binary content living untracked
under internal/services/testdata/ (gitignored by policy; recipes in
docs/contributing/development.md → Media fixtures). Test image now
ships ffmpeg to match the runtime's audio toolchain.
2026-10-19 07:54:28 -04:00

386 lines
13 KiB
Go

package services
import (
"bookhoard/internal/database"
"context"
"fmt"
"github.com/google/uuid"
"log"
"os"
"path/filepath"
"strings"
"github.com/jackc/pgx/v5/pgtype"
)
type LibraryService struct {
db *database.Queries
}
func NewLibraryService(db *database.Queries) *LibraryService {
return &LibraryService{
db: db,
}
}
// Library type definitions and file extensions
const (
LibraryTypeEbooks = "ebooks"
LibraryTypeComics = "comics"
LibraryTypeManga = "manga"
LibraryTypeAudiobooks = "audiobooks"
)
var AllowedExtensions = map[string][]string{
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".docx", ".fb2", ".pdb"},
LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
LibraryTypeManga: {".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
// All four decided breadth formats registered from day one (the
// DOC/LIT doctrine: extensions are a deliberate list); only m4b
// scans richly in Batch A — the chapter abstraction carries the rest.
// .zip joins as the zip-of-audio book container (multi-file ingest);
// comics libraries keep using .cbz so the two never collide. The
// browser-playable lossless/next-gen containers join for Batch B
// metadata parity (.wma deliberately absent: browsers can't play it).
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac", ".zip", ".opus", ".ogg", ".oga", ".aac", ".wav"},
}
var MimeTypes = map[string]string{
// Images (manga)
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".bmp": "image/bmp",
".webp": "image/webp",
".avif": "image/avif",
".tiff": "image/tiff",
".tif": "image/tiff",
// Comics
".cbz": "application/vnd.comicbook+zip",
".cbr": "application/vnd.comicbook-rar",
".cb7": "application/x-cb7",
".cbt": "application/x-cbt",
// Ebooks
".epub": "application/epub+zip",
".pdf": "application/pdf",
".mobi": "application/x-mobipocket-ebook",
".azw": "application/vnd.amazon.ebook",
".azw3": "application/vnd.amazon.ebook",
".txt": "text/plain",
".rtf": "application/rtf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".fb2": "application/x-fictionbook+xml",
".pdb": "application/vnd.palm",
// Audiobooks — audio/mp4 for the MP4-family containers is what
// every platform decoder expects from an <audio>/ExoPlayer source.
".m4b": "audio/mp4",
".m4a": "audio/mp4",
".mp3": "audio/mpeg",
".flac": "audio/flac",
// Opus is registered as audio/ogg per RFC 7845 (Ogg encapsulation);
// every browser <audio> element accepts it that way.
".opus": "audio/ogg",
".ogg": "audio/ogg",
".oga": "audio/ogg",
".aac": "audio/aac",
".wav": "audio/wav",
// Zip-of-audio book container (audiobooks libraries only).
".zip": "application/zip",
}
// GetLibraryTypes retrieves all available library types
func (s *LibraryService) GetLibraryTypes(ctx context.Context) ([]database.LibraryTypes, error) {
return s.db.GetLibraryTypes(ctx)
}
// CreateLibrary creates a new library with the given parameters
func (s *LibraryService) CreateLibrary(ctx context.Context, name, description, libraryType string, adminID pgtype.UUID) (*database.Libraries, error) {
// Get library type ID
libType, err := s.db.GetLibraryTypeByName(ctx, libraryType)
if err != nil {
return nil, fmt.Errorf("invalid library type: %w", err)
}
// Create library
library, err := s.db.CreateLibrary(ctx, database.CreateLibraryParams{
Name: name,
Description: pgtype.Text{String: description, Valid: true},
LibraryTypeID: libType.ID,
CreatedByAdminID: adminID,
})
if err != nil {
return nil, fmt.Errorf("failed to create library: %w", err)
}
return &library, nil
}
// GetLibrary retrieves a library by ID with type information
func (s *LibraryService) GetLibrary(ctx context.Context, libraryID pgtype.UUID) (*database.GetLibraryRow, error) {
library, err := s.db.GetLibrary(ctx, libraryID)
if err != nil {
return nil, err
}
return &library, nil
}
// ListLibraries retrieves all libraries
func (s *LibraryService) ListLibraries(ctx context.Context) ([]database.ListLibrariesRow, error) {
return s.db.ListLibraries(ctx)
}
// UpdateLibrary updates an existing library
func (s *LibraryService) UpdateLibrary(ctx context.Context, libraryID pgtype.UUID, name, description string) (*database.Libraries, error) {
library, err := s.db.UpdateLibrary(ctx, database.UpdateLibraryParams{
ID: libraryID,
Name: name,
Description: pgtype.Text{String: description, Valid: true},
})
if err != nil {
return nil, err
}
return &library, nil
}
// DeleteLibrary deletes a library and all its associated data
func (s *LibraryService) DeleteLibrary(ctx context.Context, libraryID pgtype.UUID) error {
return s.db.DeleteLibrary(ctx, libraryID)
}
// AddLibraryFolder adds a folder to a library
func (s *LibraryService) AddLibraryFolder(ctx context.Context, libraryID pgtype.UUID, folderPath string) (*database.LibraryFolders, error) {
folder, err := s.db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: libraryID,
FolderPath: folderPath,
})
if err != nil {
return nil, err
}
return &folder, nil
}
// GetLibraryFolders retrieves all folders for a library
func (s *LibraryService) GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID) ([]database.LibraryFolders, error) {
return s.db.GetLibraryFolders(ctx, libraryID)
}
// DeleteLibraryFolder removes a folder from a library
func (s *LibraryService) DeleteLibraryFolder(ctx context.Context, libraryID pgtype.UUID, folderPath string) error {
_, err := s.db.DeleteLibraryFolder(ctx, database.DeleteLibraryFolderParams{
LibraryID: libraryID,
FolderPath: folderPath,
})
return err
}
// SetLibraryVisibility sets library visibility for a user
func (s *LibraryService) SetLibraryVisibility(ctx context.Context, userID, libraryID pgtype.UUID, isVisible bool) (*database.LibraryVisibility, error) {
visibility, err := s.db.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{
UserID: userID,
LibraryID: libraryID,
IsVisible: isVisible,
})
if err != nil {
return nil, err
}
return &visibility, nil
}
// GetUserVisibleLibraries retrieves all libraries visible to a user.
// VisibleLibrarySet is THE canonical library-visibility resolver (H4.5):
// every endpoint that asks "which libraries can this user see" funnels
// here. The family-lens rule itself lives in the
// GetUserVisibleLibraries SQL (COALESCE(lv.is_visible, true) —
// default-allow: an admin hides a library, everyone else still sees it).
// Callers adapt their own identity source (JWT user object, device
// token, SSR session); this core takes the resolved user id.
func VisibleLibrarySet(ctx context.Context, db *database.Queries, userID pgtype.UUID) (map[string]bool, []pgtype.UUID, error) {
rows, err := db.GetUserVisibleLibraries(ctx, userID)
if err != nil {
return nil, nil, fmt.Errorf("failed to check library access: %w", err)
}
set := make(map[string]bool, len(rows))
ids := make([]pgtype.UUID, 0, len(rows))
for _, lib := range rows {
if !lib.ID.Valid {
continue
}
set[uuid.UUID(lib.ID.Bytes).String()] = true
ids = append(ids, lib.ID)
}
return set, ids, nil
}
// VisibleLibrarySet is the LibraryService entry point for the
// package-level core (see the core's doc).
func (s *LibraryService) VisibleLibrarySet(ctx context.Context, userID pgtype.UUID) (map[string]bool, []pgtype.UUID, error) {
return VisibleLibrarySet(ctx, s.db, userID)
}
func (s *LibraryService) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]database.GetUserVisibleLibrariesRow, error) {
return s.db.GetUserVisibleLibraries(ctx, userID)
}
// IsFileExtensionAllowed checks if a file extension is allowed for a library type
func (s *LibraryService) IsFileExtensionAllowed(libraryType, extension string) bool {
extensions, exists := AllowedExtensions[libraryType]
if !exists {
return false
}
for _, ext := range extensions {
if strings.EqualFold(ext, extension) {
return true
}
}
return false
}
// GetLibraryFileExtensions returns all allowed file extensions for a library type
func (s *LibraryService) GetLibraryFileExtensions(libraryType string) []string {
extensions, exists := AllowedExtensions[libraryType]
if !exists {
return []string{}
}
return extensions
}
// GetLibraryTypeFromFileExtension determines the library type based on file extension
func (s *LibraryService) GetLibraryTypeFromFileExtension(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
for libType, extensions := range AllowedExtensions {
for _, allowedExt := range extensions {
if ext == allowedExt {
return libType
}
}
}
return ""
}
// ValidateLibraryPath checks if a path is valid for the given library type
func (s *LibraryService) ValidateLibraryPath(libraryType, folderPath string) error {
// You could add more validation here like:
// - Check if path exists
// - Check if path is readable
// - Validate path format for specific library types
// - Check for appropriate file structures (e.g., for manga with image folders)
return nil
}
// GetLibraryStats returns statistics for a library (media count, etc.)
func (s *LibraryService) GetLibraryStats(ctx context.Context, libraryID pgtype.UUID) (map[string]interface{}, error) {
// For now, return basic info. This can be expanded with more detailed stats
mediaItems, err := s.db.ListMediaItemsByLibrary(ctx, libraryID)
if err != nil {
return nil, err
}
return map[string]interface{}{
"media_count": len(mediaItems),
}, nil
}
// HasFolders checks if a library has at least one folder configured
func (s *LibraryService) HasFolders(ctx context.Context, libraryID pgtype.UUID) (bool, error) {
folders, err := s.db.GetLibraryFolders(ctx, libraryID)
if err != nil {
return false, fmt.Errorf("failed to check library folders: %w", err)
}
return len(folders) > 0, nil
}
// BrowseDirectories lists directories at a given path for folder browser UI
// Returns: (directories, currentPath, parentPath, error)
func (s *LibraryService) BrowseDirectories(ctx context.Context, path string) ([]string, string, string, error) {
// Security: path traversal protection
if strings.Contains(path, "..") {
return nil, "", "", fmt.Errorf("path traversal not allowed")
}
cleanPath := filepath.Clean(path)
// Check if path exists and is accessible
fileInfo, err := os.Stat(cleanPath)
if err != nil {
if os.IsNotExist(err) {
return nil, "", "", fmt.Errorf("path does not exist")
}
return nil, "", "", fmt.Errorf("path not accessible: %w", err)
}
if !fileInfo.IsDir() {
return nil, "", "", fmt.Errorf("not a directory")
}
// Read directory contents
entries, err := os.ReadDir(cleanPath)
if err != nil {
return nil, "", "", fmt.Errorf("failed to read directory: %w", err)
}
// Filter only directories
var dirs []string
for _, entry := range entries {
if entry.IsDir() {
dirs = append(dirs, entry.Name())
}
}
// Get parent path for navigation
parentPath := filepath.Dir(cleanPath)
if parentPath == cleanPath {
parentPath = "" // At root
}
return dirs, cleanPath, parentPath, nil
}
// SyncAllowedExtensions syncs the Go AllowedExtensions map into the database.
// This ensures library_types.allowed_extensions stays in sync with the Go source of truth.
func (s *LibraryService) SyncAllowedExtensions(ctx context.Context) {
for typeName, exts := range AllowedExtensions {
if err := s.db.SyncLibraryTypeExtensions(ctx, database.SyncLibraryTypeExtensionsParams{
Name: typeName,
AllowedExtensions: exts,
}); err != nil {
log.Printf("Warning: failed to sync allowed extensions for library type %s: %v", typeName, err)
}
}
}
func (s *LibraryService) ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
// Typed-nil interface guard: a nil *LibraryService held in an
// interface passes `!= nil` checks at call sites; degrade instead
// of panicking (kobo markup 500s were exactly this path).
if s == nil || s.db == nil {
return "", fmt.Errorf("library service not configured")
}
// Get library folders for this library
folders, err := s.db.GetLibraryFolders(ctx, libraryID)
if err != nil || len(folders) == 0 {
return "", fmt.Errorf("no library folders found for library")
}
// Try each folder - find one where the relative path makes sense
for _, folder := range folders {
fullPath := filepath.Join(folder.FolderPath, relativePath)
if _, err := os.Stat(fullPath); err == nil {
return fullPath, nil
}
}
// Fallback: use first folder (file might not exist yet during scan)
if len(folders) > 0 {
return filepath.Join(folders[0].FolderPath, relativePath), nil
}
return "", fmt.Errorf("could not resolve path")
}