The cross-platform settings contract needs one typed store behind it before a resolver, routes or a migration can exist. This adds that storage to both user-store backends and holds them to identical behavior. PostgreSQL gets user_setting_values with the scope CHECK constraints, the five partial unique indexes that enforce one explicit value per identity, and the covering indexes the one-query read path needs, plus user_setting_mutations for mutation_id idempotency and the inert user_setting_migration_rejects audit table. The per-user SQLite store gets the same shape minus user_id, since that database is already user-scoped. The UserStore interface grows the typed operations: read one explicit value at one scope, collect every candidate row for a resolution request in a single query, upsert with a revision increment, unset, and the idempotency receipt operations. The resolution read deliberately returns unranked candidates so the resolver can rank in Go — one query per request, never one per scope, which the pgx query-count test pins. Delete behavior is application-enforced. Neither backend can inherit it from constraints: the SQLite store declares no foreign keys, and library, series and device columns are not FK targets in Postgres either. Profile deletion cascades to profile-anchored values while account scope survives, forgetting a device clears its profile_device values alongside the legacy overrides, and the library/series purges remove only what is scoped to that entity. The shared conformance suite covers all of it, including the set-versus-unset distinction for false, 0, "" and null, so a divergence between the two backends fails a test rather than reaching a client. Part of #376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
185 lines
13 KiB
Go
185 lines
13 KiB
Go
package userstore
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
var ErrCollectionGroupNotFound = errors.New("collection group not found")
|
|
|
|
// UserStore defines the interface for per-user data storage.
|
|
// Both SQLite and Postgres backends implement this interface.
|
|
type UserStore interface {
|
|
// Profiles
|
|
CreateProfile(ctx context.Context, p Profile) error
|
|
GetProfile(ctx context.Context, id string) (*Profile, error)
|
|
ListProfiles(ctx context.Context) ([]Profile, error)
|
|
UpdateProfile(ctx context.Context, id string, u UpdateProfileInput) error
|
|
DeleteProfile(ctx context.Context, id string) error
|
|
VerifyPIN(ctx context.Context, profileID, pin string) (bool, error)
|
|
|
|
// Progress
|
|
UpdateProgress(ctx context.Context, profileID, mediaItemID string, position, duration float64, thresholds ProgressThresholds) error
|
|
SetProgress(ctx context.Context, profileID, mediaItemID string, position, duration float64, thresholds ProgressThresholds) error
|
|
SetProgressAt(ctx context.Context, profileID, mediaItemID string, position, duration float64, completed bool, updatedAt time.Time) error
|
|
SetProgressIfNewer(ctx context.Context, profileID, mediaItemID string, position, duration float64, completed bool, updatedAt time.Time) (bool, error)
|
|
UpdateProgressHints(ctx context.Context, profileID, mediaItemID string, hints VersionHints) error
|
|
MarkWatched(ctx context.Context, profileID, mediaItemID string, duration float64) error
|
|
MarkProgressBatch(ctx context.Context, profileID string, mediaItemIDs []string, updatedAt time.Time) error
|
|
ClearProgressBatch(ctx context.Context, profileID string, mediaItemIDs []string, updatedAt time.Time) error
|
|
ClearProgress(ctx context.Context, profileID, mediaItemID string) error
|
|
GetProgress(ctx context.Context, profileID, mediaItemID string) (*WatchProgress, error)
|
|
ListProgress(ctx context.Context, profileID, status string, limit, offset int) ([]WatchProgress, error)
|
|
// ListProgressFiltered is ListProgress with an additional SQL pre-filter on
|
|
// the backing catalog item's type and/or library, so the watched-items path
|
|
// no longer scans the whole status set before discarding non-matching rows.
|
|
// types is matched case-insensitively against media_items.type ("episode"
|
|
// resolves through the separate episodes table); a nil libraryID drops the
|
|
// library predicate, and an empty types + nil libraryID degrades to the
|
|
// plain status listing. It is a coarse pre-filter: callers still apply
|
|
// access/parental exclusions over the returned rows.
|
|
ListProgressFiltered(ctx context.Context, profileID, status string, types []string, libraryID *int, limit, offset int) ([]WatchProgress, error)
|
|
ListProgressByMediaItems(ctx context.Context, profileID string, mediaItemIDs []string) (map[string]WatchProgress, error)
|
|
// ListProgressSince returns rows whose server cursor exceeds the opaque
|
|
// cursor token (empty = full delta), in cursor order, with the next cursor.
|
|
// Cross-device delta delivery depends only on the server-assigned synced_seq.
|
|
ListProgressSince(ctx context.Context, profileID, cursor string) ([]WatchProgress, string, error)
|
|
AddHistory(ctx context.Context, entry WatchHistoryEntry) error
|
|
AddHistoryIfMissing(ctx context.Context, entry WatchHistoryEntry) (bool, error)
|
|
ListHistory(ctx context.Context, profileID string, limit, offset int) ([]WatchHistoryEntry, error)
|
|
ListCompletedHistory(ctx context.Context, query CompletedHistoryQuery) ([]WatchHistoryEntry, error)
|
|
ListCompletedHistoryItems(ctx context.Context, query CompletedHistoryItemQuery) ([]CompletedHistoryItem, error)
|
|
RemoveHistoryItems(ctx context.Context, profileID string, mediaItemIDs []string, removedAt time.Time) error
|
|
DeleteHistoryBySource(ctx context.Context, profileID string, mediaItemIDs []string, source WatchHistorySource) error
|
|
ListHomeDismissals(ctx context.Context, profileID, surface string) ([]HomeItemDismissal, error)
|
|
UpsertHomeDismissal(ctx context.Context, dismissal HomeItemDismissal) error
|
|
DeleteHomeDismissal(ctx context.Context, profileID, surface, mediaItemID string) error
|
|
|
|
// Favorites & Watchlist
|
|
AddFavorite(ctx context.Context, profileID, mediaItemID string) error
|
|
AddFavoriteAt(ctx context.Context, profileID, mediaItemID string, addedAt time.Time) (bool, error)
|
|
RemoveFavorite(ctx context.Context, profileID, mediaItemID string) error
|
|
ListFavorites(ctx context.Context, profileID string, limit, offset int) ([]Favorite, error)
|
|
ListFavoritesByMediaItems(ctx context.Context, profileID string, mediaItemIDs []string) (map[string]bool, error)
|
|
IsFavorite(ctx context.Context, profileID, mediaItemID string) (bool, error)
|
|
AddToWatchlist(ctx context.Context, profileID, mediaItemID string) error
|
|
AddToWatchlistAt(ctx context.Context, profileID, mediaItemID string, addedAt time.Time) (bool, error)
|
|
RemoveFromWatchlist(ctx context.Context, profileID, mediaItemID string) error
|
|
// ReplaceWatchlistOrder mirrors a provider's watchlist order: the given ids
|
|
// get sort_index 0..N-1 in order; all other rows reset to added_at ordering.
|
|
ReplaceWatchlistOrder(ctx context.Context, profileID string, orderedMediaItemIDs []string) error
|
|
ListWatchlist(ctx context.Context, profileID string, limit, offset int) ([]WatchlistEntry, error)
|
|
ListWatchlistByMediaItems(ctx context.Context, profileID string, mediaItemIDs []string) (map[string]bool, error)
|
|
InWatchlist(ctx context.Context, profileID, mediaItemID string) (bool, error)
|
|
// RemoveWatchedFromWatchlist reports the profile's preference for pruning
|
|
// fully-watched entries from the watchlist (defaults true): movies are
|
|
// removed outright on completion, while fully-watched series are only
|
|
// hidden from display so they reappear when new episodes are added.
|
|
RemoveWatchedFromWatchlist(ctx context.Context, profileID string) (bool, error)
|
|
|
|
// Collections
|
|
CreateCollection(ctx context.Context, input CreateCollectionInput) (*Collection, error)
|
|
GetCollection(ctx context.Context, id string) (*Collection, error)
|
|
ListCollections(ctx context.Context, profileID string) ([]Collection, error)
|
|
UpdateCollection(ctx context.Context, input UpdateCollectionInput) error
|
|
DeleteCollection(ctx context.Context, id string) error
|
|
AddCollectionItem(ctx context.Context, collectionID, mediaItemID string, position int) error
|
|
RemoveCollectionItem(ctx context.Context, collectionID, mediaItemID string) error
|
|
ListCollectionItems(ctx context.Context, collectionID string) ([]CollectionItem, error)
|
|
ReplaceCollectionItems(ctx context.Context, collectionID string, items []CollectionItemReplacement) error
|
|
ReorderCollectionItems(ctx context.Context, collectionID string, orderedMediaItemIDs []string) error
|
|
// ReorderCollections scopes to the supplied group_id. A nil groupID means
|
|
// the implicit Ungrouped bucket.
|
|
ReorderCollections(ctx context.Context, profileID string, groupID *string, orderedIDs []string) error
|
|
UpdateCollectionSyncState(ctx context.Context, input UpdateCollectionSyncStateInput) error
|
|
ListCollectionGroups(ctx context.Context) ([]CollectionGroup, error)
|
|
EnsureCollectionGroup(ctx context.Context, id string) error
|
|
CreateCollectionGroup(ctx context.Context, name, slug string, defaultSortMode GroupSortMode) (*CollectionGroup, error)
|
|
UpdateCollectionGroup(ctx context.Context, id string, name *string, slug *string, defaultSortMode *GroupSortMode) (*CollectionGroup, error)
|
|
DeleteCollectionGroup(ctx context.Context, id string) error
|
|
ReorderCollectionGroups(ctx context.Context, orderedIDs []string) error
|
|
|
|
// Section Overrides
|
|
ListSectionOverrides(ctx context.Context, profileID, scope, libraryID string) ([]SectionOverride, error)
|
|
SaveSectionOverrides(ctx context.Context, profileID, scope, libraryID string, overrides []SectionOverride) error
|
|
ResetSectionOverrides(ctx context.Context, profileID, scope, libraryID string) error
|
|
|
|
// Settings & Preferences
|
|
GetSetting(ctx context.Context, key string) (string, error)
|
|
SetSetting(ctx context.Context, key, value string) error
|
|
DeleteSetting(ctx context.Context, key string) error
|
|
ListSettings(ctx context.Context) ([]SettingEntry, error)
|
|
GetDeviceSetting(ctx context.Context, profileID, deviceID, key string) (*DeviceSettingEntry, error)
|
|
SetDeviceSetting(ctx context.Context, entry DeviceSettingEntry) error
|
|
DeleteDeviceSetting(ctx context.Context, profileID, deviceID, key string) error
|
|
DeleteAllDeviceSettings(ctx context.Context, profileID, deviceID string) error
|
|
DeleteDeviceSettingsByKey(ctx context.Context, key string) error
|
|
ListDeviceSettings(ctx context.Context, key string) ([]DeviceSettingEntry, error)
|
|
ListAllDeviceSettings(ctx context.Context) ([]DeviceSettingEntry, error)
|
|
SetSubtitlePreference(ctx context.Context, pref SubtitlePreference) error
|
|
GetSubtitlePreference(ctx context.Context, profileID, seriesID string) (*SubtitlePreference, error)
|
|
DeleteSubtitlePreference(ctx context.Context, profileID, seriesID string) error
|
|
SetAudioPreference(ctx context.Context, pref AudioPreference) error
|
|
GetAudioPreference(ctx context.Context, profileID, seriesID string) (*AudioPreference, error)
|
|
DeleteAudioPreference(ctx context.Context, profileID, seriesID string) error
|
|
SetSeriesPlaybackPreference(ctx context.Context, pref SeriesPlaybackPreference) error
|
|
GetSeriesPlaybackPreference(ctx context.Context, profileID, seriesID string) (*SeriesPlaybackPreference, error)
|
|
DeleteSeriesPlaybackPreference(ctx context.Context, profileID, seriesID string) error
|
|
GetLibraryPlaybackPreference(ctx context.Context, profileID string, libraryID int) (*LibraryPlaybackPreference, error)
|
|
ListLibraryPlaybackPreferences(ctx context.Context, profileID string) ([]LibraryPlaybackPreference, error)
|
|
UpsertLibraryPlaybackPreference(ctx context.Context, pref LibraryPlaybackPreference) error
|
|
DeleteLibraryPlaybackPreference(ctx context.Context, profileID string, libraryID int) error
|
|
|
|
// Canonical typed setting values (contracts/settings/v1).
|
|
//
|
|
// These back the settings contract's storage layer. The manifest remains
|
|
// the schema; the store holds validated JSON keyed by scope identity, and
|
|
// knows nothing about definitions, defaults or resolution order.
|
|
|
|
// GetSettingValue returns the explicit value at exactly one scope, or nil
|
|
// when that identity is unset. It does not resolve fallbacks.
|
|
GetSettingValue(ctx context.Context, id SettingIdentity) (*SettingValue, error)
|
|
// ListSettingValuesForResolution returns every candidate row for one
|
|
// resolution request in a single query, unranked. The resolver applies each
|
|
// definition's resolution order in Go; issuing one lookup per scope is a
|
|
// rejected implementation.
|
|
ListSettingValuesForResolution(ctx context.Context, query SettingResolutionQuery) ([]SettingValue, error)
|
|
// UpsertSettingValue writes the explicit value at one scope and increments
|
|
// that row's revision. Concurrent writes to one identity are
|
|
// last-write-wins in server receipt order; there is no compare-and-set
|
|
// precondition in v1.
|
|
UpsertSettingValue(ctx context.Context, id SettingIdentity, value json.RawMessage) (*SettingValue, error)
|
|
// DeleteSettingValue removes the explicit value at one scope — the `unset`
|
|
// operation — and reports whether a row existed.
|
|
DeleteSettingValue(ctx context.Context, id SettingIdentity) (bool, error)
|
|
|
|
// The scoped deletes below are application-enforced cleanup for identities
|
|
// this table cannot reference: the per-user SQLite store declares no foreign
|
|
// keys, and libraries, series and devices are not FK targets in Postgres
|
|
// either. Each removes only the rows scoped to the named entity.
|
|
DeleteSettingValuesForProfile(ctx context.Context, profileID string) (int64, error)
|
|
DeleteSettingValuesForDevice(ctx context.Context, profileID, deviceID string) (int64, error)
|
|
DeleteSettingValuesForLibrary(ctx context.Context, libraryID int) (int64, error)
|
|
DeleteSettingValuesForSeries(ctx context.Context, seriesID string) (int64, error)
|
|
|
|
// GetSettingMutation returns a recorded idempotency receipt, or nil.
|
|
GetSettingMutation(ctx context.Context, mutationID string) (*SettingMutationRecord, error)
|
|
// PutSettingMutation records a receipt without ever overwriting one. When
|
|
// the id is already recorded it returns the stored record with
|
|
// inserted=false, so the caller compares request hashes and answers
|
|
// already_applied or mutation_id_conflict.
|
|
PutSettingMutation(ctx context.Context, record SettingMutationRecord) (SettingMutationRecord, bool, error)
|
|
// DeleteExpiredSettingMutations removes receipts that expired before the
|
|
// given instant and reports how many. expires_at is not self-enforcing.
|
|
DeleteExpiredSettingMutations(ctx context.Context, before time.Time) (int64, error)
|
|
}
|
|
|
|
// DeviceRegistry is implemented by stores that track observed devices even
|
|
// when they do not currently have any device-scoped overrides.
|
|
type DeviceRegistry interface {
|
|
RegisterDevice(ctx context.Context, entry DeviceEntry) error
|
|
ListDevices(ctx context.Context) ([]DeviceEntry, error)
|
|
}
|