* 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>
228 lines
6.1 KiB
Go
228 lines
6.1 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
const defaultOrphanedProvisionalCleanupBatchSize = 1000
|
|
|
|
const orphanedMediaItemSafetyConditions = `NOT EXISTS (
|
|
SELECT 1 FROM public.media_item_libraries mil
|
|
WHERE mil.content_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.media_files mf
|
|
WHERE mf.content_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.episodes e
|
|
WHERE e.series_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.seasons s
|
|
WHERE s.series_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.library_collection_items lci
|
|
WHERE lci.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.abs_bookmarks ab
|
|
WHERE ab.library_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.abs_playback_sessions aps
|
|
WHERE aps.content_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.abs_rss_feeds arf
|
|
WHERE arf.library_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.downloads d
|
|
WHERE d.content_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.playback_history_admin pha
|
|
WHERE pha.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.plex_sync_item_bindings psib
|
|
WHERE psib.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.plex_sync_item_state psis
|
|
WHERE psis.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.podcast_feeds pf
|
|
WHERE pf.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.user_favorites uf
|
|
WHERE uf.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.user_downloads ud
|
|
WHERE ud.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.user_history_hidden_items uhhi
|
|
WHERE uhhi.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.user_home_item_dismissals uhid
|
|
WHERE uhid.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.user_home_item_dismissals uhid_series
|
|
WHERE uhid_series.series_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.user_audio_preferences uap
|
|
WHERE uap.series_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.user_personal_collection_items upci
|
|
WHERE upci.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.user_ratings ur
|
|
WHERE ur.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.user_series_playback_preferences uspp
|
|
WHERE uspp.series_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.user_subtitle_preferences usp
|
|
WHERE usp.series_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.user_watch_history uwh
|
|
WHERE uwh.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.user_watch_progress uwp
|
|
WHERE uwp.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.user_watchlist uwl
|
|
WHERE uwl.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.watch_provider_list_items wpli
|
|
WHERE wpli.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.watch_provider_history_exports wphe
|
|
WHERE wphe.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.watch_provider_scrobble_sessions wpss
|
|
WHERE wpss.media_item_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.watch_together_rooms wtr
|
|
WHERE wtr.selected_content_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.watch_together_suggestions wts
|
|
WHERE wts.content_id = mi.content_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.webhook_sync_item_state wsis
|
|
WHERE wsis.media_item_id = mi.content_id
|
|
)`
|
|
|
|
const orphanedProvisionalMediaItemConditions = `mi.status IN ('pending', 'unmatched', 'ambiguous')
|
|
AND ` + orphanedMediaItemSafetyConditions
|
|
|
|
const orphanedProvisionalMediaItemPredicate = `
|
|
WHERE ` + orphanedProvisionalMediaItemConditions
|
|
|
|
const deleteOrphanedProvisionalBatchSQL = `
|
|
WITH candidates AS (
|
|
SELECT mi.content_id
|
|
FROM public.media_items mi
|
|
` + orphanedProvisionalMediaItemPredicate + `
|
|
ORDER BY mi.content_id ASC
|
|
LIMIT $1
|
|
)
|
|
DELETE FROM public.media_items mi
|
|
USING candidates c
|
|
WHERE mi.content_id = c.content_id
|
|
RETURNING mi.content_id
|
|
`
|
|
|
|
type OrphanedProvisionalCleanupStats struct {
|
|
Candidates int
|
|
Deleted int
|
|
}
|
|
|
|
type OrphanedProvisionalCleaner struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewOrphanedProvisionalCleaner(pool *pgxpool.Pool) *OrphanedProvisionalCleaner {
|
|
return &OrphanedProvisionalCleaner{pool: pool}
|
|
}
|
|
|
|
func (c *OrphanedProvisionalCleaner) Cleanup(ctx context.Context, batchSize int) (OrphanedProvisionalCleanupStats, error) {
|
|
var stats OrphanedProvisionalCleanupStats
|
|
if c == nil || c.pool == nil {
|
|
return stats, fmt.Errorf("orphaned provisional cleanup is not configured")
|
|
}
|
|
if batchSize <= 0 {
|
|
batchSize = defaultOrphanedProvisionalCleanupBatchSize
|
|
}
|
|
|
|
if err := c.pool.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM public.media_items mi `+orphanedProvisionalMediaItemPredicate,
|
|
).Scan(&stats.Candidates); err != nil {
|
|
return stats, fmt.Errorf("counting orphaned provisional media items: %w", err)
|
|
}
|
|
|
|
for {
|
|
deletedIDs, err := c.cleanupBatch(ctx, batchSize)
|
|
if err != nil {
|
|
return stats, fmt.Errorf("deleting orphaned provisional media items: %w", err)
|
|
}
|
|
|
|
stats.Deleted += len(deletedIDs)
|
|
if len(deletedIDs) < batchSize {
|
|
break
|
|
}
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
func (c *OrphanedProvisionalCleaner) cleanupBatch(ctx context.Context, batchSize int) ([]string, error) {
|
|
tx, err := c.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
rows, err := tx.Query(ctx, deleteOrphanedProvisionalBatchSQL, batchSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
deletedIDs, err := pgx.CollectRows(rows, pgx.RowTo[string])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("collecting deleted orphaned provisional IDs: %w", err)
|
|
}
|
|
if err := EnqueueSearchIndexDeletes(ctx, tx, deletedIDs); err != nil {
|
|
return nil, fmt.Errorf("enqueueing catalog search orphaned provisional deletes: %w", err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return deletedIDs, nil
|
|
}
|