- 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.
71 lines
2.1 KiB
Go
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
|
|
}
|