- 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.
97 lines
2.8 KiB
Go
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
|
|
}
|