Vellum/internal/media/media.go
mithral 5c63932419 Phase 3: EPUB module, file streaming, per-user progress
- stream: serving goes through http.ServeContent, so Range/If-Range/
  If-Modified-Since are correct for every media type by construction
- progress: the polymorphic store. One row per user/item/file; the PK includes
  the file so multi-track audiobooks resume per track later. Finished is
  inferred when position reaches duration.
- media: generic item endpoints (detail, file, cover, progress, recent). Every
  one routes through auth.CanAccessLibrary; an unauthorised item 404s.
- covers: a per-kind extractor registry. Extraction is lazy and recorded in
  the assets table. A missing cover is a placeholder, never a 500.
- modules/ebooks: EPUB parsing (container.xml -> OPF -> metadata, spine, cover
  with three fallback strategies), cover extraction to /config, and lazy
  metadata parsing so parser fixes need no rescan.

Verified against a generated EPUB: title/2 authors/language/publisher/spine,
cover served as PNG, range request returned 206 with correct Content-Range,
progress round-tripped.
2026-10-09 16:36:27 -04:00

71 lines
2.1 KiB
Go

// Package media serves generic item endpoints: detail, the raw file, cover
// art, and progress. It knows nothing about any specific media format; modules
// register cover extractors and expose their own metadata endpoints.
package media
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
)
// ErrItemNotFound means no item matched.
var ErrItemNotFound = errors.New("media: item not found")
// Item is one media item plus the root of its library.
type Item struct {
ID int64
LibraryID int64
Kind string
Path string // path relative to the library root
Title string
Year int
Missing bool
// RootPath is the absolute library root, so AbsPath can be computed.
RootPath string
}
// AbsPath returns the absolute filesystem path of the item's file.
//
// item.path is stored relative to the library root and the root is stored
// resolved, so this join cannot escape the library.
func (i Item) AbsPath() string {
return filepath.Join(i.RootPath, filepath.FromSlash(i.Path))
}
// LoadItem loads an item and its library root.
func LoadItem(ctx context.Context, db *sql.DB, id int64) (Item, error) {
var it Item
err := db.QueryRowContext(ctx, `
SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year, 0), i.missing, l.root_path
FROM media_items i
JOIN libraries l ON l.id = i.library_id
WHERE i.id = ?`, id,
).Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year, &it.Missing, &it.RootPath)
if errors.Is(err, sql.ErrNoRows) {
return Item{}, ErrItemNotFound
}
if err != nil {
return Item{}, fmt.Errorf("media: load item: %w", err)
}
return it, nil
}
// FileEntry is one physical file backing an item.
type FileEntry struct {
ID int64 `json:"id"`
Path string `json:"path"`
Size int64 `json:"size"`
Container string `json:"container"`
Missing bool `json:"missing"`
}
// FileCount returns how many files back an item.
func FileCount(ctx context.Context, db *sql.DB, itemID int64) (int, error) {
var n int
err := db.QueryRowContext(ctx, `SELECT count(*) FROM media_files WHERE item_id = ?`, itemID).Scan(&n)
return n, err
}