708 lines
23 KiB
Go
708 lines
23 KiB
Go
package pgstore
|
|
|
|
import (
|
|
"context"
|
|
"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 := nowUTC()
|
|
completed := false
|
|
if duration > 0 && position/duration > userstore.WatchedFraction(thresholds.WatchedPct) {
|
|
completed = true
|
|
position = duration // match MarkWatched() — reset so no stale resume point
|
|
}
|
|
_, 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 = GREATEST(excluded.position_seconds, user_watch_progress.position_seconds),
|
|
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
|
|
}
|
|
|
|
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 := nowUTC()
|
|
completed := false
|
|
if duration > 0 && position/duration > userstore.WatchedFraction(thresholds.WatchedPct) {
|
|
completed = true
|
|
position = duration // match MarkWatched() — reset so no stale resume point
|
|
}
|
|
_, 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 = 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 && duration > 0 {
|
|
position = duration
|
|
}
|
|
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 = 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 && duration > 0 {
|
|
position = duration
|
|
}
|
|
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 = 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 := nowUTC()
|
|
_, 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, TRUE, $6)
|
|
ON CONFLICT(user_id, profile_id, media_item_id) DO UPDATE SET
|
|
position_seconds = excluded.position_seconds,
|
|
duration_seconds = excluded.duration_seconds,
|
|
completed = TRUE,
|
|
updated_at = excluded.updated_at`,
|
|
s.userID, profileID, mediaItemID, duration, 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, `
|
|
INSERT INTO user_watch_progress
|
|
(user_id, profile_id, media_item_id, completed, position_seconds, duration_seconds, updated_at)
|
|
SELECT $1, $2, mid, TRUE, 0, 0, $4
|
|
FROM unnest($3::text[]) AS mid
|
|
ON CONFLICT (user_id, profile_id, media_item_id) DO UPDATE
|
|
SET completed = TRUE,
|
|
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":
|
|
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 = FALSE
|
|
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
|
|
}
|
|
|
|
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) 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
|
|
}
|
|
sources := make([]string, 0, len(query.ExcludeSources))
|
|
for _, source := range query.ExcludeSources {
|
|
sources = append(sources, string(source))
|
|
}
|
|
includeSources := make([]string, 0, len(query.IncludeSources))
|
|
for _, source := range query.IncludeSources {
|
|
includeSources = append(includeSources, string(source))
|
|
}
|
|
mediaItemIDs := compactMediaItemIDs(query.MediaItemIDs)
|
|
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[]))
|
|
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 h.watched_at ASC
|
|
LIMIT $6 OFFSET $7`,
|
|
s.userID, query.ProfileID, includeSources, sources, 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) 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, `
|
|
INSERT INTO user_history_hidden_items (user_id, profile_id, media_item_id, hidden_before, updated_at)
|
|
SELECT $1, $2, unnest($3::text[]), $4, $4
|
|
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
|
|
WHERE user_id = $1
|
|
AND profile_id = $2
|
|
AND media_item_id = ANY($3::text[])
|
|
AND watched_at <= $4
|
|
`, s.userID, profileID, mediaItemIDs, removedAt.UTC()); 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 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
|
|
}
|