Marking a large series as watched only marked some episodes when the user
navigated away or closed the tab. POST /watched/{id} expanded a series to
every episode and then did four sequential DB round-trips per episode — a
duration lookup, a progress upsert, a stable-identity resolution (itself an
episode lookup plus a series provider-ID lookup), and a history insert — all
on the request context with no transaction. A 200-episode series was ~800
sequential queries, each committing on its own, so a disconnect mid-loop left
everything already committed in place.
Server:
- episodeTargets now resolves durations through the existing batched
listEpisodeFiles helper instead of one file query per episode. The shared
mediaFileDurationSeconds keeps the batched and single-item paths in step.
- ResolveHistoryIdentities resolves a whole series in one episode query plus
one provider-ID query per distinct series, behind optional-capability
interfaces with per-ID fallbacks.
- New userstore.WatchedBatchWriter capability, implemented transactionally in
both Postgres and SQLite, with a per-target fallback for stores that lack
it. recordMarkWatched and jellycompat's recordMarkWatchedBatch share it.
A cancelled request now rolls back to "nothing marked" rather than stranding
a half-watched series, which is the correct all-or-nothing semantic and needs
no detached context.
Client: the watched mutation sends keepalive so the request survives
navigation and tab close, plus an optimistic played flip with rollback,
following the existing favorites.ts pattern.
Routing jellycompat through the shared path would have clobbered known
durations with 0, since that caller supplies none; the batch upsert only ever
advances a duration, matching what MarkProgressBatch did before.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
278 lines
9.2 KiB
Go
278 lines
9.2 KiB
Go
package userstore
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type HistoryVisibilityStore interface {
|
|
VisibleHistoryTimestamps(ctx context.Context, profileID string, mediaItemIDs []string, at time.Time) (map[string]string, error)
|
|
}
|
|
|
|
type VisibleHistoryAdder interface {
|
|
AddVisibleHistory(ctx context.Context, entry WatchHistoryEntry) (WatchHistoryEntry, error)
|
|
}
|
|
|
|
func AddVisibleHistory(ctx context.Context, store UserStore, entry WatchHistoryEntry) (WatchHistoryEntry, error) {
|
|
if adder, ok := store.(VisibleHistoryAdder); ok {
|
|
return adder.AddVisibleHistory(ctx, entry)
|
|
}
|
|
entryTimes, err := VisibleHistoryTimestamps(ctx, store, entry.ProfileID, []string{entry.MediaItemID}, parseHistoryTimestamp(entry.WatchedAt))
|
|
if err != nil {
|
|
return entry, err
|
|
}
|
|
if entryTime := entryTimes[entry.MediaItemID]; entryTime != "" {
|
|
entry.WatchedAt = entryTime
|
|
}
|
|
if err := store.AddHistory(ctx, entry); err != nil {
|
|
return entry, err
|
|
}
|
|
return entry, nil
|
|
}
|
|
|
|
// MarkWatchedTarget is one leaf item to mark watched, carrying the duration
|
|
// that belongs on its progress row.
|
|
type MarkWatchedTarget struct {
|
|
MediaItemID string
|
|
DurationSeconds float64
|
|
}
|
|
|
|
// WatchedBatchWriter is an optional store capability: mark every target
|
|
// watched and insert its history row in a single transaction, so a canceled
|
|
// request leaves nothing marked instead of a partial subset. Marking a large
|
|
// series is the motivating case — the per-target fallback below commits each
|
|
// episode independently, so losing the request mid-loop strands the series
|
|
// half-watched.
|
|
//
|
|
// Semantics must match the fallback: per-target duration lands on the progress
|
|
// row, each entry gets exactly one history row, and both respect the
|
|
// hidden-history watermark the single-row MarkWatched/AddVisibleHistory paths
|
|
// apply. The returned entries carry the resolved (possibly watermark-adjusted)
|
|
// WatchedAt, in the order given.
|
|
type WatchedBatchWriter interface {
|
|
MarkWatchedBatch(ctx context.Context, profileID string, targets []MarkWatchedTarget, entries []WatchHistoryEntry) ([]WatchHistoryEntry, error)
|
|
}
|
|
|
|
// MarkWatchedBatch marks every target watched and records its history entry,
|
|
// atomically where the store supports it. targets and entries are parallel:
|
|
// entries[i] is the history row for targets[i].
|
|
func MarkWatchedBatch(ctx context.Context, store UserStore, profileID string, targets []MarkWatchedTarget, entries []WatchHistoryEntry) ([]WatchHistoryEntry, error) {
|
|
if len(targets) == 0 {
|
|
return nil, nil
|
|
}
|
|
if writer, ok := store.(WatchedBatchWriter); ok {
|
|
return writer.MarkWatchedBatch(ctx, profileID, targets, entries)
|
|
}
|
|
written := make([]WatchHistoryEntry, 0, len(entries))
|
|
for i, target := range targets {
|
|
if err := store.MarkWatched(ctx, profileID, target.MediaItemID, target.DurationSeconds); err != nil {
|
|
return written, err
|
|
}
|
|
if i >= len(entries) {
|
|
continue
|
|
}
|
|
entry, err := AddVisibleHistory(ctx, store, entries[i])
|
|
if err != nil {
|
|
return written, err
|
|
}
|
|
written = append(written, entry)
|
|
}
|
|
return written, nil
|
|
}
|
|
|
|
func VisibleHistoryTimestamps(ctx context.Context, store UserStore, profileID string, mediaItemIDs []string, at time.Time) (map[string]string, error) {
|
|
mediaItemIDs = compactHistoryMediaItemIDs(mediaItemIDs)
|
|
result := make(map[string]string, len(mediaItemIDs))
|
|
if len(mediaItemIDs) == 0 {
|
|
return result, nil
|
|
}
|
|
if visibilityStore, ok := store.(HistoryVisibilityStore); ok {
|
|
return visibilityStore.VisibleHistoryTimestamps(ctx, profileID, mediaItemIDs, at)
|
|
}
|
|
timestamp := at.UTC().Format(time.RFC3339)
|
|
if at.IsZero() {
|
|
timestamp = time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
for _, mediaItemID := range mediaItemIDs {
|
|
result[mediaItemID] = timestamp
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func parseHistoryTimestamp(value string) time.Time {
|
|
if value == "" {
|
|
return time.Time{}
|
|
}
|
|
parsed, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
// SeriesWatchCounts is the aggregate episode watch state for one series, as
|
|
// computed by SeriesEpisodeRollupStore.
|
|
type SeriesWatchCounts struct {
|
|
TotalEpisodes int
|
|
WatchedCount int
|
|
InProgressCount int
|
|
}
|
|
|
|
// SeriesEpisodeRollupStore is an optional store capability: compute the
|
|
// per-series episode watch-state rollup (total / watched / in-progress
|
|
// episode counts) in SQL instead of materializing every episode of every
|
|
// series and batching per-episode progress lookups through
|
|
// ListProgressWithCompletedHistory. Implemented by the Postgres store, where
|
|
// episodes and progress live in the same database; SQLite-backed stores fall
|
|
// back to the chunked in-memory path. Semantics must match
|
|
// ListProgressWithCompletedHistory + catalog.EpisodeRollupUserData: an episode
|
|
// is watched when its visible progress row is completed or a visible completed
|
|
// history row exists, and in-progress when it is not watched and its visible
|
|
// progress row has position_seconds > 0.
|
|
type SeriesEpisodeRollupStore interface {
|
|
SeriesEpisodeWatchCounts(ctx context.Context, profileID string, seriesIDs []string) (map[string]SeriesWatchCounts, error)
|
|
}
|
|
|
|
// CompletedHistoryItemMap returns the latest completed-history item row for a
|
|
// scoped item query. Lookup failures degrade to an empty map so user-data
|
|
// enrichment can keep returning progress rows.
|
|
func CompletedHistoryItemMap(ctx context.Context, store ProgressCompletionStore, query CompletedHistoryItemQuery) map[string]CompletedHistoryItem {
|
|
result := map[string]CompletedHistoryItem{}
|
|
if store == nil || query.ProfileID == "" {
|
|
return result
|
|
}
|
|
query.MediaItemIDs = compactHistoryMediaItemIDs(query.MediaItemIDs)
|
|
if len(query.MediaItemIDs) == 0 {
|
|
return result
|
|
}
|
|
items, err := store.ListCompletedHistoryItems(ctx, query)
|
|
if err != nil {
|
|
return result
|
|
}
|
|
for _, item := range items {
|
|
if item.MediaItemID != "" {
|
|
result[item.MediaItemID] = item
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetProgressWithCompletedHistory returns normal progress overlaid with
|
|
// completed history for callers that present a single item's played state.
|
|
func GetProgressWithCompletedHistory(ctx context.Context, store UserStore, profileID, mediaItemID string) (*WatchProgress, error) {
|
|
mediaItemID = strings.TrimSpace(mediaItemID)
|
|
if store == nil || profileID == "" || mediaItemID == "" {
|
|
return nil, nil
|
|
}
|
|
progress, err := store.GetProgress(ctx, profileID, mediaItemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if progress != nil && progress.Completed {
|
|
return progress, nil
|
|
}
|
|
completed := CompletedHistoryItemMap(ctx, store, CompletedHistoryItemQuery{
|
|
ProfileID: profileID,
|
|
MediaItemIDs: []string{mediaItemID},
|
|
})[mediaItemID]
|
|
if completed.MediaItemID == "" {
|
|
return progress, nil
|
|
}
|
|
if progress == nil {
|
|
return &WatchProgress{
|
|
ProfileID: profileID,
|
|
MediaItemID: mediaItemID,
|
|
Completed: true,
|
|
UpdatedAt: completed.WatchedAt,
|
|
}, nil
|
|
}
|
|
progress.Completed = true
|
|
if timestampAfter(completed.WatchedAt, progress.UpdatedAt) {
|
|
progress.UpdatedAt = completed.WatchedAt
|
|
}
|
|
return progress, nil
|
|
}
|
|
|
|
// ListProgressWithCompletedHistory returns progress for mediaItemIDs with
|
|
// completed history folded into the map. History is only queried for IDs that
|
|
// are not already completed by a progress row.
|
|
func ListProgressWithCompletedHistory(ctx context.Context, store ProgressCompletionStore, profileID string, mediaItemIDs []string) (map[string]WatchProgress, error) {
|
|
mediaItemIDs = compactHistoryMediaItemIDs(mediaItemIDs)
|
|
if store == nil || profileID == "" || len(mediaItemIDs) == 0 {
|
|
return map[string]WatchProgress{}, nil
|
|
}
|
|
progressMap, err := store.ListProgressByMediaItems(ctx, profileID, mediaItemIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if progressMap == nil {
|
|
progressMap = map[string]WatchProgress{}
|
|
}
|
|
|
|
candidates := make([]string, 0, len(mediaItemIDs))
|
|
for _, mediaItemID := range mediaItemIDs {
|
|
if progress, ok := progressMap[mediaItemID]; ok && progress.Completed {
|
|
continue
|
|
}
|
|
candidates = append(candidates, mediaItemID)
|
|
}
|
|
if len(candidates) == 0 {
|
|
return progressMap, nil
|
|
}
|
|
|
|
completed := CompletedHistoryItemMap(ctx, store, CompletedHistoryItemQuery{
|
|
ProfileID: profileID,
|
|
MediaItemIDs: candidates,
|
|
})
|
|
for mediaItemID, completedItem := range completed {
|
|
if progress, ok := progressMap[mediaItemID]; ok {
|
|
progress.Completed = true
|
|
if timestampAfter(completedItem.WatchedAt, progress.UpdatedAt) {
|
|
progress.UpdatedAt = completedItem.WatchedAt
|
|
}
|
|
progressMap[mediaItemID] = progress
|
|
continue
|
|
}
|
|
progressMap[mediaItemID] = WatchProgress{
|
|
ProfileID: profileID,
|
|
MediaItemID: mediaItemID,
|
|
Completed: true,
|
|
UpdatedAt: completedItem.WatchedAt,
|
|
}
|
|
}
|
|
return progressMap, nil
|
|
}
|
|
|
|
func compactHistoryMediaItemIDs(mediaItemIDs []string) []string {
|
|
result := make([]string, 0, len(mediaItemIDs))
|
|
seen := make(map[string]struct{}, len(mediaItemIDs))
|
|
for _, mediaItemID := range mediaItemIDs {
|
|
mediaItemID = strings.TrimSpace(mediaItemID)
|
|
if mediaItemID == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[mediaItemID]; ok {
|
|
continue
|
|
}
|
|
seen[mediaItemID] = struct{}{}
|
|
result = append(result, mediaItemID)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func timestampAfter(left, right string) bool {
|
|
if left == "" {
|
|
return false
|
|
}
|
|
if right == "" {
|
|
return true
|
|
}
|
|
leftTime, leftErr := time.Parse(time.RFC3339, left)
|
|
rightTime, rightErr := time.Parse(time.RFC3339, right)
|
|
if leftErr == nil && rightErr == nil {
|
|
return leftTime.After(rightTime)
|
|
}
|
|
return left > right
|
|
}
|