chore: align Simkl API client with docs.simkl.org#13
Merged
Conversation
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>
There was a problem hiding this comment.
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,
playbackin activities, anime ID fixes). - Migrated trending to the Simkl CDN JSON feed and replaced legacy
/search/idusage with/redirectfor 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.
…/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>
…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>
…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>
- 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Audited every Simkl endpoint the iOS app uses against the updated docs at
docs.simkl.organd 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/watchedper-item in up-next sync → single batched POST. Loop inprocessUpNextEpisodesmade N calls (30 for me) every 6h; now 1 call. Docs explicitly say "calling [/sync/watched] alongside/sync/all-itemsis 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=specialsalone →extended=episodes,specials. Old form still worked but is undocumented behavior.🟡 Drift — field names that were always silently
nilTVModel.ids.traktslug→trakttvslug(live returns the latter)MoviesModel.ids.tvdbmslug→tvdbslug,traktslug→traktmslug, addedtvdbAnimeModel.ids: pruned 13 fields that never come back; addedtvdb/tvdbslug/trakttvslugto match live responseLastActivitiesModel: addedplayback(new field on all 3 blocks)SDAnimes.id_anilistwas never populated even though the decoder had the field🔵 Future-proofing migrations
api.simkl.com/{type}/trending/today→data.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) →/redirectinActorDetailViewModel. Captures the 301Locationheader via a statelessURLSessionTaskDelegatethat 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 throughShowDetailView.markEpisodeWatched.What I deliberately did NOT change
/sync/ratings/{type}/1,2,...,10filter: would shrink payload ~32× for movies but regresses unrate detection — when a user unrates an item,rated_atactivity bumps and the unfiltered endpoint returns the row withuser_rating: nullso SwiftData can clear it. The filtered endpoint excludes it. Skipping for correctness./sync/watchedentirely: 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
/sync/all-items/*and/sync/activities/sync/watched(30 shows in 1 call)seasons[].episodes[]populatedadd-to-listwithanime[]bucketratingswithanime[]buckethistorymark-watched withanime[]simkl_type: "anime"history/removeepisode-level + item-level/redirectflow via standalone Swift script (mirror of iOS code)SwiftData migration
None needed. No
@Modelclasses touched — only JSON decoder structs. Some always-nil SwiftData columns will start getting populated organically aslast_activitiestimestamps bump per bucket (e.g.SDShows.id_traktslugwas always nil, now gets thetrakttvslugvalue). I grepped and zero UI code reads those columns, so it's a backfill of dead-write data.Test plan
AnimeDetailView(new), works the same/redirect)🤖 Generated with Claude Code