Files
silo-server/internal/userstore/store.go
T
a5fb16a5c5 feat(historyimport): import the Plex account watchlist alongside watch history (#285)
* feat(historyimport): import the Plex account watchlist alongside watch history

The Plex import migrated only watch history; the user's saved watchlist
had to be rebuilt by hand (#245).

- PlexClient gains FetchWatchlist: pages the account-level watchlist on
  the Plex discover API (discover.provider.plex.tv). It authenticates
  with the plex.tv ACCOUNT token — the PIN/OAuth session token, which
  resolvePlexAuth now threads through plexAuth.AccountToken (manual-token
  imports pass the user token, which doubles as the account token).
- Watchlist entries become import Records flagged Watchlisted, carrying
  identity only (movie/show → KindMovie/KindSeries, guids parsed) and no
  watch state. They ride the existing matcher (series matching already
  exists), and matched entries are added to the importing profile's
  watchlist via the idempotent AddToWatchlistAt — re-imports do not
  duplicate. A watchlist fetch failure downgrades to a run warning so the
  history import still completes.
- Run summaries gain a watchlist_added counter (new column + repo
  plumbing + client/admin UI cards).

Fixes #245

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(historyimport): count only newly inserted watchlist rows in WatchlistAdded

AddToWatchlistAt now reports whether a row was actually inserted (the
insert is ON CONFLICT DO NOTHING / INSERT OR IGNORE), and the import
summary increments WatchlistAdded only for genuine inserts, so
re-importing the same Plex account no longer inflates the count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-05 00:19:31 -04:00

139 lines
9.7 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 auto-removing
// fully-watched items from the watchlist (defaults true).
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)
}