Skip to content

chore: align Simkl API client with docs.simkl.org#13

Merged
NickReisenauer merged 5 commits into
mainfrom
chore/simkl-api-audit-2026-05
May 22, 2026
Merged

chore: align Simkl API client with docs.simkl.org#13
NickReisenauer merged 5 commits into
mainfrom
chore/simkl-api-audit-2026-05

Conversation

@NickReisenauer

Copy link
Copy Markdown
Contributor

Summary

Audited every Simkl endpoint the iOS app uses against the updated docs at docs.simkl.org and the live API. Fixes JSON-decoder drift, removes a pattern Simkl explicitly warns can suspend a client_id, and migrates two legacy paths. Build passes; live-tested with my account end-to-end on every category of change.

What changed, grouped by why

🔴 Patterns docs now warn against

  • /sync/watched per-item in up-next sync → single batched POST. Loop in processUpNextEpisodes made N calls (30 for me) every 6h; now 1 call. Docs explicitly say "calling [/sync/watched] alongside /sync/all-items is one of the patterns that gets an app's client_id suspended."
  • /sync/all-items/* parallel fan-out → sequential. Docs: "Everything else (sync, search, user-state) must be sequential." Trending CDN + per-id stale refresh stay parallel (those are explicitly allowed).
  • extended=specials aloneextended=episodes,specials. Old form still worked but is undocumented behavior.

🟡 Drift — field names that were always silently nil

  • TVModel.ids.traktslugtrakttvslug (live returns the latter)
  • MoviesModel.ids.tvdbmslugtvdbslug, traktslugtraktmslug, added tvdb
  • AnimeModel.ids: pruned 13 fields that never come back; added tvdb/tvdbslug/trakttvslug to match live response
  • LastActivitiesModel: added playback (new field on all 3 blocks)
  • Fixed latent bug: SDAnimes.id_anilist was never populated even though the decoder had the field

🔵 Future-proofing migrations

  • Trending → CDN: api.simkl.com/{type}/trending/todaydata.simkl.in/discover/trending/{type}/today_100.json. Documented path, 1h CDN cache, parallel-allowed. Bonus: returns 100 items vs the legacy ~50.
  • /search/id (legacy) → /redirect in ActorDetailViewModel. Captures the 301 Location header via a stateless URLSessionTaskDelegate that prevents follow.

🟢 Anime[] bucket consistency

Anime mutations were posting under "shows": (Simkl resolves by simkl_id so it worked, but isn't idiomatic). Fixed in 6 places: addAnimeRating, markEpisodeWatched/Unwatched, updateAnimeList (×2), addMemoToAnime, plus the UpNext swipe-to-watch which was dispatching anime items through ShowDetailView.markEpisodeWatched.

What I deliberately did NOT change

  • /sync/ratings/{type}/1,2,...,10 filter: would shrink payload ~32× for movies but regresses unrate detection — when a user unrates an item, rated_at activity bumps and the unfiltered endpoint returns the row with user_rating: null so SwiftData can clear it. The filtered endpoint excludes it. Skipping for correctness.
  • Removing /sync/watched entirely: detail-view single-item calls remain. Docs warn about the bulk pattern (per-show in a loop) which I batched. Per-detail-view-open calls are user-initiated single-item lookups — safe.

Live validation done with my account

Test Result
Every model decodes against live /sync/all-items/* and /sync/activities ✓ All required fields covered, extras ignored
Trending CDN ✓ 100 items each for tv/movies/anime, all required fields present
Batched /sync/watched (30 shows in 1 call) ✓ ordered response, all seasons[].episodes[] populated
Anime add-to-list with anime[] bucket ✓ lands in anime/plantowatch, NOT shows/plantowatch
Anime ratings with anime[] bucket ✓ rating set correctly
Anime history mark-watched with anime[] ✓ episode marked, server reports simkl_type: "anime"
Anime history/remove episode-level + item-level ✓ both work, library restored to original state
/redirect flow via standalone Swift script (mirror of iOS code) ✓ 4/4 cases (tv/movie/anime/not-found)
Sendable/concurrency warnings ✓ Zero source-level warnings
Xcode build ✓ BUILD SUCCEEDED

SwiftData migration

None needed. No @Model classes touched — only JSON decoder structs. Some always-nil SwiftData columns will start getting populated organically as last_activities timestamps bump per bucket (e.g. SDShows.id_traktslug was always nil, now gets the trakttvslug value). I grepped and zero UI code reads those columns, so it's a backfill of dead-write data.

Test plan

  • Open the app → trending populates with 100 items per type, posters load
  • Open a show detail → episode watched state shows correctly
  • Open an anime detail → episode watched state shows correctly
  • Mark an episode watched from UpNext (swipe) → row updates in-place, no flicker
  • Mark an anime episode watched from UpNext → routes through AnimeDetailView (new), works the same
  • Open an actor detail page → filmography destinations resolve correctly (now via /redirect)
  • Add a memo to an anime → persists
  • Rate an anime → persists
  • Add anime to plantowatch from detail → appears in user's anime list (not shows)
  • First-time sync on a fresh device → all buckets populate (sequential, should take longer than before but not break)

🤖 Generated with Claude Code

Live-audited every endpoint the iOS app uses against the new Simkl docs
and the live API. Fixes drift in JSON decoders, removes a pattern Simkl
explicitly warns can suspend a client_id, and migrates two legacy paths.

Model decoders (JSON only — SwiftData schemas unchanged):
- TVModel: traktslug -> trakttvslug; drop dead offen/instagram/tw/jwslug
- MoviesModel: tvdbmslug -> tvdbslug, traktslug -> traktmslug, +tvdb
- AnimeModel: prune 13 dead ids; +tvdb/tvdbslug/trakttvslug; fix latent
  id_anilist bug where the column was never populated
- LastActivitiesModel: +playback on all three blocks
- ActorModel: remove unused SimklIDLookupResponse/SimklIDLookupIDs

Endpoint migrations:
- Trending: api.simkl.com/{type}/trending/today -> data.simkl.in CDN
  (100 items per type vs ~50, documented path, parallel-allowed)
- ActorDetailViewModel: legacy /search/id -> /redirect with a no-redirect
  URLSessionTaskDelegate that captures the 301 Location header
- /sync/watched: extended=specials -> extended=episodes,specials
  (documented form; previous form silently still worked)
- Up-next sync: per-show /sync/watched loop -> single batched POST,
  chunked at the 100-item cap. Docs warn the loop pattern alongside
  /sync/all-items can get a client_id suspended

Concurrency / correctness:
- /sync/* fan-out parallelized via withTaskGroup -> sequential per docs
  rate-limit guidance. Trending CDN + per-id stale refresh stay parallel
- Each sync call gets its own ModelContext (matches the original
  task-isolation pattern after dropping the TaskGroup)
- Dictionary(uniqueKeysWithValues:) -> last-wins merge so an unexpected
  server duplicate can't crash up-next sync
- Anime POSTs route under "anime[]" bucket instead of "shows[]" across
  ratings, history, history/remove, add-to-list, memos, plus the UpNext
  swipe-to-watch path (which used the show helper for anime items)

Live-validated each fix end-to-end against the user's account: trending
CDN parity, batched /sync/watched (30 items in 1 call, ordered), anime[]
add->rate->mark-watched->remove round-trip on a throwaway title, and the
/redirect flow via a standalone Swift script that mirrors the iOS code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the app’s Simkl integration to match current docs.simkl.org and observed live API responses, focusing on safer sync patterns, corrected decoding for drifted fields, and a couple of endpoint migrations.

Changes:

  • Reworked sync execution to avoid Simkl-documented risky calling patterns (batch /sync/watched, sequentialize /sync/* calls, keep CDN trending parallel).
  • Updated multiple decoded models to align with current field names/shape (IDs drift, playback in activities, anime ID fixes).
  • Migrated trending to the Simkl CDN JSON feed and replaced legacy /search/id usage with /redirect for actor destinations.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
simalytics/Views/UpNextView.swift Routes swipe-to-watch to the correct mark-watched endpoint for anime vs TV.
simalytics/ViewModels/Sync.swift Sequentializes /sync/* calls and batches watched lookups for Up Next.
simalytics/ViewModels/ShowDetailViewModel.swift Adds batched /sync/watched lookup; updates extended params.
simalytics/ViewModels/MemoViewModel.swift Uses the correct anime bucket for anime memo mutations.
simalytics/ViewModels/ExploreViewModel.swift Switches trending to the Simkl CDN JSON feed via a shared fetch helper.
simalytics/ViewModels/AnimeDetailViewModel.swift Uses the correct anime bucket and adds batched watched lookup; updates extended params.
simalytics/ViewModels/ActorDetailViewModel.swift Migrates TMDB→Simkl lookup from /search/id to /redirect and parses the Location header.
simalytics/Models/API/Sync/TVModel.swift Aligns TV IDs decoding/mapping with current field names (e.g., Trakt slug drift).
simalytics/Models/API/Sync/MoviesModel.swift Aligns movie IDs decoding/mapping with current field names.
simalytics/Models/API/Sync/LastActivitiesModel.swift Adds playback activity fields for anime/movies/tv blocks.
simalytics/Models/API/Sync/AnimeModel.swift Prunes non-returned anime ID fields and maps corrected IDs (incl. anilist population).
simalytics/Models/API/External/ActorModel.swift Removes legacy /search/id response structs no longer used.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread simalytics/ViewModels/Sync.swift Outdated
Comment thread simalytics/ViewModels/ShowDetailViewModel.swift Outdated
Comment thread simalytics/ViewModels/AnimeDetailViewModel.swift Outdated
Comment thread simalytics/ViewModels/ActorDetailViewModel.swift
…/redirect

1. SDLastSync race (Sync.swift):
   Trending + stale-data refresh used to run in parallel with the sequential
   /sync/* chain via async let. All three paths fetch the same SDLastSync(id:1)
   row through separate ModelContexts, mutate their own field, then save the
   whole row — last write wins on the entire row, so a stale-loaded context
   could clobber another's field. Now sequenced after the /sync/* chain.

2. Silent batch failures (Show/AnimeDetailViewModel):
   getShowWatchlistBatch / getAnimeWatchlistBatch dropped up to 100 items'
   worth of watched state on a non-200 response with no signal. Now reports
   the status code to Sentry before continuing so rate-limit / auth issues
   surface.

3. Burst /redirect calls (ActorDetailViewModel.filmographyItems):
   80 concurrent /redirect lookups easily exceed Simkl's 10 GET/sec/client_id
   ceiling. /redirect isn't on the parallel-allowed list. Bounded the
   TaskGroup to 5 in-flight tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comment thread simalytics/Views/UpNextView.swift
Comment thread simalytics/ViewModels/Sync.swift Outdated
…he stamp

1. Anime specials in UpNext (Sync.swift):
   processUpNextEpisodes could pick a season-0 (special) episode as next-to-watch
   for anime, but SDAnimes doesn't persist next_to_watch_info_season and the
   UpNext swipe path hardcodes season=1. The swipe would POST season=1 + the
   special's episode number, marking the wrong episode (or no-op). Filter
   specials out of the unwatched/watched anime candidates so UpNext only
   surfaces main-run episodes — these can be marked watched correctly under
   season=1.

2. Stale changes_api stamp on partial batch failure (Sync.swift + batch helpers):
   A non-200 chunk in getShowWatchlistBatch / getAnimeWatchlistBatch was reported
   to Sentry but the cache was still stamped fresh, so the 6h gate would
   suppress retries until the next scheduled tick. Each batch helper now
   returns WatchlistBatch { items, hadFailures } and processUpNextEpisodes
   only stamps syncRecord.changes_api when both batches succeeded entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comment thread simalytics/ViewModels/ShowDetailViewModel.swift Outdated
Comment thread simalytics/ViewModels/Sync.swift Outdated
…lify sync steps

1. Shared /sync/watched batch helper (SimklWatchedBatch.swift):
   getShowWatchlistBatch and getAnimeWatchlistBatch were 95% identical —
   only "tv" vs "anime" and the decoded model differed. Extracted a generic
   simklWatchedBatch<T: Decodable & Sendable>() into a new shared file so
   future Simkl-spec changes (chunk size, headers, error reporting) only
   need to be made in one place. ShowDetailView/AnimeDetailView now expose
   thin typed wrappers.

   Added Sendable conformance on ShowWatchlistModel, AnimeWatchlistModel,
   WatchlistSeason, WatchlistEpisode so the generic result can cross actor
   boundaries from the MainActor-isolated view extensions.

2. Sync step list (Sync.swift):
   The phase-1 sequential await chain repeated `accessToken` + a fresh
   `ModelContext(modelContainer)` on every line. Folded the boilerplate
   into a local async `step(_:_:)` helper so each bucket is now one line
   naming just the activity key and the handler. Preserves sequential
   execution and keeps function names visible in stack traces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 2 comments.

Comment thread simalytics/ViewModels/SimklWatchedBatch.swift Outdated
Comment thread simalytics/ViewModels/SimklWatchedBatch.swift Outdated
- Removed unused `import Sentry` from SimklWatchedBatch.swift (errors route
  through reportError which already imports Sentry).
- Removed the unused `decode: T.Type` parameter from simklWatchedBatch.
  The generic T is inferred from each caller's declared return type
  (SimklWatchedBatch<ShowWatchlistModel> / SimklWatchedBatch<AnimeWatchlistModel>)
  so the explicit phantom argument was dead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.

@NickReisenauer NickReisenauer merged commit bcfbffc into main May 22, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants