// 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" "github.com/mithral/vellum/internal/core" ) // 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), core.Stamp(now)) 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 }