* fix(progress): track resume points independently of watched state
Re-watching a finished item never re-entered Continue Watching: completion
latched completed = TRUE one-way, pinned position_seconds to the duration,
and the resume query filtered on completed = FALSE — so a rewatch heartbeat
could never surface the item again (and releasing the latch would have
erased the watched state clients display).
Adopt the Jellyfin invariant instead of guard heuristics:
- Completion resets position_seconds to 0 (UpdateProgress, SetProgress,
SetProgressAt, SetProgressIfNewer, MarkWatched, MarkProgressBatch), so
position_seconds > 0 now means "live resume point".
- completed stays a pure one-way watched latch; rewatch heartbeats re-enter
Continue Watching through plain GREATEST/MAX while the watched flag and
PlayCount survive (matching Plex and Jellyfin master).
- ListProgress("in_progress") keys on position_seconds > 0 in both stores;
the SQLite store also gains the min-resume floor the Postgres store had.
- jellycompat reports Played=true with live PositionTicks during a rewatch
(resumePositionTicks no longer zeroes played items) — the DTO shape real
Jellyfin emits since jellyfin/jellyfin#15762.
- Web mirrors the latch (playbackProgressCache), resumes rewatches at their
stored position, and shows progress bars on rewatched episodes.
- ABS audiobook surfaces keep today's behavior: finished books report 100%
via the completed flag and Continue Listening still excludes them.
- Migrations reset legacy completed rows (position pinned to duration) to
0: a Goose migration for Postgres and a user_version-gated one-time fix
for the per-user SQLite DBs.
Replaces the guard-based approach of #109, whose restart detection
(50% fraction + 60s time gap) could never release the latch for immediate
rewatches (blocked heartbeats refreshed updated_at, re-arming the gap) and
un-watched items on position-0 heartbeats.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(progress): address review — migration gate, one-way latch, missed writers/readers
Review fixes for the position-based watch-progress model:
- The per-user SQLite data fix is now migrateToV11 in the existing
versioned runMigrations chain (schemaVersion 11). The previous
standalone PRAGMA gate compared against 1, but existing DBs already
sit at user_version 10, so the reset never ran for them — and the
gate would have rewound the version. Fresh DBs short-circuit to the
current version as before.
- `completed` is now one-way across every playback/sync writer:
SetProgress (the RecordPlaybackStop path — stopping a rewatch below
the watched threshold no longer clears the watched state),
SetProgressAt, SetProgressIfNewer (both stores), and the history
import upsert, which also stops pinning completed imports to
position = duration. Mark-unwatched still releases the latch via
ClearProgress/ClearProgressBatch.
- MarkProgressBatch regains its freshness guard: a delayed batch mark
carrying an old timestamp can no longer zero a newer rewatch resume
point (the position-reset now rides the original updated_at check).
- Catalog read paths align with the new in-progress definition
(position_seconds > 0, completed-agnostic): smart-collection
in_progress filter, progress sort ratio, episode progress CTE, and
both next-up predicates.
- jellycompat derives PlayedPercentage and PlaybackPositionTicks from
the same clamped position; a played item at rest reports 100 (as the
old model did) while a rewatch reports its live fraction.
- ABS audiobook UpsertProgress stores position 0 on finish so finished
books can't surface as phantom resume entries; re-listens still move
position forward from 0 with the latch intact.
- The web optimistic cache zeroes the resume point on completion,
mirroring the server invariant until the refetch lands.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
231 lines
7.9 KiB
Go
231 lines
7.9 KiB
Go
package audiobooks
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
|
|
)
|
|
|
|
// ABSProgressStore implements abs.ProgressStore directly against the
|
|
// user_watch_progress table using a shared pgxpool. Using the pool directly
|
|
// (rather than the per-user-scoped PostgresUserStore) lets us query by
|
|
// (user_id, profile_id) without needing a ForUser call, which would require
|
|
// knowing the integer user_id at construction time. The ABS handlers carry
|
|
// user_id as a string and resolve it inline here.
|
|
type ABSProgressStore struct {
|
|
Pool *pgxpool.Pool
|
|
}
|
|
|
|
var _ abs.ProgressStore = (*ABSProgressStore)(nil)
|
|
|
|
// GetProgress returns the progress row for (userID, profileID, contentID).
|
|
// Returns (nil, nil) when no row exists.
|
|
func (s *ABSProgressStore) GetProgress(ctx context.Context, userID, profileID, contentID string) (*abs.ProgressRow, error) {
|
|
uid, err := strconv.Atoi(userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("abs_progress_store: invalid user_id %q: %w", userID, err)
|
|
}
|
|
var row abs.ProgressRow
|
|
var updatedAt time.Time
|
|
var positionSeconds, durationSeconds float64
|
|
var completed bool
|
|
var progressPct *float64
|
|
|
|
// Completed rows store position_seconds = 0 (no resume point), so the
|
|
// percentage must come from the completed flag, not the position.
|
|
dbRow := s.Pool.QueryRow(ctx, `
|
|
SELECT media_item_id, position_seconds, duration_seconds, completed,
|
|
CASE WHEN completed THEN 1.0
|
|
WHEN duration_seconds > 0 THEN position_seconds / duration_seconds
|
|
ELSE 0 END AS progress_pct,
|
|
updated_at
|
|
FROM user_watch_progress
|
|
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`,
|
|
uid, profileID, contentID,
|
|
)
|
|
err = dbRow.Scan(
|
|
&row.ContentID, &positionSeconds, &durationSeconds, &completed,
|
|
&progressPct, &updatedAt,
|
|
)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("abs_progress_store: get progress: %w", err)
|
|
}
|
|
|
|
row.UserID = userID
|
|
row.ProfileID = profileID
|
|
row.CurrentSeconds = positionSeconds
|
|
row.DurationSeconds = durationSeconds
|
|
row.IsFinished = completed
|
|
if progressPct != nil {
|
|
row.ProgressPct = *progressPct
|
|
}
|
|
row.UpdatedAt = updatedAt
|
|
return &row, nil
|
|
}
|
|
|
|
// ListProgressForAudiobooks returns all progress rows for (userID, profileID)
|
|
// that join to media_items with type = 'audiobook'. Ordered by updated_at DESC,
|
|
// capped at limit rows.
|
|
func (s *ABSProgressStore) ListProgressForAudiobooks(ctx context.Context, userID, profileID string, limit int) ([]abs.ProgressRow, error) {
|
|
uid, err := strconv.Atoi(userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("abs_progress_store: invalid user_id %q: %w", userID, err)
|
|
}
|
|
if limit <= 0 {
|
|
limit = 500
|
|
}
|
|
rows, err := s.Pool.Query(ctx, `
|
|
SELECT wp.media_item_id,
|
|
wp.position_seconds,
|
|
wp.duration_seconds,
|
|
wp.completed,
|
|
CASE WHEN wp.completed THEN 1.0
|
|
WHEN wp.duration_seconds > 0 THEN wp.position_seconds / wp.duration_seconds
|
|
ELSE 0 END,
|
|
wp.updated_at
|
|
FROM user_watch_progress wp
|
|
JOIN media_items mi ON mi.content_id = wp.media_item_id
|
|
WHERE wp.user_id = $1
|
|
AND wp.profile_id = $2
|
|
AND mi.type = 'audiobook'
|
|
ORDER BY wp.updated_at DESC
|
|
LIMIT $3`,
|
|
uid, profileID, limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("abs_progress_store: list progress: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []abs.ProgressRow
|
|
for rows.Next() {
|
|
var p abs.ProgressRow
|
|
var updatedAt time.Time
|
|
if err := rows.Scan(
|
|
&p.ContentID,
|
|
&p.CurrentSeconds,
|
|
&p.DurationSeconds,
|
|
&p.IsFinished,
|
|
&p.ProgressPct,
|
|
&updatedAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("abs_progress_store: scan progress row: %w", err)
|
|
}
|
|
p.UserID = userID
|
|
p.ProfileID = profileID
|
|
p.UpdatedAt = updatedAt
|
|
result = append(result, p)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("abs_progress_store: iterate progress rows: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// UpsertProgress inserts or updates a user_watch_progress row. Conflict
|
|
// updates merge monotonically so concurrent writes cannot un-finish an item or
|
|
// rewind progress with a stale position.
|
|
func (s *ABSProgressStore) UpsertProgress(ctx context.Context, row abs.ProgressRow) error {
|
|
uid, err := strconv.Atoi(row.UserID)
|
|
if err != nil {
|
|
return fmt.Errorf("abs_progress_store: invalid user_id %q: %w", row.UserID, err)
|
|
}
|
|
updatedAt := row.UpdatedAt
|
|
if updatedAt.IsZero() {
|
|
updatedAt = time.Now().UTC()
|
|
}
|
|
// Completed rows hold no resume point (position_seconds = 0) — the same
|
|
// invariant as the userstore write paths — so finished books can't leak
|
|
// into position-based continue-watching/listening queries. A later
|
|
// non-finished report (re-listen) moves position forward from 0 while
|
|
// the completed latch stays.
|
|
position := row.CurrentSeconds
|
|
if row.IsFinished {
|
|
position = 0
|
|
}
|
|
_, err = s.Pool.Exec(ctx, `
|
|
INSERT INTO user_watch_progress
|
|
(user_id, profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
ON CONFLICT (user_id, profile_id, media_item_id) DO UPDATE SET
|
|
position_seconds = CASE WHEN EXCLUDED.completed THEN 0
|
|
ELSE GREATEST(user_watch_progress.position_seconds, EXCLUDED.position_seconds) END,
|
|
duration_seconds = GREATEST(user_watch_progress.duration_seconds, EXCLUDED.duration_seconds),
|
|
completed = user_watch_progress.completed OR EXCLUDED.completed,
|
|
updated_at = GREATEST(user_watch_progress.updated_at, EXCLUDED.updated_at)`,
|
|
uid, row.ProfileID, row.ContentID,
|
|
position, row.DurationSeconds, row.IsFinished, updatedAt,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("abs_progress_store: upsert progress: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateProgressPosition updates only the position_seconds column for an
|
|
// existing row. If no row exists this is a no-op (the session-sync path
|
|
// that calls this only needs to move the cursor, not create a progress row
|
|
// for the first time; that's done when the user explicitly sets progress).
|
|
func (s *ABSProgressStore) UpdateProgressPosition(ctx context.Context, userID, profileID, contentID string, positionSeconds float64) error {
|
|
uid, err := strconv.Atoi(userID)
|
|
if err != nil {
|
|
return fmt.Errorf("abs_progress_store: invalid user_id %q: %w", userID, err)
|
|
}
|
|
_, err = s.Pool.Exec(ctx, `
|
|
UPDATE user_watch_progress
|
|
SET position_seconds = $4,
|
|
updated_at = now()
|
|
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`,
|
|
uid, profileID, contentID, positionSeconds,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("abs_progress_store: update progress position: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteProgress removes the progress row entirely. Idempotent on missing-row.
|
|
// Used by the ABS "Reset Progress" / clear-progress affordance.
|
|
func (s *ABSProgressStore) DeleteProgress(ctx context.Context, userID, profileID, contentID string) error {
|
|
uid, err := strconv.Atoi(userID)
|
|
if err != nil {
|
|
return fmt.Errorf("abs_progress_store: invalid user id %q: %w", userID, err)
|
|
}
|
|
if _, err := s.Pool.Exec(ctx, `
|
|
DELETE FROM user_watch_progress
|
|
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`,
|
|
uid, profileID, contentID,
|
|
); err != nil {
|
|
return fmt.Errorf("abs_progress_store: delete progress: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetHideFromContinue sets the hide_from_continue flag for the given
|
|
// progress row. Idempotent on missing-row.
|
|
func (s *ABSProgressStore) SetHideFromContinue(ctx context.Context, userID, profileID, contentID string, hide bool) error {
|
|
uid, err := strconv.Atoi(userID)
|
|
if err != nil {
|
|
return fmt.Errorf("abs_progress_store: invalid user id %q: %w", userID, err)
|
|
}
|
|
if _, err := s.Pool.Exec(ctx, `
|
|
UPDATE user_watch_progress
|
|
SET hide_from_continue = $4
|
|
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`,
|
|
uid, profileID, contentID, hide,
|
|
); err != nil {
|
|
return fmt.Errorf("abs_progress_store: set hide_from_continue: %w", err)
|
|
}
|
|
return nil
|
|
}
|