Vellum/internal/media/media.go
mithral c3b7db7114 Phase 4: CBZ comics module and reader
- comics: reads pages straight out of the archive on demand. No extraction
  pass, so a 200-page CBZ is not materialised for a reader who views three
  pages. Pages are read into memory and served via http.ServeContent, which
  gives Range for free.
- natural page ordering: page2 sorts before page10. Lexical ordering shuffles
  a numbered archive, which is the difference between a readable book and a
  scrambled one.
- skips __MACOSX and dotfiles; rejects archives with no images.
- cover is the first page.
- media.Item gained ModTime (from the backing file) so page responses carry a
  Last-Modified and support conditional GETs.
- web: ComicReader with a bounded prefetch window (two pages each way), page
  jump, keyboard nav, and progress stored as a page number.

Verified: 3-page CBZ served in natural order, out-of-range 404, cover from
page 1, progress round-trip. svelte-check 0/0.
2026-10-09 21:07:59 -04:00

97 lines
2.8 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"
"time"
)
// 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
// ModTime is the backing file's modification time, used for conditional
// GETs. Zero when unknown.
ModTime time.Time
}
// 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
var mtime string
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,
COALESCE((SELECT f.mtime FROM media_files f WHERE f.item_id = i.id ORDER BY f.id LIMIT 1), '')
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, &mtime)
if errors.Is(err, sql.ErrNoRows) {
return Item{}, ErrItemNotFound
}
if err != nil {
return Item{}, fmt.Errorf("media: load item: %w", err)
}
if nanos := parseUnixNano(mtime); nanos > 0 {
it.ModTime = time.Unix(0, nanos).UTC()
}
return it, nil
}
// parseUnixNano parses a base-10 nanosecond timestamp, returning 0 on anything
// that is not all digits.
func parseUnixNano(s string) int64 {
if s == "" {
return 0
}
var n int64
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return 0
}
n = n*10 + int64(s[i]-'0')
}
return n
}
// 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
}