- 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.
155 lines
4.7 KiB
Go
155 lines
4.7 KiB
Go
// Package progress stores and retrieves per-user playback/reading position.
|
|
//
|
|
// One table, one shape, every media kind: milliseconds for audio and video, a
|
|
// spine index or page number for text. Callers that don't fit the integer
|
|
// model should encode their position into it rather than adding a column.
|
|
package progress
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// ErrNoFile means an item has no media_files row to attach progress to.
|
|
var ErrNoFile = errors.New("progress: item has no file to track")
|
|
|
|
// Progress is one user's position in one item's file.
|
|
type Progress struct {
|
|
ItemID int64 `json:"item_id"`
|
|
FileID int64 `json:"file_id"`
|
|
Kind string `json:"kind"`
|
|
Position int64 `json:"position"`
|
|
Duration int64 `json:"duration"`
|
|
Percent float64 `json:"percent"`
|
|
Finished bool `json:"finished"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// Get returns the most recently updated progress row for a user and item.
|
|
// The second return value is false when the user has never touched the item.
|
|
func Get(ctx context.Context, db *sql.DB, userID, itemID int64) (Progress, bool, error) {
|
|
var (
|
|
p Progress
|
|
finished int
|
|
updatedAt string
|
|
)
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT item_id, file_id, kind, position, duration, percent, finished, updated_at
|
|
FROM progress
|
|
WHERE user_id = ? AND item_id = ?
|
|
ORDER BY updated_at DESC
|
|
LIMIT 1`, userID, itemID,
|
|
).Scan(&p.ItemID, &p.FileID, &p.Kind, &p.Position, &p.Duration, &p.Percent, &finished, &updatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Progress{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return Progress{}, false, fmt.Errorf("progress: get: %w", err)
|
|
}
|
|
p.Finished = finished == 1
|
|
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt)
|
|
return p, true, nil
|
|
}
|
|
|
|
// Put upserts progress. A zero FileID resolves to the item's first file, which
|
|
// is what single-file items (epub, cbz) always are.
|
|
func Put(ctx context.Context, db *sql.DB, userID int64, p Progress) (Progress, error) {
|
|
if p.FileID == 0 {
|
|
fileID, err := firstFileID(ctx, db, p.ItemID)
|
|
if err != nil {
|
|
return Progress{}, err
|
|
}
|
|
p.FileID = fileID
|
|
}
|
|
if p.Kind == "" {
|
|
_ = db.QueryRowContext(ctx, `SELECT kind FROM media_items WHERE id = ?`, p.ItemID).Scan(&p.Kind)
|
|
}
|
|
|
|
if p.Percent < 0 {
|
|
p.Percent = 0
|
|
}
|
|
if p.Percent > 1 {
|
|
p.Percent = 1
|
|
}
|
|
// A timed item at 100% is finished even if the client did not say so.
|
|
if p.Duration > 0 && p.Position >= p.Duration {
|
|
p.Percent = 1
|
|
p.Finished = true
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
_, err := db.ExecContext(ctx, `
|
|
INSERT INTO progress (user_id, item_id, file_id, kind, position, duration, percent, finished, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT (user_id, item_id, file_id)
|
|
DO UPDATE SET kind = excluded.kind, position = excluded.position,
|
|
duration = excluded.duration, percent = excluded.percent,
|
|
finished = excluded.finished, updated_at = excluded.updated_at`,
|
|
userID, p.ItemID, p.FileID, p.Kind, p.Position, p.Duration, p.Percent, boolInt(p.Finished),
|
|
now.Format(time.RFC3339Nano))
|
|
if err != nil {
|
|
return Progress{}, fmt.Errorf("progress: put: %w", err)
|
|
}
|
|
p.UpdatedAt = now
|
|
return p, nil
|
|
}
|
|
|
|
// Delete clears progress for a user and item.
|
|
func Delete(ctx context.Context, db *sql.DB, userID, itemID int64) error {
|
|
_, err := db.ExecContext(ctx, `DELETE FROM progress WHERE user_id = ? AND item_id = ?`, userID, itemID)
|
|
return err
|
|
}
|
|
|
|
// RecentlyUpdated powers a "Continue reading/watching" shelf.
|
|
func RecentlyUpdated(ctx context.Context, db *sql.DB, userID int64, limit int) ([]Progress, error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 20
|
|
}
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT item_id, file_id, kind, position, duration, percent, finished, updated_at
|
|
FROM progress
|
|
WHERE user_id = ? AND finished = 0
|
|
ORDER BY updated_at DESC
|
|
LIMIT ?`, userID, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("progress: recent: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []Progress{}
|
|
for rows.Next() {
|
|
var p Progress
|
|
var finished int
|
|
var updatedAt string
|
|
if err := rows.Scan(&p.ItemID, &p.FileID, &p.Kind, &p.Position, &p.Duration, &p.Percent, &finished, &updatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
p.Finished = finished == 1
|
|
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt)
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func firstFileID(ctx context.Context, db *sql.DB, itemID int64) (int64, error) {
|
|
var id int64
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT id FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1`, itemID).Scan(&id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, ErrNoFile
|
|
}
|
|
if err != nil {
|
|
return 0, fmt.Errorf("progress: first file: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func boolInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|