Files
silo-server/internal/userstore/pgstore/progress.go
T
54507d236a fix(watchstate): make series mark-watched atomic and unload-proof (#645)
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>
2026-08-14 23:15:56 -04:00

1413 lines
48 KiB
Go

package pgstore
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/Silo-Server/silo-server/internal/userstore"
)
func scanWatchProgress(scanner interface {
Scan(dest ...any) error
}) (*userstore.WatchProgress, error) {
var wp userstore.WatchProgress
var updatedAt time.Time
err := scanner.Scan(
&wp.ProfileID, &wp.MediaItemID, &wp.PositionSeconds,
&wp.DurationSeconds, &wp.Completed, &updatedAt,
&wp.LastFileID, &wp.LastResolution, &wp.LastHDR, &wp.LastCodecVideo, &wp.LastEditionKey,
)
if err != nil {
return nil, err
}
wp.UpdatedAt = timeToString(updatedAt)
return &wp, nil
}
func scanWatchHistoryEntry(scanner interface {
Scan(dest ...any) error
}) (*userstore.WatchHistoryEntry, error) {
var entry userstore.WatchHistoryEntry
var watchedAt time.Time
var identityJSON string
err := scanner.Scan(
&entry.ID, &entry.ProfileID, &entry.MediaItemID,
&watchedAt, &entry.DurationSeconds, &entry.Completed, &entry.Source,
&identityJSON,
)
if err != nil {
return nil, err
}
entry.WatchedAt = timeToString(watchedAt)
if identityJSON != "" && identityJSON != "{}" {
_ = json.Unmarshal([]byte(identityJSON), &entry.Identity)
}
return &entry, nil
}
func (s *PostgresUserStore) UpdateProgress(ctx context.Context, profileID, mediaItemID string, position, duration float64, thresholds userstore.ProgressThresholds) error {
position, completed, skip := userstore.ResolveProgressState(position, duration, thresholds)
if skip {
return nil
}
now := time.Now().UTC()
// `completed` is a one-way watched latch; position resets to 0 on
// completion so `position_seconds > 0` means "has a resume point".
// Position itself is last-write-wins: a deliberate backward seek is a
// legitimate resume point (the old GREATEST clamp made "rewind and stop"
// resume at the stale, later position on every client). Rewatching a
// completed row still re-enters Continue Watching (stored position is 0,
// any heartbeat replaces it) while the watched flag survives.
_, err := s.pool.Exec(ctx, `
WITH visible AS (
SELECT
CASE
WHEN hhi.hidden_before IS NOT NULL AND $7::timestamptz <= hhi.hidden_before
THEN hhi.hidden_before + interval '1 second'
ELSE $7::timestamptz
END AS updated_at
FROM (SELECT 1) seed
LEFT JOIN user_history_hidden_items hhi
ON hhi.user_id = $1
AND hhi.profile_id = $2
AND hhi.media_item_id = $3
)
INSERT INTO user_watch_progress (user_id, profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at)
SELECT $1, $2, $3, $4, $5, $6, updated_at
FROM visible
ON CONFLICT(user_id, profile_id, media_item_id) DO UPDATE SET
position_seconds = CASE WHEN excluded.completed THEN 0
ELSE excluded.position_seconds END,
duration_seconds = excluded.duration_seconds,
completed = CASE WHEN excluded.completed
THEN TRUE ELSE user_watch_progress.completed END,
updated_at = excluded.updated_at,
event_at = excluded.updated_at`,
s.userID, profileID, mediaItemID, position, duration, completed, now,
)
if err != nil {
return fmt.Errorf("updating progress: %w", err)
}
return nil
}
// SetProgress bypasses the forward-only guard after the min-resume threshold.
func (s *PostgresUserStore) SetProgress(ctx context.Context, profileID, mediaItemID string, position, duration float64, thresholds userstore.ProgressThresholds) error {
position, completed, skip := userstore.ResolveProgressState(position, duration, thresholds)
if skip {
return nil
}
now := time.Now().UTC()
_, err := s.pool.Exec(ctx, `
WITH visible AS (
SELECT
CASE
WHEN hhi.hidden_before IS NOT NULL AND $7::timestamptz <= hhi.hidden_before
THEN hhi.hidden_before + interval '1 second'
ELSE $7::timestamptz
END AS updated_at
FROM (SELECT 1) seed
LEFT JOIN user_history_hidden_items hhi
ON hhi.user_id = $1
AND hhi.profile_id = $2
AND hhi.media_item_id = $3
)
INSERT INTO user_watch_progress (user_id, profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at)
SELECT $1, $2, $3, $4, $5, $6, updated_at
FROM visible
ON CONFLICT(user_id, profile_id, media_item_id) DO UPDATE SET
position_seconds = excluded.position_seconds,
duration_seconds = excluded.duration_seconds,
completed = user_watch_progress.completed OR excluded.completed,
updated_at = excluded.updated_at,
event_at = excluded.updated_at`,
s.userID, profileID, mediaItemID, position, duration, completed, now,
)
if err != nil {
return fmt.Errorf("setting progress: %w", err)
}
return nil
}
func (s *PostgresUserStore) SetProgressAt(ctx context.Context, profileID, mediaItemID string, position, duration float64, completed bool, updatedAt time.Time) error {
if position < 0 {
position = 0
}
if duration < 0 {
duration = 0
}
if completed {
position = 0
}
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
updatedAtText := updatedAt.UTC().Format(time.RFC3339)
suppressed, err := s.historyIsHidden(ctx, profileID, mediaItemID, updatedAtText)
if err != nil {
return err
}
if suppressed {
return nil
}
_, 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 = excluded.position_seconds,
duration_seconds = excluded.duration_seconds,
completed = user_watch_progress.completed OR excluded.completed,
updated_at = excluded.updated_at,
event_at = excluded.updated_at`,
s.userID, profileID, mediaItemID, position, duration, completed, updatedAt.UTC(),
)
if err != nil {
return fmt.Errorf("setting progress at time: %w", err)
}
return nil
}
func (s *PostgresUserStore) SetProgressIfNewer(ctx context.Context, profileID, mediaItemID string, position, duration float64, completed bool, updatedAt time.Time) (bool, error) {
if position < 0 {
position = 0
}
if duration < 0 {
duration = 0
}
if completed {
position = 0
}
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
updatedAtText := updatedAt.UTC().Format(time.RFC3339)
suppressed, err := s.historyIsHidden(ctx, profileID, mediaItemID, updatedAtText)
if err != nil {
return false, err
}
if suppressed {
return false, nil
}
// event_at is the LWW comparison key (the clamped client event time); the
// synced_seq cursor is stamped server-side by the user_watch_progress trigger.
tag, err := s.pool.Exec(ctx, `
INSERT INTO user_watch_progress (user_id, profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at, event_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
ON CONFLICT(user_id, profile_id, media_item_id) DO UPDATE SET
position_seconds = EXCLUDED.position_seconds,
duration_seconds = EXCLUDED.duration_seconds,
completed = user_watch_progress.completed OR EXCLUDED.completed,
updated_at = EXCLUDED.updated_at,
event_at = EXCLUDED.event_at
WHERE EXCLUDED.event_at > user_watch_progress.event_at`,
s.userID, profileID, mediaItemID, position, duration, completed, updatedAt.UTC(),
)
if err != nil {
return false, fmt.Errorf("setting newer progress: %w", err)
}
return tag.RowsAffected() > 0, nil
}
// ListProgressSince returns user_watch_progress rows whose server cursor
// (synced_seq) exceeds cursor, in cursor order, with the next cursor to resume
// from. Delta delivery is driven only by synced_seq, never a client clock.
func (s *PostgresUserStore) ListProgressSince(ctx context.Context, profileID, cursor string) ([]userstore.WatchProgress, string, error) {
c, _ := strconv.ParseInt(cursor, 10, 64) // empty/invalid cursor → 0 (full delta)
const limit = 500
rows, err := s.pool.Query(ctx, `
SELECT profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at, synced_seq,
last_file_id, last_resolution, last_hdr, last_codec_video, last_edition_key
FROM user_watch_progress
WHERE user_id = $1 AND profile_id = $2 AND synced_seq IS NOT NULL AND synced_seq > $3
AND NOT EXISTS (
SELECT 1
FROM user_history_hidden_items hhi
WHERE hhi.user_id = user_watch_progress.user_id
AND hhi.profile_id = user_watch_progress.profile_id
AND hhi.media_item_id = user_watch_progress.media_item_id
AND user_watch_progress.updated_at <= hhi.hidden_before
)
ORDER BY synced_seq ASC
LIMIT $4`,
s.userID, profileID, c, limit,
)
if err != nil {
return nil, cursor, fmt.Errorf("listing progress since: %w", err)
}
defer rows.Close()
next := c
var results []userstore.WatchProgress
for rows.Next() {
var wp userstore.WatchProgress
var updatedAt time.Time
var seq int64
if err := rows.Scan(
&wp.ProfileID, &wp.MediaItemID, &wp.PositionSeconds, &wp.DurationSeconds, &wp.Completed, &updatedAt, &seq,
&wp.LastFileID, &wp.LastResolution, &wp.LastHDR, &wp.LastCodecVideo, &wp.LastEditionKey,
); err != nil {
return nil, cursor, fmt.Errorf("scanning progress since row: %w", err)
}
wp.UpdatedAt = timeToString(updatedAt)
if seq > next {
next = seq
}
results = append(results, wp)
}
if err := rows.Err(); err != nil {
return nil, cursor, fmt.Errorf("iterating progress since rows: %w", err)
}
return results, strconv.FormatInt(next, 10), nil
}
func (s *PostgresUserStore) MarkWatched(ctx context.Context, profileID, mediaItemID string, duration float64) error {
if duration < 0 {
duration = 0
}
now := time.Now().UTC()
_, err := s.pool.Exec(ctx, `
WITH visible AS (
SELECT
CASE
WHEN hhi.hidden_before IS NOT NULL AND $5::timestamptz <= hhi.hidden_before
THEN hhi.hidden_before + interval '1 second'
ELSE $5::timestamptz
END AS updated_at
FROM (SELECT 1) seed
LEFT JOIN user_history_hidden_items hhi
ON hhi.user_id = $1
AND hhi.profile_id = $2
AND hhi.media_item_id = $3
)
INSERT INTO user_watch_progress (user_id, profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at)
SELECT $1, $2, $3, 0, $4, TRUE, updated_at
FROM visible
ON CONFLICT(user_id, profile_id, media_item_id) DO UPDATE SET
position_seconds = 0,
duration_seconds = excluded.duration_seconds,
completed = TRUE,
updated_at = excluded.updated_at,
event_at = excluded.updated_at`,
s.userID, profileID, mediaItemID, duration, now,
)
if err != nil {
return fmt.Errorf("marking watched: %w", err)
}
return nil
}
func (s *PostgresUserStore) ClearProgress(ctx context.Context, profileID, mediaItemID string) error {
_, err := s.pool.Exec(ctx, `
DELETE FROM user_watch_progress
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`,
s.userID, profileID, mediaItemID,
)
if err != nil {
return fmt.Errorf("clearing progress: %w", err)
}
return nil
}
// MarkWatchedBatch marks every target watched and inserts its history row in
// one transaction, so a series mark either lands whole or not at all. The
// prior per-episode loop committed each episode separately, which left a large
// series half-marked whenever the client disconnected mid-request.
//
// Two statements rather than a reuse of MarkProgressBatch: that one hardcodes
// duration_seconds = 0, and the manual mark-watched path must preserve each
// episode's real duration. Both statements carry the same
// user_history_hidden_items watermark logic as their single-row counterparts
// (MarkWatched, AddVisibleHistory) so a mark after a history removal stays
// visible.
func (s *PostgresUserStore) MarkWatchedBatch(
ctx context.Context,
profileID string,
targets []userstore.MarkWatchedTarget,
entries []userstore.WatchHistoryEntry,
) ([]userstore.WatchHistoryEntry, error) {
if len(targets) == 0 {
return nil, nil
}
mediaItemIDs := make([]string, 0, len(targets))
durations := make([]float64, 0, len(targets))
seen := make(map[string]struct{}, len(targets))
for _, target := range targets {
mediaItemID := strings.TrimSpace(target.MediaItemID)
if mediaItemID == "" {
continue
}
if _, ok := seen[mediaItemID]; ok {
continue
}
seen[mediaItemID] = struct{}{}
duration := target.DurationSeconds
if duration < 0 {
duration = 0
}
mediaItemIDs = append(mediaItemIDs, mediaItemID)
durations = append(durations, duration)
}
if len(mediaItemIDs) == 0 {
return nil, nil
}
now := time.Now().UTC()
historyIDs := make([]string, 0, len(entries))
historyItemIDs := make([]string, 0, len(entries))
historyWatchedAt := make([]time.Time, 0, len(entries))
historyDurations := make([]float64, 0, len(entries))
historyCompleted := make([]bool, 0, len(entries))
historySources := make([]string, 0, len(entries))
historyIdentities := make([]string, 0, len(entries))
// Entries with a blank media ID are dropped, so keep an explicit map back
// to the caller's slice rather than assuming positional alignment.
historySourceIndex := make([]int, 0, len(entries))
for entryIndex, entry := range entries {
mediaItemID := strings.TrimSpace(entry.MediaItemID)
if mediaItemID == "" {
continue
}
id := entry.ID
if id == "" {
id = generateUUID()
}
watchedAt := now
if entry.WatchedAt != "" {
parsed, err := time.Parse(time.RFC3339, entry.WatchedAt)
if err != nil {
return nil, fmt.Errorf("parsing watched_at for %s: %w", mediaItemID, err)
}
watchedAt = parsed.UTC()
}
source := entry.Source
if source == "" {
source = userstore.WatchHistorySourceLegacy
}
identityJSON, err := json.Marshal(entry.Identity)
if err != nil {
return nil, fmt.Errorf("marshaling watch identity: %w", err)
}
historyIDs = append(historyIDs, id)
historyItemIDs = append(historyItemIDs, mediaItemID)
historyWatchedAt = append(historyWatchedAt, watchedAt)
historyDurations = append(historyDurations, entry.DurationSeconds)
historyCompleted = append(historyCompleted, entry.Completed)
historySources = append(historySources, string(source))
historyIdentities = append(historyIdentities, string(identityJSON))
historySourceIndex = append(historySourceIndex, entryIndex)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin mark watched batch: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
if _, err := tx.Exec(ctx, `
WITH target(media_item_id, duration_seconds) AS (
SELECT * FROM unnest($3::text[], $4::double precision[])
),
visible AS (
SELECT
t.media_item_id,
t.duration_seconds,
CASE
WHEN hhi.hidden_before IS NOT NULL AND $5::timestamptz <= hhi.hidden_before
THEN hhi.hidden_before + interval '1 second'
ELSE $5::timestamptz
END AS updated_at
FROM target t
LEFT JOIN user_history_hidden_items hhi
ON hhi.user_id = $1
AND hhi.profile_id = $2
AND hhi.media_item_id = t.media_item_id
)
INSERT INTO user_watch_progress
(user_id, profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at)
SELECT $1, $2, media_item_id, 0, duration_seconds, TRUE, updated_at
FROM visible
ON CONFLICT (user_id, profile_id, media_item_id) DO UPDATE SET
position_seconds = 0,
-- Keep a known duration when the caller has none: jellycompat's
-- series mark-played passes 0 for every target, and its previous
-- MarkProgressBatch path left duration_seconds untouched.
duration_seconds = CASE
WHEN EXCLUDED.duration_seconds > 0 THEN EXCLUDED.duration_seconds
ELSE user_watch_progress.duration_seconds
END,
completed = TRUE,
updated_at = EXCLUDED.updated_at,
event_at = EXCLUDED.updated_at`,
s.userID, profileID, mediaItemIDs, durations, now,
); err != nil {
return nil, fmt.Errorf("marking watched batch: %w", err)
}
written := make([]userstore.WatchHistoryEntry, 0, len(historyIDs))
if len(historyIDs) > 0 {
rows, err := tx.Query(ctx, `
WITH entry(id, media_item_id, watched_at, duration_seconds, completed, source, watch_identity, ord) AS (
SELECT * FROM unnest(
$3::text[], $4::text[], $5::timestamptz[], $6::double precision[],
$7::boolean[], $8::text[], $9::jsonb[]
) WITH ORDINALITY
)
INSERT INTO user_watch_history
(id, user_id, profile_id, media_item_id, watched_at, duration_seconds, completed, source, watch_identity)
SELECT
e.id, $1, $2, e.media_item_id,
CASE
WHEN hhi.hidden_before IS NOT NULL AND e.watched_at <= hhi.hidden_before
THEN hhi.hidden_before + interval '1 second'
ELSE e.watched_at
END,
e.duration_seconds, e.completed, e.source, e.watch_identity
FROM entry e
LEFT JOIN user_history_hidden_items hhi
ON hhi.user_id = $1
AND hhi.profile_id = $2
AND hhi.media_item_id = e.media_item_id
ORDER BY e.ord
RETURNING id, media_item_id, watched_at`,
s.userID, profileID, historyIDs, historyItemIDs, historyWatchedAt,
historyDurations, historyCompleted, historySources, historyIdentities,
)
if err != nil {
return nil, fmt.Errorf("adding visible history batch: %w", err)
}
resolved := make(map[string]time.Time, len(historyIDs))
for rows.Next() {
var id, mediaItemID string
var watchedAt time.Time
if err := rows.Scan(&id, &mediaItemID, &watchedAt); err != nil {
rows.Close()
return nil, fmt.Errorf("scanning inserted history: %w", err)
}
resolved[id] = watchedAt
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating inserted history: %w", err)
}
for i, id := range historyIDs {
entry := entries[historySourceIndex[i]]
entry.ID = id
entry.MediaItemID = historyItemIDs[i]
if entry.Source == "" {
entry.Source = userstore.WatchHistorySource(historySources[i])
}
if watchedAt, ok := resolved[id]; ok {
entry.WatchedAt = timeToString(watchedAt)
}
written = append(written, entry)
}
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit mark watched batch: %w", err)
}
return written, nil
}
// MarkProgressBatch upserts a `completed = TRUE` progress row for every entry
// in mediaItemIDs in a single statement. Used by the jellycompat series
// mark-played path so a 200-episode mark collapses to one INSERT.
func (s *PostgresUserStore) MarkProgressBatch(ctx context.Context, profileID string, mediaItemIDs []string, updatedAt time.Time) error {
mediaItemIDs = compactMediaItemIDs(mediaItemIDs)
if len(mediaItemIDs) == 0 {
return nil
}
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
_, err := s.pool.Exec(ctx, `
WITH target(media_item_id) AS (
SELECT unnest($3::text[])
),
visible AS (
SELECT
t.media_item_id,
CASE
WHEN hhi.hidden_before IS NOT NULL AND $4::timestamptz <= hhi.hidden_before
THEN hhi.hidden_before + interval '1 second'
ELSE $4::timestamptz
END AS updated_at
FROM target t
LEFT JOIN user_history_hidden_items hhi
ON hhi.user_id = $1
AND hhi.profile_id = $2
AND hhi.media_item_id = t.media_item_id
)
INSERT INTO user_watch_progress
(user_id, profile_id, media_item_id, completed, position_seconds, duration_seconds, updated_at)
SELECT $1, $2, media_item_id, TRUE, 0, 0, updated_at
FROM visible
ON CONFLICT (user_id, profile_id, media_item_id) DO UPDATE
SET completed = TRUE,
position_seconds = 0,
updated_at = EXCLUDED.updated_at
WHERE user_watch_progress.completed IS DISTINCT FROM TRUE
OR user_watch_progress.updated_at < EXCLUDED.updated_at`,
s.userID, profileID, mediaItemIDs, updatedAt.UTC(),
)
if err != nil {
return fmt.Errorf("marking progress batch: %w", err)
}
return nil
}
// ClearProgressBatch resets every (user, profile, media_item_id) row to
// `completed = FALSE, position_seconds = 0` in a single UPDATE. Rows that
// don't exist are silently skipped. (Mark-unplayed flows currently go through
// RemoveHistoryItems; this remains on the interface for bulk resets.)
func (s *PostgresUserStore) ClearProgressBatch(ctx context.Context, profileID string, mediaItemIDs []string, updatedAt time.Time) error {
mediaItemIDs = compactMediaItemIDs(mediaItemIDs)
if len(mediaItemIDs) == 0 {
return nil
}
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
// Clear partially-watched rows (completed = FALSE with position_seconds > 0)
// in addition to fully-completed ones — the prior ClearProgress path
// DELETE-d the row unconditionally, so any non-default state must be
// cleared. Skip rows already in the target state (completed = FALSE AND
// position_seconds = 0) to avoid pointless writes.
_, err := s.pool.Exec(ctx, `
UPDATE user_watch_progress
SET completed = FALSE, position_seconds = 0, updated_at = $4
WHERE user_id = $1 AND profile_id = $2
AND media_item_id = ANY($3::text[])
AND (completed = TRUE OR position_seconds <> 0)`,
s.userID, profileID, mediaItemIDs, updatedAt.UTC(),
)
if err != nil {
return fmt.Errorf("clearing progress batch: %w", err)
}
return nil
}
func (s *PostgresUserStore) GetProgress(ctx context.Context, profileID, mediaItemID string) (*userstore.WatchProgress, error) {
row := s.pool.QueryRow(ctx, `
SELECT profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at,
last_file_id, last_resolution, last_hdr, last_codec_video, last_edition_key
FROM user_watch_progress
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3
AND NOT EXISTS (
SELECT 1
FROM user_history_hidden_items hhi
WHERE hhi.user_id = user_watch_progress.user_id
AND hhi.profile_id = user_watch_progress.profile_id
AND hhi.media_item_id = user_watch_progress.media_item_id
AND user_watch_progress.updated_at <= hhi.hidden_before
)`,
s.userID, profileID, mediaItemID,
)
wp, err := scanWatchProgress(row)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting progress: %w", err)
}
return wp, nil
}
func (s *PostgresUserStore) ListProgress(ctx context.Context, profileID, status string, limit, offset int) ([]userstore.WatchProgress, error) {
var query string
var args []any
switch status {
case "in_progress":
// position_seconds > 0 (not completed = FALSE): completed rows hold
// position 0, so a rewatch of a watched item has completed = TRUE with
// a live resume point and belongs in Continue Watching.
query = `
SELECT profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at,
last_file_id, last_resolution, last_hdr, last_codec_video, last_edition_key
FROM user_watch_progress
WHERE user_id = $1 AND profile_id = $2 AND position_seconds > 0
AND NOT EXISTS (
SELECT 1
FROM user_history_hidden_items hhi
WHERE hhi.user_id = user_watch_progress.user_id
AND hhi.profile_id = user_watch_progress.profile_id
AND hhi.media_item_id = user_watch_progress.media_item_id
AND user_watch_progress.updated_at <= hhi.hidden_before
)
ORDER BY updated_at DESC
LIMIT $3 OFFSET $4`
args = []any{s.userID, profileID, limit, offset}
case "completed":
query = `
SELECT profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at,
last_file_id, last_resolution, last_hdr, last_codec_video, last_edition_key
FROM user_watch_progress
WHERE user_id = $1 AND profile_id = $2 AND completed = TRUE
AND NOT EXISTS (
SELECT 1
FROM user_history_hidden_items hhi
WHERE hhi.user_id = user_watch_progress.user_id
AND hhi.profile_id = user_watch_progress.profile_id
AND hhi.media_item_id = user_watch_progress.media_item_id
AND user_watch_progress.updated_at <= hhi.hidden_before
)
ORDER BY updated_at DESC
LIMIT $3 OFFSET $4`
args = []any{s.userID, profileID, limit, offset}
default:
query = `
SELECT profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at,
last_file_id, last_resolution, last_hdr, last_codec_video, last_edition_key
FROM user_watch_progress
WHERE user_id = $1 AND profile_id = $2
AND NOT EXISTS (
SELECT 1
FROM user_history_hidden_items hhi
WHERE hhi.user_id = user_watch_progress.user_id
AND hhi.profile_id = user_watch_progress.profile_id
AND hhi.media_item_id = user_watch_progress.media_item_id
AND user_watch_progress.updated_at <= hhi.hidden_before
)
ORDER BY updated_at DESC
LIMIT $3 OFFSET $4`
args = []any{s.userID, profileID, limit, offset}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("listing progress: %w", err)
}
defer rows.Close()
var results []userstore.WatchProgress
for rows.Next() {
wp, err := scanWatchProgress(rows)
if err != nil {
return nil, fmt.Errorf("scanning progress row: %w", err)
}
results = append(results, *wp)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating progress rows: %w", err)
}
return results, nil
}
// ListProgressFiltered mirrors the status branches of ListProgress and AND-s in
// an EXISTS pre-filter so the requested item types and/or library are resolved
// in SQL instead of after a full-set scan. Movies/series resolve through
// media_items; episodes live in the separate episodes table joined via
// series_id → media_items (a plain media_items join would miss them); the
// optional library predicate hits media_item_libraries. The completed branch's
// `completed = TRUE` + `ORDER BY updated_at DESC` shape keeps
// idx_uwp_profile_completed in play, while the EXISTS sub-selects ride
// idx_item_libraries_content. The filter is coarse (callers re-check
// access/parental exclusions over the hydrated rows), and an empty types slice
// with a nil libraryID degrades to the plain status listing.
func (s *PostgresUserStore) ListProgressFiltered(ctx context.Context, profileID, status string, types []string, libraryID *int, limit, offset int) ([]userstore.WatchProgress, error) {
args := []any{s.userID, profileID}
var statusClause string
switch status {
case "in_progress":
statusClause = "position_seconds > 0"
case "completed":
statusClause = "completed = TRUE"
default:
statusClause = "TRUE"
}
var filterClause string
filterClause, args = buildProgressCatalogFilter(types, libraryID, args)
args = append(args, limit, offset)
limitPlaceholder := fmt.Sprintf("$%d", len(args)-1)
offsetPlaceholder := fmt.Sprintf("$%d", len(args))
query := `
SELECT profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at,
last_file_id, last_resolution, last_hdr, last_codec_video, last_edition_key
FROM user_watch_progress
WHERE user_id = $1 AND profile_id = $2 AND ` + statusClause + filterClause + `
AND NOT EXISTS (
SELECT 1
FROM user_history_hidden_items hhi
WHERE hhi.user_id = user_watch_progress.user_id
AND hhi.profile_id = user_watch_progress.profile_id
AND hhi.media_item_id = user_watch_progress.media_item_id
AND user_watch_progress.updated_at <= hhi.hidden_before
)
ORDER BY updated_at DESC
LIMIT ` + limitPlaceholder + ` OFFSET ` + offsetPlaceholder
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("listing filtered progress: %w", err)
}
defer rows.Close()
var results []userstore.WatchProgress
for rows.Next() {
wp, err := scanWatchProgress(rows)
if err != nil {
return nil, fmt.Errorf("scanning progress row: %w", err)
}
results = append(results, *wp)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating progress rows: %w", err)
}
return results, nil
}
// buildProgressCatalogFilter builds the EXISTS pre-filter that constrains
// user_watch_progress rows to the requested item types and/or library. It
// appends any new bind args to args and returns the SQL fragment (a leading
// " AND (...)" or empty when no filter applies) plus the grown args slice.
func buildProgressCatalogFilter(types []string, libraryID *int, args []any) (string, []any) {
wantEpisode := false
nonEpisode := make([]string, 0, len(types))
for _, t := range types {
switch lt := strings.ToLower(strings.TrimSpace(t)); lt {
case "":
// skip blanks
case "episode":
wantEpisode = true
default:
nonEpisode = append(nonEpisode, lt)
}
}
typed := len(nonEpisode) > 0 || wantEpisode
if !typed && libraryID == nil {
return "", args
}
// A library-only request (no types) must match both movies and episodes in
// that library, so include both branches when no type was requested.
includeMovie := !typed || len(nonEpisode) > 0
includeEpisode := !typed || wantEpisode
var typePlaceholder, libPlaceholder string
if len(nonEpisode) > 0 {
args = append(args, nonEpisode)
typePlaceholder = fmt.Sprintf("$%d", len(args))
}
if libraryID != nil {
args = append(args, *libraryID)
libPlaceholder = fmt.Sprintf("$%d", len(args))
}
branches := make([]string, 0, 2)
if includeMovie {
var sb strings.Builder
sb.WriteString("EXISTS (SELECT 1 FROM media_items mi")
if libPlaceholder != "" {
sb.WriteString(" JOIN media_item_libraries mil ON mi.content_id = mil.content_id")
}
sb.WriteString(" WHERE mi.content_id = user_watch_progress.media_item_id")
if typePlaceholder != "" {
sb.WriteString(" AND lower(mi.type) = ANY(" + typePlaceholder + ")")
}
if libPlaceholder != "" {
sb.WriteString(" AND mil.media_folder_id = " + libPlaceholder)
}
sb.WriteString(")")
branches = append(branches, sb.String())
}
if includeEpisode {
var sb strings.Builder
sb.WriteString("EXISTS (SELECT 1 FROM episodes e")
if libPlaceholder != "" {
sb.WriteString(" JOIN media_items si ON e.series_id = si.content_id")
sb.WriteString(" JOIN media_item_libraries mil ON si.content_id = mil.content_id")
}
sb.WriteString(" WHERE e.content_id = user_watch_progress.media_item_id")
if libPlaceholder != "" {
sb.WriteString(" AND mil.media_folder_id = " + libPlaceholder)
}
sb.WriteString(")")
branches = append(branches, sb.String())
}
return " AND (" + strings.Join(branches, " OR ") + ")", args
}
func (s *PostgresUserStore) ListProgressByMediaItems(ctx context.Context, profileID string, mediaItemIDs []string) (map[string]userstore.WatchProgress, error) {
result := make(map[string]userstore.WatchProgress, len(mediaItemIDs))
if len(mediaItemIDs) == 0 {
return result, nil
}
placeholders := make([]string, len(mediaItemIDs))
args := make([]any, 0, len(mediaItemIDs)+2)
args = append(args, s.userID, profileID)
for i, mediaItemID := range mediaItemIDs {
placeholders[i] = fmt.Sprintf("$%d", i+3)
args = append(args, mediaItemID)
}
rows, err := s.pool.Query(ctx, `
SELECT profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at,
last_file_id, last_resolution, last_hdr, last_codec_video, last_edition_key
FROM user_watch_progress
WHERE user_id = $1 AND profile_id = $2 AND media_item_id IN (`+strings.Join(placeholders, ",")+`)
AND NOT EXISTS (
SELECT 1
FROM user_history_hidden_items hhi
WHERE hhi.user_id = user_watch_progress.user_id
AND hhi.profile_id = user_watch_progress.profile_id
AND hhi.media_item_id = user_watch_progress.media_item_id
AND user_watch_progress.updated_at <= hhi.hidden_before
)`,
args...,
)
if err != nil {
return nil, fmt.Errorf("listing progress by media items: %w", err)
}
defer rows.Close()
for rows.Next() {
wp, err := scanWatchProgress(rows)
if err != nil {
return nil, fmt.Errorf("scanning progress row: %w", err)
}
result[wp.MediaItemID] = *wp
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating progress rows: %w", err)
}
return result, nil
}
// Compile-time capability check: the Postgres store computes series episode
// rollups in SQL (see userstore.SeriesEpisodeRollupStore).
var _ userstore.SeriesEpisodeRollupStore = (*PostgresUserStore)(nil)
// SeriesEpisodeWatchCounts aggregates per-series episode watch state in one
// query. It replaces the ListBySeriesIDs + chunked
// ListProgressWithCompletedHistory fanout that materialized every episode of
// every requested series (a 50-series page of an episode-heavy library
// expanded to 32k episode rows and ~65 sequential queries, ~17s measured).
//
// Semantics mirror the chunked path exactly:
// - episodes count when they are available (episode_libraries row — the same
// predicate as catalog's episode listings);
// - a progress row is visible unless hidden via user_history_hidden_items
// (updated_at <= hidden_before), matching ListProgressByMediaItems;
// - watched = visible completed progress OR a visible completed history row
// (watched_at <= hidden_before hides it), matching the completed-history
// fold in userstore.ListProgressWithCompletedHistory;
// - in-progress = not watched and visible position_seconds > 0, matching
// catalog.EpisodeRollupUserData.
//
// Series with no available episodes produce no row, so callers keep the same
// "no rollup" behavior the episode-list path had for them.
func (s *PostgresUserStore) SeriesEpisodeWatchCounts(ctx context.Context, profileID string, seriesIDs []string) (map[string]userstore.SeriesWatchCounts, error) {
result := make(map[string]userstore.SeriesWatchCounts, len(seriesIDs))
if len(seriesIDs) == 0 {
return result, nil
}
rows, err := s.pool.Query(ctx, `
SELECT e.series_id,
COUNT(*)::int,
COUNT(*) FILTER (WHERE ws.watched)::int,
COUNT(*) FILTER (WHERE NOT ws.watched AND ws.resumable)::int
FROM episodes e
LEFT JOIN user_watch_progress p
ON p.user_id = $1 AND p.profile_id = $2 AND p.media_item_id = e.content_id
AND NOT EXISTS (
SELECT 1 FROM user_history_hidden_items hh
WHERE hh.user_id = p.user_id AND hh.profile_id = p.profile_id
AND hh.media_item_id = p.media_item_id AND p.updated_at <= hh.hidden_before
)
CROSS JOIN LATERAL (
SELECT
COALESCE(p.completed, FALSE) OR EXISTS (
SELECT 1 FROM user_watch_history h
WHERE h.user_id = $1 AND h.profile_id = $2 AND h.media_item_id = e.content_id
AND h.completed = TRUE
AND NOT EXISTS (
SELECT 1 FROM user_history_hidden_items hh
WHERE hh.user_id = h.user_id AND hh.profile_id = h.profile_id
AND hh.media_item_id = h.media_item_id AND h.watched_at <= hh.hidden_before
)
) AS watched,
COALESCE(p.position_seconds, 0) > 0 AS resumable
) ws
WHERE e.series_id = ANY($3::text[])
AND EXISTS (SELECT 1 FROM episode_libraries el WHERE el.episode_id = e.content_id)
GROUP BY e.series_id`,
s.userID, profileID, seriesIDs,
)
if err != nil {
return nil, fmt.Errorf("aggregating series episode watch counts: %w", err)
}
defer rows.Close()
for rows.Next() {
var seriesID string
var counts userstore.SeriesWatchCounts
if err := rows.Scan(&seriesID, &counts.TotalEpisodes, &counts.WatchedCount, &counts.InProgressCount); err != nil {
return nil, fmt.Errorf("scanning series episode watch counts: %w", err)
}
result[seriesID] = counts
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating series episode watch counts: %w", err)
}
return result, nil
}
func (s *PostgresUserStore) UpdateProgressHints(ctx context.Context, profileID, mediaItemID string, hints userstore.VersionHints) error {
_, err := s.pool.Exec(ctx, `
UPDATE user_watch_progress
SET last_file_id = $4, last_resolution = $5, last_hdr = $6, last_codec_video = $7, last_edition_key = $8
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`,
s.userID, profileID, mediaItemID,
hints.FileID, hints.Resolution, hints.HDR, hints.CodecVideo, nilIfEmpty(hints.EditionKey),
)
if err != nil {
return fmt.Errorf("updating progress hints: %w", err)
}
return nil
}
func nilIfEmpty(value string) *string {
if strings.TrimSpace(value) == "" {
return nil
}
return &value
}
func (s *PostgresUserStore) AddHistory(ctx context.Context, entry userstore.WatchHistoryEntry) error {
if entry.ID == "" {
entry.ID = generateUUID()
}
if entry.WatchedAt == "" {
entry.WatchedAt = nowUTC()
}
if entry.Source == "" {
entry.Source = userstore.WatchHistorySourceLegacy
}
suppressed, err := s.historyIsHidden(ctx, entry.ProfileID, entry.MediaItemID, entry.WatchedAt)
if err != nil {
return err
}
if suppressed {
return nil
}
identityJSON, err := json.Marshal(entry.Identity)
if err != nil {
return fmt.Errorf("marshaling watch identity: %w", err)
}
_, err = s.pool.Exec(ctx, `
INSERT INTO user_watch_history (id, user_id, profile_id, media_item_id, watched_at, duration_seconds, completed, source, watch_identity)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
entry.ID, s.userID, entry.ProfileID, entry.MediaItemID,
entry.WatchedAt, entry.DurationSeconds, entry.Completed, entry.Source,
string(identityJSON),
)
if err != nil {
return fmt.Errorf("adding history entry: %w", err)
}
return nil
}
func (s *PostgresUserStore) AddVisibleHistory(ctx context.Context, entry userstore.WatchHistoryEntry) (userstore.WatchHistoryEntry, error) {
if entry.ID == "" {
entry.ID = generateUUID()
}
if entry.WatchedAt == "" {
entry.WatchedAt = nowUTC()
}
if entry.Source == "" {
entry.Source = userstore.WatchHistorySourceLegacy
}
identityJSON, err := json.Marshal(entry.Identity)
if err != nil {
return entry, fmt.Errorf("marshaling watch identity: %w", err)
}
var watchedAt time.Time
if err := s.pool.QueryRow(ctx, `
WITH visible AS (
SELECT
CASE
WHEN hhi.hidden_before IS NOT NULL AND $5::timestamptz <= hhi.hidden_before
THEN hhi.hidden_before + interval '1 second'
ELSE $5::timestamptz
END AS watched_at
FROM (SELECT 1) seed
LEFT JOIN user_history_hidden_items hhi
ON hhi.user_id = $2
AND hhi.profile_id = $3
AND hhi.media_item_id = $4
)
INSERT INTO user_watch_history (id, user_id, profile_id, media_item_id, watched_at, duration_seconds, completed, source, watch_identity)
SELECT $1, $2, $3, $4, watched_at, $6, $7, $8, $9
FROM visible
RETURNING watched_at`,
entry.ID, s.userID, entry.ProfileID, entry.MediaItemID, entry.WatchedAt,
entry.DurationSeconds, entry.Completed, entry.Source, string(identityJSON),
).Scan(&watchedAt); err != nil {
return entry, fmt.Errorf("adding visible history entry: %w", err)
}
entry.WatchedAt = timeToString(watchedAt)
return entry, nil
}
func (s *PostgresUserStore) AddHistoryIfMissing(ctx context.Context, entry userstore.WatchHistoryEntry) (bool, error) {
if entry.WatchedAt == "" {
entry.WatchedAt = nowUTC()
}
suppressed, err := s.historyIsHidden(ctx, entry.ProfileID, entry.MediaItemID, entry.WatchedAt)
if err != nil {
return false, err
}
if suppressed {
return false, nil
}
var exists bool
if err := s.pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1
FROM user_watch_history
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3 AND watched_at = $4
)`,
s.userID, entry.ProfileID, entry.MediaItemID, entry.WatchedAt,
).Scan(&exists); err != nil {
return false, fmt.Errorf("checking history row existence: %w", err)
}
if exists {
return false, nil
}
if err := s.AddHistory(ctx, entry); err != nil {
return false, err
}
return true, nil
}
func (s *PostgresUserStore) ListHistory(ctx context.Context, profileID string, limit, offset int) ([]userstore.WatchHistoryEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT h.id, h.profile_id, h.media_item_id, h.watched_at, h.duration_seconds, h.completed, h.source, h.watch_identity::text
FROM user_watch_history h
WHERE h.user_id = $1 AND h.profile_id = $2
AND NOT EXISTS (
SELECT 1
FROM user_history_hidden_items hhi
WHERE hhi.user_id = h.user_id
AND hhi.profile_id = h.profile_id
AND hhi.media_item_id = h.media_item_id
AND h.watched_at <= hhi.hidden_before
)
ORDER BY watched_at DESC
LIMIT $3 OFFSET $4`,
s.userID, profileID, limit, offset,
)
if err != nil {
return nil, fmt.Errorf("listing history: %w", err)
}
defer rows.Close()
var results []userstore.WatchHistoryEntry
for rows.Next() {
entry, err := scanWatchHistoryEntry(rows)
if err != nil {
return nil, fmt.Errorf("scanning history row: %w", err)
}
results = append(results, *entry)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating history rows: %w", err)
}
return results, nil
}
func (s *PostgresUserStore) ListCompletedHistory(ctx context.Context, query userstore.CompletedHistoryQuery) ([]userstore.WatchHistoryEntry, error) {
limit := query.Limit
if limit <= 0 || limit > 500 {
limit = 500
}
includeSources, excludeSources, mediaItemIDs := completedHistoryFilterArgs(query.MediaItemIDs, query.IncludeSources, query.ExcludeSources)
rows, err := s.pool.Query(ctx, `
SELECT h.id, h.profile_id, h.media_item_id, h.watched_at, h.duration_seconds, h.completed, h.source, h.watch_identity::text
FROM user_watch_history h
WHERE h.user_id = $1
AND h.profile_id = $2
AND h.completed = true
AND (cardinality($3::text[]) = 0 OR h.source = ANY($3::text[]))
AND (cardinality($4::text[]) = 0 OR h.source <> ALL($4::text[]))
AND (cardinality($5::text[]) = 0 OR h.media_item_id = ANY($5::text[]))
`+completedHistoryVisibleSQL+`
ORDER BY h.watched_at ASC, h.id ASC
LIMIT $6 OFFSET $7`,
s.userID, query.ProfileID, includeSources, excludeSources, mediaItemIDs, limit, query.Offset,
)
if err != nil {
return nil, fmt.Errorf("listing completed history: %w", err)
}
defer rows.Close()
var results []userstore.WatchHistoryEntry
for rows.Next() {
entry, err := scanWatchHistoryEntry(rows)
if err != nil {
return nil, fmt.Errorf("scanning completed history row: %w", err)
}
results = append(results, *entry)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating completed history rows: %w", err)
}
return results, nil
}
func (s *PostgresUserStore) ListCompletedHistoryItems(ctx context.Context, query userstore.CompletedHistoryItemQuery) ([]userstore.CompletedHistoryItem, error) {
includeSources, excludeSources, mediaItemIDs := completedHistoryFilterArgs(query.MediaItemIDs, query.IncludeSources, query.ExcludeSources)
rows, err := s.pool.Query(ctx, `
SELECT h.media_item_id, MAX(h.watched_at)
FROM user_watch_history h
WHERE h.user_id = $1
AND h.profile_id = $2
AND h.completed = true
AND (cardinality($3::text[]) = 0 OR h.source = ANY($3::text[]))
AND (cardinality($4::text[]) = 0 OR h.source <> ALL($4::text[]))
AND (cardinality($5::text[]) = 0 OR h.media_item_id = ANY($5::text[]))
`+completedHistoryVisibleSQL+`
GROUP BY h.media_item_id
ORDER BY h.media_item_id ASC`,
s.userID, query.ProfileID, includeSources, excludeSources, mediaItemIDs,
)
if err != nil {
return nil, fmt.Errorf("listing completed history items: %w", err)
}
defer rows.Close()
var results []userstore.CompletedHistoryItem
for rows.Next() {
var item userstore.CompletedHistoryItem
var watchedAt time.Time
if err := rows.Scan(&item.MediaItemID, &watchedAt); err != nil {
return nil, fmt.Errorf("scanning completed history item: %w", err)
}
item.WatchedAt = timeToString(watchedAt)
results = append(results, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating completed history items: %w", err)
}
return results, nil
}
func (s *PostgresUserStore) VisibleHistoryTimestamps(ctx context.Context, profileID string, mediaItemIDs []string, at time.Time) (map[string]string, error) {
mediaItemIDs = compactMediaItemIDs(mediaItemIDs)
result := make(map[string]string, len(mediaItemIDs))
if len(mediaItemIDs) == 0 {
return result, nil
}
if at.IsZero() {
at = time.Now().UTC()
}
rows, err := s.pool.Query(ctx, `
SELECT t.media_item_id, hhi.hidden_before
FROM unnest($3::text[]) AS t(media_item_id)
LEFT JOIN user_history_hidden_items hhi
ON hhi.user_id = $1
AND hhi.profile_id = $2
AND hhi.media_item_id = t.media_item_id`,
s.userID, profileID, mediaItemIDs,
)
if err != nil {
return nil, fmt.Errorf("listing visible history timestamps: %w", err)
}
defer rows.Close()
for rows.Next() {
var mediaItemID string
var hiddenBefore sql.NullTime
if err := rows.Scan(&mediaItemID, &hiddenBefore); err != nil {
return nil, fmt.Errorf("scanning visible history timestamp: %w", err)
}
result[mediaItemID] = visibleTimestampAfterHiddenTime(at, hiddenBefore)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating visible history timestamps: %w", err)
}
return result, nil
}
const completedHistoryVisibleSQL = `
AND NOT EXISTS (
SELECT 1
FROM user_history_hidden_items hhi
WHERE hhi.user_id = h.user_id
AND hhi.profile_id = h.profile_id
AND hhi.media_item_id = h.media_item_id
AND h.watched_at <= hhi.hidden_before
)`
func completedHistoryFilterArgs(
mediaItemIDs []string,
includeSources []userstore.WatchHistorySource,
excludeSources []userstore.WatchHistorySource,
) ([]string, []string, []string) {
include := make([]string, 0, len(includeSources))
for _, source := range includeSources {
include = append(include, string(source))
}
exclude := make([]string, 0, len(excludeSources))
for _, source := range excludeSources {
exclude = append(exclude, string(source))
}
return include, exclude, compactMediaItemIDs(mediaItemIDs)
}
func (s *PostgresUserStore) RemoveHistoryItems(
ctx context.Context,
profileID string,
mediaItemIDs []string,
removedAt time.Time,
) error {
mediaItemIDs = compactMediaItemIDs(mediaItemIDs)
if len(mediaItemIDs) == 0 {
return nil
}
if removedAt.IsZero() {
removedAt = time.Now().UTC()
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin remove history items: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
WITH target(media_item_id) AS (
SELECT unnest($3::text[])
),
watermark AS (
SELECT
t.media_item_id,
GREATEST($4::timestamptz, COALESCE(MAX(h.watched_at), $4::timestamptz)) AS hidden_before
FROM target t
LEFT JOIN user_watch_history h
ON h.user_id = $1
AND h.profile_id = $2
AND h.media_item_id = t.media_item_id
GROUP BY t.media_item_id
)
INSERT INTO user_history_hidden_items (user_id, profile_id, media_item_id, hidden_before, updated_at)
SELECT $1, $2, media_item_id, hidden_before, $4
FROM watermark
ON CONFLICT (user_id, profile_id, media_item_id) DO UPDATE SET
hidden_before = GREATEST(user_history_hidden_items.hidden_before, EXCLUDED.hidden_before),
updated_at = EXCLUDED.updated_at
`, s.userID, profileID, mediaItemIDs, removedAt.UTC()); err != nil {
return fmt.Errorf("upserting hidden history items: %w", err)
}
if _, err := tx.Exec(ctx, `
DELETE FROM user_watch_history h
USING user_history_hidden_items hhi
WHERE h.user_id = $1
AND h.profile_id = $2
AND h.media_item_id = ANY($3::text[])
AND hhi.user_id = h.user_id
AND hhi.profile_id = h.profile_id
AND hhi.media_item_id = h.media_item_id
AND h.watched_at <= hhi.hidden_before
`, s.userID, profileID, mediaItemIDs); err != nil {
return fmt.Errorf("deleting removed history rows: %w", err)
}
if _, err := tx.Exec(ctx, `
DELETE FROM user_watch_progress
WHERE user_id = $1
AND profile_id = $2
AND media_item_id = ANY($3::text[])
`, s.userID, profileID, mediaItemIDs); err != nil {
return fmt.Errorf("deleting removed progress rows: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit remove history items: %w", err)
}
return nil
}
func (s *PostgresUserStore) DeleteHistoryBySource(ctx context.Context, profileID string, mediaItemIDs []string, source userstore.WatchHistorySource) error {
if len(mediaItemIDs) == 0 {
return nil
}
_, err := s.pool.Exec(ctx, `
DELETE FROM user_watch_history
WHERE user_id = $1 AND profile_id = $2 AND source = $3 AND media_item_id = ANY($4::text[])`,
s.userID, profileID, source, mediaItemIDs,
)
if err != nil {
return fmt.Errorf("deleting history by source: %w", err)
}
return nil
}
func (s *PostgresUserStore) historyIsHidden(
ctx context.Context,
profileID, mediaItemID, watchedAt string,
) (bool, error) {
var exists bool
if err := s.pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1
FROM user_history_hidden_items
WHERE user_id = $1
AND profile_id = $2
AND media_item_id = $3
AND hidden_before >= $4::timestamptz
)
`, s.userID, profileID, mediaItemID, watchedAt).Scan(&exists); err != nil {
return false, fmt.Errorf("checking hidden history item: %w", err)
}
return exists, nil
}
func visibleTimestampAfterHiddenTime(at time.Time, hiddenBefore sql.NullTime) string {
if at.IsZero() {
at = time.Now().UTC()
}
at = at.UTC()
if !hiddenBefore.Valid || at.After(hiddenBefore.Time) {
return timeToString(at)
}
return timeToString(hiddenBefore.Time.UTC().Add(time.Second))
}
func compactMediaItemIDs(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
}