Files
silo-server/internal/userstore/store.go
T
af37c53838 feat(collections): add profile-scoped default sorting (#489)
* feat(collections): add profile-scoped default sorting

* fix(collections): address review feedback

* chore(collections): preserve rebase migration coverage

* chore(collections): satisfy changed-line lint

* fix(collections): address remaining review feedback

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-08-14 15:45:47 -04:00

252 lines
17 KiB
Go

package userstore
import (
"context"
"encoding/json"
"errors"
"time"
)
var ErrCollectionGroupNotFound = errors.New("collection group not found")
// PreferenceSettingsWriter is the subset of the user store that participates
// in legacy-preference/canonical-setting synchronization. Implementations pass
// a transaction-scoped writer to WithPreferenceSettingsTransaction so callers
// can commit the legacy row and every canonical row as one unit.
type PreferenceSettingsWriter interface {
CreateProfile(ctx context.Context, profile Profile) error
ListSettings(ctx context.Context) ([]SettingEntry, error)
// ListProfileIDs reads the current household membership inside the same
// transaction as a legacy account-setting mutation. This closes the
// create/write window where a newly committed profile could miss fan-out.
ListProfileIDs(ctx context.Context) ([]string, error)
// UpdateProfile writes the legacy profile preference columns that shipped
// clients still mutate during the canonical-settings cutover.
UpdateProfile(ctx context.Context, id string, u UpdateProfileInput) error
SetSubtitlePreference(ctx context.Context, pref SubtitlePreference) error
DeleteSubtitlePreference(ctx context.Context, profileID, seriesID string) error
SetAudioPreference(ctx context.Context, pref AudioPreference) error
DeleteAudioPreference(ctx context.Context, profileID, seriesID string) error
UpsertLibraryPlaybackPreference(ctx context.Context, pref LibraryPlaybackPreference) error
DeleteLibraryPlaybackPreference(ctx context.Context, profileID string, libraryID int) error
SetSetting(ctx context.Context, key, value string) error
DeleteSetting(ctx context.Context, key string) error
SetDeviceSetting(ctx context.Context, entry DeviceSettingEntry) error
DeleteDeviceSetting(ctx context.Context, profileID, deviceID, key string) error
// UpsertSettingValue writes one explicit value and increments its revision.
UpsertSettingValue(ctx context.Context, id SettingIdentity, value json.RawMessage) (*SettingValue, error)
// DeleteSettingValue removes one explicit value and reports whether it existed.
DeleteSettingValue(ctx context.Context, id SettingIdentity) (bool, error)
}
// PreferenceSettingsTransactioner is implemented by stores that can atomically
// synchronize a shipped legacy preference row with its canonical values.
type PreferenceSettingsTransactioner interface {
WithPreferenceSettingsTransaction(ctx context.Context, fn func(PreferenceSettingsWriter) error) error
}
// 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)
// Collection sort preferences
SetCollectionSortPreference(ctx context.Context, pref CollectionSortPreference) error
GetCollectionSortPreference(ctx context.Context, profileID, collectionKind, collectionID string) (*CollectionSortPreference, error)
ClearCollectionSortPreference(ctx context.Context, profileID, collectionKind, collectionID string) 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
// Onboarding
GetOnboardingState(ctx context.Context, profileID, tourID string) (*OnboardingState, error)
UpsertOnboardingState(ctx context.Context, state OnboardingState) error
// Jellyfin DisplayPreferences blobs, keyed by (prefs id, client) per user.
// They are the jellycompat subsystem's storage rather than user settings —
// the contract neither validates nor resolves them — so they live in the
// dedicated jellycompat_displayprefs table and hold opaque Jellyfin client
// JSON verbatim. Get returns "" when nothing is stored.
GetJellycompatDisplayPrefs(ctx context.Context, prefsID, client string) (string, error)
SetJellycompatDisplayPrefs(ctx context.Context, prefsID, client, value string) 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)
// ListAllSettingValues returns every explicit value this user has stored,
// across all scopes, in a stable (key, scope, identity) order. It serves
// the admin inspection surface; resolution reads keep going through
// ListSettingValuesForResolution.
ListAllSettingValues(ctx context.Context) ([]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)
// DeviceExists reports whether one device is registered to one profile. It
// exists so a write naming a device can be authorized without scanning the
// whole account's registry: ListDevices is account-wide by construction, so
// filtering its result per write would read every household member's rows.
DeviceExists(ctx context.Context, profileID, deviceID string) (bool, error)
// ForgetDevice removes one profile's registry row for a device. Settings
// are deleted separately through the scoped setting deletes, so forgetting
// a device shared by two profiles leaves the other profile's row intact.
ForgetDevice(ctx context.Context, profileID, deviceID string) error
}