Files
silo-server/internal/userstore/pgstore/progress.go
T
CoffeeKnyteandGitHub 652b5d1e9f perf(jellycompat): fast-paths for slow Jellyfin-compat queries (#247)
* perf(jellycompat): fast-paths for slow Jellyfin-compat queries

Rebuilds the jellycompat slow-query fast-paths cleanly on main, without
the hub-sections virtual-library feature (which is intentionally absent
on this branch). Each path is bounded so behavior stays predictable
under load.

Fast-paths:
- Episode catalog: add the load-bearing `si.type = 'series'` hydration
  guard plus a matching entry-scan series-parent guard, so the page and
  count scans stay aligned with hydration (no under-filled pages, no
  inflated TotalRecordCount from podcast/audiobook episode rows).
- /Shows/{id}/Episodes AdjacentTo: serve a bounded prev/self/next
  neighbor window with series-membership validation and a cross-series
  id guard, falling back to the full listing.
- Resume: serve Continue Watching through the capped native
  continue-watching fetcher (next-up suppressed, in-progress only)
  instead of loadProgressPage's unbounded scan. Wires only the
  sections read-time fetcher (StoreProvider) into ItemsHandler; no
  hub-section/virtual-library exposure.
- Played-history: push the type + library filter down into SQL.
- recently_added: fan a no-parentId browse out across libraries with an
  app-side dedup merge, clamped to BrowseFilters.MaxLimit, and exclude
  compat-hidden (audiobook/podcast) libraries from the fan-out.
- Detail fields: batch per-item GetItemDetail in the Latest/browse
  detail-field paths via GetItemDetailsByIDs, with per-item fallback.
- Search: term-length and result-count guards for aux search.

* perf(jellycompat): clamp Resume fast-path fetch to maxResumeItems

A large client-supplied StartIndex on /UserItems/Resume inflated
loadResumeViaSections' fetchLimit (pageSize + StartIndex) and fed it
straight into ResolvedSection.ItemLimit, letting the continue-watching
fetcher scan up to its continueProgressMaxScanned hard cap (1000 rows)
instead of stopping near maxResumeItems. Clamp fetchLimit to
maxResumeItems and short-circuit to an empty page when StartIndex is at
or beyond the cap (no visible row can land there), keeping the capped
fetcher's scan bounded and predictable under load.

Addresses CodeRabbit review finding on PR #247.
2026-07-01 09:06:24 -04:00

1076 lines
36 KiB
Go

package pgstore
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"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 {
if duration > 0 && position > 0 && position/duration < userstore.MinResumeFraction(thresholds.MinResumePct) {
return nil
}
now := time.Now().UTC()
completed := false
if duration > 0 && position/duration > userstore.WatchedFraction(thresholds.WatchedPct) {
completed = true
position = 0 // match MarkWatched() — completed rows hold no resume point
}
// `completed` is a one-way watched latch; position resets to 0 on
// completion so `position_seconds > 0` means "has a resume point". A
// rewatch heartbeat on a completed row therefore re-enters Continue
// Watching through plain GREATEST (stored position is 0) 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 GREATEST(excluded.position_seconds, user_watch_progress.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`,
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 {
if duration > 0 && position > 0 && position/duration < userstore.MinResumeFraction(thresholds.MinResumePct) {
return nil
}
now := time.Now().UTC()
completed := false
if duration > 0 && position/duration > userstore.WatchedFraction(thresholds.WatchedPct) {
completed = true
position = 0 // match MarkWatched() — completed rows hold no resume point
}
_, 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`,
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`,
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
}
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)
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
WHERE EXCLUDED.updated_at > user_watch_progress.updated_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
}
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`,
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
}
// 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. Used by the
// jellycompat series mark-unplayed path. Rows that don't exist are silently
// skipped — the matching DeleteHistoryBySource call handles removing history.
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
}
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
}