Removing a series from the watchlist on full watch stranded it once new episodes aired: nothing ever re-added it. Split the behavior by type: - watchlist.Maintainer now auto-removes only fully-watched movies (still propagating removals to connected providers). - Series stay on the watchlist; the new catalog.WatchlistVisibility filter hides series whose available episodes are all completed on the display surfaces (sections rail, catalog watchlist source, GET /watchlist). A newly added episode makes the series reappear on the next fetch, and nothing is synced upstream since the entry never leaves the list. Sync, recommendations, notifications, and the watchlist check endpoint intentionally keep seeing the full list. The filter honors the existing per-profile remove-watched preference and uses batch lookups only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
141 lines
9.9 KiB
Go
141 lines
9.9 KiB
Go
package userstore
|
|
|
|
import (
|
|
"context"
|
|
"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) 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
|
|
}
|
|
|
|
// 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)
|
|
}
|