Files
bookhoard/internal/services/library_service.go
T
John O'Keefe 902cbe5915 feat(audiobooks): Batch A — foundation, m4b ingest, playback sync, web player
Phase 1 (foundation): 'audiobooks' library type seeded (schema + DB);
audio_metadata JSONB on media_items (the kobo_metadata precedent —
only audio paths read it); reading_progress.audio_offset_seconds for
the honest {chapter, offset} locator; extensions/mimes registered for
ALL breadth formats (m4b/m4a/mp3/flac — deliberate per the DOC/LIT
doctrine, m4b scans richly first); classifyFormatGroup 'audiobook'
case (gotcha 39 recomputes on rescan); Go mime table extended at boot
so media elements accept the streams; ffmpeg/ffprobe in the image
(8.1.2 — also the xHE-AAC identification tripwire).

Phase 2 (ingest): sidecar-first (Libation/ABS metadata.json —
narrators/ASIN/curated chapters), ffprobe fallback/filler (duration,
bitrate, codec/profile, embedded chapters). Fixed three real
parser brittlenesses the Libation sidecar exposed: series accepts
string OR array-of-objects, publishedYear accepts number OR string,
ffprobe chapter timings accept number OR string (flexFloat). Folder-
structure fallback now runs on the UPDATE path too (rescans were
clearing sidecar-absent series/author); audio facts re-derive on
every rescan (gotcha 39).

Phase 3 (playback + sync): GET /api/media-items/:id/chapters (the
format-abstraction list); streaming rides the existing cookie-authed
Range /uploads path; PUT progress carries audio_offset_seconds +
elapsed_seconds (honest wall-clock playing time) — the reading-
history capture prefers player-reported elapsed over the inter-push
delta, same 2h ceiling; reading_mode='audio' marks the row.

Phase 4 (web player): dedicated audio reader page (foliate not
involved) — cover, chapter title, whole-book scrubber, skip ± (chips
1/5/10/15/30s, default 10s), speed (0.75×–2×), chapter list with
jump-to, Media Session lockscreen/notification transport, periodic
playing-only progress pushes, position restore on open.

Verified live: ingest of the Libation sample (Gardens of the Moon —
title/author/series/cover/date from sidecar+folders, 34 chapters,
26h duration, aac/LC); chapters endpoint; the reader route serving
the player; playback with position restore (0:57 round-trip);
progress rows in audio mode; wall-clock session rows in
reading_history.
2026-10-15 16:14:34 -04:00

341 lines
11 KiB
Go

package services
import (
"bookhoard/internal/database"
"context"
"fmt"
"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.
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac"},
}
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",
}
// 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
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")
}