* feat(watchsync): sync watchlists with Trakt/Simkl/MDBList Extend the watch-providers feature to sync a user's watchlist, generalizing the existing favorites pipeline rather than duplicating it. What changed - Generalize the favorites sync into one ListKind-parameterized pipeline (internal/watchsync/lists.go) driving both favorites and watchlist; the per-favorites service methods are replaced by kind-generic ones. The shadow table watch_provider_favorite_items becomes watch_provider_list_items with a list_kind discriminator. - Providers: Trakt gains watchlist sync (/sync/watchlist, distinct from favorites); Simkl gains plan-to-watch sync; MDBList is re-mapped from favorites to watchlist (its only list is a watchlist) — its capabilities now report import_favorites=false / import_watchlist=true, and the migration re-binds existing MDBList connections. - Auto-remove watched items from the watchlist: a standalone, default-on profile preference (user_profiles.remove_watched_from_watchlist) removes a movie when watched and a series once every episode is watched. Implemented as watchstate.CompletionObserver (internal/watchlist.Maintainer), wired into the manual mark-watched, playback-stop, and jellycompat mark-played paths. - Optional MDBList sort-order mirroring: an opt-in, capability-gated toggle mirrors MDBList's watchlist order into Silo via user_watchlist.sort_index; ListWatchlist orders by sort_index then added_at, so both /api/v1/watchlist and the catalog watchlist view inherit it. - Real-time + scheduled: local add/remove pushes to connected providers immediately (removals gated by the opt-in removals toggle); the hourly job is the inbound/import + retry/reconcile path. - Web: watch-provider settings gain watchlist import/export/removals and "mirror watchlist order" toggles plus watchlist sync stats. Why - The favorites and watchlist pipelines are ~90% identical; generalizing keeps one code path (per CLAUDE.md's anti-duplication guidance) instead of cloning. API/compat - All new fields on ConnectionStatus/Capabilities/ConnectionUpdate/SyncRun and the web types are additive (Silo v1 additive-only rule). No existing field is renamed, removed, or retyped. Risks / follow-up - MDBList capability flip is intentional and client-visible: silo-android / silo-apple may need to surface MDBList under the watchlist (not favorites) UI. - MDBList existing users: their MDBList list previously mirrored Silo favorites and now mirrors Silo watchlist; the first post-migration sync is a union (removals default off), so nothing is destructively purged. - Order mirroring reflects the order MDBList returns from /watchlist/items (couldn't confirm against their docs — Cloudflare-blocked); if it ever diverges from the UI sort, a sort param is the small follow-up. Tests: new maintainer (auto-remove) and watchlist-order unit tests; provider + service tests updated. go build, go test (affected pkgs), migrate-validate, verify-local-paths, web prettier/eslint/tsc all pass. AI-use disclosure: implemented with Claude Code (Claude Opus 4.8). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(watchsync): update list shadow table references * fix(watchsync): address review — retry/progress + error propagation Addresses CodeRabbit review on #227: - maintainer: propagate transient catalog lookup errors instead of silently treating every items.GetByID failure as "maybe an episode". - exportList: mark every queued item not confirmed sent (not_found, failed, or omitted) so the pending loop always advances; the next run's upsert clears the error and re-attempts, so transient failures still retry. - removePendingListItems + realtime removal: treat Sent and NotFound as reconciled; leave true failures pending (no last_error, which would strand them from the removal query) so the scheduled run retries, using in-memory dedupe to terminate the loop. - exportLocalListItems: send the normalized items (with computed ProviderItemKey), not the original event slice. - UpdateConnection: clear mirrored watchlist order before persisting the disable and propagate failures, so a failed clear can't report "disabled" while sort_index ordering is still active. - web: include favorite + watchlist removal counts in the exported "sent" total. - test: align serviceFakeRepo list-state with Postgres (clear last_error on successful transitions); add maintainer error-propagation test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
239 lines
7.0 KiB
Go
239 lines
7.0 KiB
Go
package userdb
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
// Favorite is an alias for the canonical type in userstore.
|
|
type Favorite = userstore.Favorite
|
|
|
|
// WatchlistEntry is an alias for the canonical type in userstore.
|
|
type WatchlistEntry = userstore.WatchlistEntry
|
|
|
|
// ---------- Favorites ----------
|
|
|
|
// AddFavorite adds a media item to a profile's favorites.
|
|
// If the item is already a favorite, the operation is a no-op.
|
|
func AddFavorite(db *sql.DB, profileID, mediaItemID string) error {
|
|
_, err := db.Exec(
|
|
`INSERT OR IGNORE INTO favorites (profile_id, media_item_id, added_at) VALUES (?, ?, ?)`,
|
|
profileID, mediaItemID, nowUTC(),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func AddFavoriteAt(db *sql.DB, profileID, mediaItemID string, addedAt time.Time) error {
|
|
_, err := db.Exec(
|
|
`INSERT OR IGNORE INTO favorites (profile_id, media_item_id, added_at) VALUES (?, ?, ?)`,
|
|
profileID, mediaItemID, addedAt.UTC().Format(time.RFC3339),
|
|
)
|
|
return err
|
|
}
|
|
|
|
// RemoveFavorite removes a media item from a profile's favorites.
|
|
func RemoveFavorite(db *sql.DB, profileID, mediaItemID string) error {
|
|
_, err := db.Exec(
|
|
`DELETE FROM favorites WHERE profile_id = ? AND media_item_id = ?`,
|
|
profileID, mediaItemID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// ListFavorites returns a paginated list of favorites for a profile,
|
|
// ordered by most-recently-added first.
|
|
func ListFavorites(db *sql.DB, profileID string, limit, offset int) ([]Favorite, error) {
|
|
rows, err := db.Query(
|
|
`SELECT profile_id, media_item_id, added_at FROM favorites
|
|
WHERE profile_id = ? ORDER BY added_at DESC LIMIT ? OFFSET ?`,
|
|
profileID, limit, offset,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var favorites []Favorite
|
|
for rows.Next() {
|
|
var f Favorite
|
|
if err := rows.Scan(&f.ProfileID, &f.MediaItemID, &f.AddedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
favorites = append(favorites, f)
|
|
}
|
|
return favorites, rows.Err()
|
|
}
|
|
|
|
// IsFavorite checks whether a media item is in a profile's favorites.
|
|
func IsFavorite(db *sql.DB, profileID, mediaItemID string) (bool, error) {
|
|
var count int
|
|
err := db.QueryRow(
|
|
`SELECT COUNT(*) FROM favorites WHERE profile_id = ? AND media_item_id = ?`,
|
|
profileID, mediaItemID,
|
|
).Scan(&count)
|
|
return count > 0, err
|
|
}
|
|
|
|
func ListFavoritesByMediaItems(db *sql.DB, profileID string, mediaItemIDs []string) (map[string]bool, error) {
|
|
result := make(map[string]bool, len(mediaItemIDs))
|
|
if len(mediaItemIDs) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
placeholders := make([]string, len(mediaItemIDs))
|
|
args := make([]any, 0, len(mediaItemIDs)+1)
|
|
args = append(args, profileID)
|
|
for i, mediaItemID := range mediaItemIDs {
|
|
placeholders[i] = "?"
|
|
args = append(args, mediaItemID)
|
|
}
|
|
|
|
rows, err := db.Query(
|
|
`SELECT media_item_id FROM favorites WHERE profile_id = ? AND media_item_id IN (`+strings.Join(placeholders, ",")+`)`,
|
|
args...,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var mediaItemID string
|
|
if err := rows.Scan(&mediaItemID); err != nil {
|
|
return nil, err
|
|
}
|
|
result[mediaItemID] = true
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// ---------- Watchlist ----------
|
|
|
|
// AddToWatchlist adds a media item to a profile's watchlist.
|
|
// If the item is already on the watchlist, the operation is a no-op.
|
|
func AddToWatchlist(db *sql.DB, profileID, mediaItemID string) error {
|
|
return AddToWatchlistAt(db, profileID, mediaItemID, time.Now().UTC())
|
|
}
|
|
|
|
// AddToWatchlistAt adds a media item to a profile's watchlist with an explicit
|
|
// added-at timestamp (used when importing from an external provider).
|
|
func AddToWatchlistAt(db *sql.DB, profileID, mediaItemID string, addedAt time.Time) error {
|
|
_, err := db.Exec(
|
|
`INSERT OR IGNORE INTO watchlist (profile_id, media_item_id, added_at) VALUES (?, ?, ?)`,
|
|
profileID, mediaItemID, addedAt.UTC().Format(time.RFC3339),
|
|
)
|
|
return err
|
|
}
|
|
|
|
// RemoveFromWatchlist removes a media item from a profile's watchlist.
|
|
func RemoveFromWatchlist(db *sql.DB, profileID, mediaItemID string) error {
|
|
_, err := db.Exec(
|
|
`DELETE FROM watchlist WHERE profile_id = ? AND media_item_id = ?`,
|
|
profileID, mediaItemID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// ReplaceWatchlistOrder assigns sort_index 0..N-1 to the given ids in order and
|
|
// clears sort_index on every other watchlist row for the profile. An empty
|
|
// slice clears all synced ordering.
|
|
func ReplaceWatchlistOrder(db *sql.DB, profileID string, orderedMediaItemIDs []string) error {
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("begin watchlist order tx: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.Exec(
|
|
`UPDATE watchlist SET sort_index = NULL WHERE profile_id = ? AND sort_index IS NOT NULL`,
|
|
profileID,
|
|
); err != nil {
|
|
return fmt.Errorf("clear watchlist order: %w", err)
|
|
}
|
|
for idx, mediaItemID := range orderedMediaItemIDs {
|
|
if _, err := tx.Exec(
|
|
`UPDATE watchlist SET sort_index = ? WHERE profile_id = ? AND media_item_id = ?`,
|
|
idx, profileID, mediaItemID,
|
|
); err != nil {
|
|
return fmt.Errorf("apply watchlist order: %w", err)
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit watchlist order: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListWatchlist returns a paginated list of watchlist entries for a profile.
|
|
// Items with a synced sort_index (mirrored from a provider) come first in that
|
|
// order; locally-added items (sort_index NULL) fall back to newest-first.
|
|
func ListWatchlist(db *sql.DB, profileID string, limit, offset int) ([]WatchlistEntry, error) {
|
|
rows, err := db.Query(
|
|
`SELECT profile_id, media_item_id, added_at FROM watchlist
|
|
WHERE profile_id = ?
|
|
ORDER BY sort_index IS NULL, sort_index ASC, added_at DESC LIMIT ? OFFSET ?`,
|
|
profileID, limit, offset,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var entries []WatchlistEntry
|
|
for rows.Next() {
|
|
var w WatchlistEntry
|
|
if err := rows.Scan(&w.ProfileID, &w.MediaItemID, &w.AddedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
entries = append(entries, w)
|
|
}
|
|
return entries, rows.Err()
|
|
}
|
|
|
|
// InWatchlist checks whether a media item is on a profile's watchlist.
|
|
func InWatchlist(db *sql.DB, profileID, mediaItemID string) (bool, error) {
|
|
var count int
|
|
err := db.QueryRow(
|
|
`SELECT COUNT(*) FROM watchlist WHERE profile_id = ? AND media_item_id = ?`,
|
|
profileID, mediaItemID,
|
|
).Scan(&count)
|
|
return count > 0, err
|
|
}
|
|
|
|
func ListWatchlistByMediaItems(db *sql.DB, profileID string, mediaItemIDs []string) (map[string]bool, error) {
|
|
result := make(map[string]bool, len(mediaItemIDs))
|
|
if len(mediaItemIDs) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
placeholders := make([]string, len(mediaItemIDs))
|
|
args := make([]any, 0, len(mediaItemIDs)+1)
|
|
args = append(args, profileID)
|
|
for i, mediaItemID := range mediaItemIDs {
|
|
placeholders[i] = "?"
|
|
args = append(args, mediaItemID)
|
|
}
|
|
|
|
rows, err := db.Query(
|
|
`SELECT media_item_id FROM watchlist WHERE profile_id = ? AND media_item_id IN (`+strings.Join(placeholders, ",")+`)`,
|
|
args...,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var mediaItemID string
|
|
if err := rows.Scan(&mediaItemID); err != nil {
|
|
return nil, err
|
|
}
|
|
result[mediaItemID] = true
|
|
}
|
|
return result, rows.Err()
|
|
}
|