* 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>
92 lines
2.8 KiB
Go
92 lines
2.8 KiB
Go
package userdb
|
|
|
|
import (
|
|
"database/sql"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func watchlistIDs(t *testing.T, db *sql.DB, profileID string) []string {
|
|
t.Helper()
|
|
entries, err := ListWatchlist(db, profileID, 100, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListWatchlist: %v", err)
|
|
}
|
|
ids := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
ids = append(ids, e.MediaItemID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func TestWatchlistOrderMirrorsProviderSequence(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer db.Close()
|
|
if err := InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
|
|
const profile = "profile-1"
|
|
// Add in one order (added_at ascending a, b, c).
|
|
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
for i, id := range []string{"a", "b", "c"} {
|
|
if _, err := AddToWatchlistAt(db, profile, id, base.Add(time.Duration(i)*time.Hour)); err != nil {
|
|
t.Fatalf("AddToWatchlistAt %s: %v", id, err)
|
|
}
|
|
}
|
|
|
|
// Without a synced order, newest-added comes first.
|
|
if got := watchlistIDs(t, db, profile); !equalStrings(got, []string{"c", "b", "a"}) {
|
|
t.Fatalf("default order = %v, want [c b a]", got)
|
|
}
|
|
|
|
// Mirror a provider order of c, a, b.
|
|
if err := ReplaceWatchlistOrder(db, profile, []string{"c", "a", "b"}); err != nil {
|
|
t.Fatalf("ReplaceWatchlistOrder: %v", err)
|
|
}
|
|
if got := watchlistIDs(t, db, profile); !equalStrings(got, []string{"c", "a", "b"}) {
|
|
t.Fatalf("synced order = %v, want [c a b]", got)
|
|
}
|
|
|
|
// A locally-added item (no synced index) sorts after the ordered ones.
|
|
if _, err := AddToWatchlistAt(db, profile, "d", base.Add(10*time.Hour)); err != nil {
|
|
t.Fatalf("AddToWatchlistAt d: %v", err)
|
|
}
|
|
if got := watchlistIDs(t, db, profile); !equalStrings(got, []string{"c", "a", "b", "d"}) {
|
|
t.Fatalf("order with local add = %v, want [c a b d]", got)
|
|
}
|
|
|
|
// Re-syncing a shorter provider list drops removed items' index and keeps
|
|
// the new order; items no longer present fall back to added_at.
|
|
if err := ReplaceWatchlistOrder(db, profile, []string{"b", "a"}); err != nil {
|
|
t.Fatalf("ReplaceWatchlistOrder re-sync: %v", err)
|
|
}
|
|
// b, a are positioned; c and d are unpositioned → after, newest-first (d then c).
|
|
if got := watchlistIDs(t, db, profile); !equalStrings(got, []string{"b", "a", "d", "c"}) {
|
|
t.Fatalf("re-synced order = %v, want [b a d c]", got)
|
|
}
|
|
|
|
// Clearing the order reverts everything to added_at DESC.
|
|
if err := ReplaceWatchlistOrder(db, profile, nil); err != nil {
|
|
t.Fatalf("ReplaceWatchlistOrder clear: %v", err)
|
|
}
|
|
if got := watchlistIDs(t, db, profile); !equalStrings(got, []string{"d", "c", "b", "a"}) {
|
|
t.Fatalf("cleared order = %v, want [d c b a]", got)
|
|
}
|
|
}
|
|
|
|
func equalStrings(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|