From 5e01c6b8152858e36b0cef43b64cf2a7f85e438b Mon Sep 17 00:00:00 2001 From: Nick Reisenauer Date: Fri, 22 May 2026 12:57:23 -0700 Subject: [PATCH 1/5] chore: align Simkl API client with current docs.simkl.org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Models/API/External/ActorModel.swift | 9 -- simalytics/Models/API/Sync/AnimeModel.swift | 43 ++--- .../Models/API/Sync/LastActivitiesModel.swift | 3 + simalytics/Models/API/Sync/MoviesModel.swift | 15 +- simalytics/Models/API/Sync/TVModel.swift | 16 +- .../ViewModels/ActorDetailViewModel.swift | 54 +++++-- .../ViewModels/AnimeDetailViewModel.swift | 43 ++++- simalytics/ViewModels/ExploreViewModel.swift | 72 ++------- simalytics/ViewModels/MemoViewModel.swift | 2 +- .../ViewModels/ShowDetailViewModel.swift | 33 +++- simalytics/ViewModels/Sync.swift | 149 +++++++----------- simalytics/Views/UpNextView.swift | 24 ++- 12 files changed, 237 insertions(+), 226 deletions(-) diff --git a/simalytics/Models/API/External/ActorModel.swift b/simalytics/Models/API/External/ActorModel.swift index 23a3f90..b411c26 100644 --- a/simalytics/Models/API/External/ActorModel.swift +++ b/simalytics/Models/API/External/ActorModel.swift @@ -101,12 +101,3 @@ struct ActorFilmographyItem: Identifiable, Hashable { return true } } - -struct SimklIDLookupResponse: Codable { - let type: String? - let ids: SimklIDLookupIDs? -} - -struct SimklIDLookupIDs: Codable { - let simkl: Int? -} diff --git a/simalytics/Models/API/Sync/AnimeModel.swift b/simalytics/Models/API/Sync/AnimeModel.swift index f2eb97e..bfc266c 100644 --- a/simalytics/Models/API/Sync/AnimeModel.swift +++ b/simalytics/Models/API/Sync/AnimeModel.swift @@ -38,25 +38,15 @@ struct AnimeModel_record_item: Codable { struct AnimeModel_record_item_ids: Codable { let simkl: Int let slug: String? - let offjp: String? - let ann: String? let mal: String? - let anfo: String? - let offen: String? - let wikien: String? - let wikijp: String? - let allcin: String? let imdb: String? let tmdb: String? let anilist: String? - let animeplanet: String? - let anisearch: String? let kitsu: String? - let livechart: String? - let traktslug: String? - let letterslug: String? - let jwslug: String? let anidb: String? + let tvdb: String? + let tvdbslug: String? + let trakttvslug: String? } struct AnimeModel_record_item_memo: Codable { @@ -93,23 +83,24 @@ extension AnimeModel_record { memo_text: memo?.text, memo_is_private: memo?.is_private, id_slug: show?.ids.slug, - id_offjp: show?.ids.offjp, - id_ann: show?.ids.ann, + id_offjp: nil, + id_ann: nil, id_mal: show?.ids.mal, - id_anfo: show?.ids.anfo, - id_offen: show?.ids.offen, - id_wikien: show?.ids.wikien, - id_wikijp: show?.ids.wikijp, - id_allcin: show?.ids.allcin, + id_anfo: nil, + id_offen: nil, + id_wikien: nil, + id_wikijp: nil, + id_allcin: nil, id_imdb: show?.ids.imdb, id_tmdb: show?.ids.tmdb, - id_animeplanet: show?.ids.animeplanet, - id_anisearch: show?.ids.anisearch, + id_anilist: show?.ids.anilist, + id_animeplanet: nil, + id_anisearch: nil, id_kitsu: show?.ids.kitsu, - id_livechart: show?.ids.livechart, - id_traktslug: show?.ids.traktslug, - id_letterslug: show?.ids.letterslug, - id_jwslug: show?.ids.jwslug, + id_livechart: nil, + id_traktslug: show?.ids.trakttvslug, + id_letterslug: nil, + id_jwslug: nil, id_anidb: show?.ids.anidb, next_to_watch_info_title: next_to_watch_info?.title, next_to_watch_info_episode: next_to_watch_info?.episode, diff --git a/simalytics/Models/API/Sync/LastActivitiesModel.swift b/simalytics/Models/API/Sync/LastActivitiesModel.swift index 7699842..34251f8 100644 --- a/simalytics/Models/API/Sync/LastActivitiesModel.swift +++ b/simalytics/Models/API/Sync/LastActivitiesModel.swift @@ -18,6 +18,7 @@ struct LastActivitiesModel: Codable { struct LastActivitiesAnimeModel: Codable { let all: String? let rated_at: String? + let playback: String? let plantowatch: String? let watching: String? let completed: String? @@ -29,6 +30,7 @@ struct LastActivitiesAnimeModel: Codable { struct LastActivitiesMovieModel: Codable { let all: String? let rated_at: String? + let playback: String? let plantowatch: String? let completed: String? let dropped: String? @@ -38,6 +40,7 @@ struct LastActivitiesMovieModel: Codable { struct LastActivitiesTVModel: Codable { let all: String? let rated_at: String? + let playback: String? let plantowatch: String? let watching: String? let completed: String? diff --git a/simalytics/Models/API/Sync/MoviesModel.swift b/simalytics/Models/API/Sync/MoviesModel.swift index 02fccca..f131f2d 100644 --- a/simalytics/Models/API/Sync/MoviesModel.swift +++ b/simalytics/Models/API/Sync/MoviesModel.swift @@ -31,12 +31,11 @@ struct MoviesModel_movie_item: Codable { struct MoviesModel_movie_item_ids: Codable { let simkl: Int let slug: String? - let tvdbmslug: String? + let tvdbslug: String? + let tvdb: String? let imdb: String? - let offen: String? - let traktslug: String? + let traktmslug: String? let letterslug: String? - let jwslug: String? let tmdb: String? } @@ -60,12 +59,12 @@ extension MoviesModel_movie { poster: movie?.poster, year: movie?.year, id_slug: movie?.ids?.slug, - id_tvdbmslug: movie?.ids?.tvdbmslug, + id_tvdbmslug: movie?.ids?.tvdbslug, id_imdb: movie?.ids?.imdb, - id_offen: movie?.ids?.offen, - id_traktslug: movie?.ids?.traktslug, + id_offen: nil, + id_traktslug: movie?.ids?.traktmslug, id_letterslug: movie?.ids?.letterslug, - id_jwslug: movie?.ids?.jwslug, + id_jwslug: nil, id_tmdb: movie?.ids?.tmdb, memo_text: memo?.text, memo_is_private: memo?.is_private diff --git a/simalytics/Models/API/Sync/TVModel.swift b/simalytics/Models/API/Sync/TVModel.swift index e53a08a..ebfb576 100644 --- a/simalytics/Models/API/Sync/TVModel.swift +++ b/simalytics/Models/API/Sync/TVModel.swift @@ -37,14 +37,10 @@ struct TVModel_show_item: Codable { struct TVModel_show_item_ids: Codable { let simkl: Int let slug: String? - let offen: String? let tvdbslug: String? - let instagram: String? - let tw: String? let imdb: String? let tmdb: String? - let traktslug: String? - let jwslug: String? + let trakttvslug: String? let tvdb: String? } @@ -82,14 +78,14 @@ extension TVModel_show { memo_text: memo?.text, memo_is_private: memo?.is_private, id_slug: show?.ids?.slug, - id_offen: show?.ids?.offen, + id_offen: nil, id_tvdbslug: show?.ids?.tvdbslug, - id_instagram: show?.ids?.instagram, - id_tw: show?.ids?.tw, + id_instagram: nil, + id_tw: nil, id_imdb: show?.ids?.imdb, id_tmdb: show?.ids?.tmdb, - id_traktslug: show?.ids?.traktslug, - id_jwslug: show?.ids?.jwslug, + id_traktslug: show?.ids?.trakttvslug, + id_jwslug: nil, id_tvdb: show?.ids?.tvdb, next_to_watch_info_title: next_to_watch_info?.title, next_to_watch_info_season: next_to_watch_info?.season, diff --git a/simalytics/ViewModels/ActorDetailViewModel.swift b/simalytics/ViewModels/ActorDetailViewModel.swift index 84fcdc9..022948d 100644 --- a/simalytics/ViewModels/ActorDetailViewModel.swift +++ b/simalytics/ViewModels/ActorDetailViewModel.swift @@ -7,6 +7,23 @@ import Foundation +// Stateless task delegate used to capture 3xx responses without following them. +// Required for the /redirect endpoint, which encodes the simkl id in the +// Location header rather than returning a JSON body. +private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate { + static let shared = NoRedirectDelegate() + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + completionHandler(nil) + } +} + extension ActorDetailView { static func getActorDetails(_ accessToken: String, personID: Int) async -> TMDBPersonDetails? { do { @@ -50,11 +67,12 @@ extension ActorDetailView { private static func resolveDestination(for credit: TMDBPersonCredit) async -> MediaDestination? { let lookupType = credit.media_type == "movie" ? "movie" : "show" - guard var urlComponents = URLComponents(string: "https://api.simkl.com/search/id") else { + guard var urlComponents = URLComponents(string: "https://api.simkl.com/redirect") else { return nil } urlComponents.queryItems = [ + URLQueryItem(name: "to", value: "Simkl"), URLQueryItem(name: "tmdb", value: String(credit.id)), URLQueryItem(name: "type", value: lookupType), URLQueryItem(name: "client_id", value: SIMKL_CLIENT_ID), @@ -62,24 +80,40 @@ extension ActorDetailView { do { guard let url = urlComponents.url else { return nil } - var request = URLRequest(url: url) - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let (data, response) = try await URLSession.shared.data(for: request) - guard (response as? HTTPURLResponse)?.statusCode == 200 else { return nil } - - let results = try JSONDecoder().decode([SimklIDLookupResponse].self, from: data) - guard let result = results.first, let simklID = result.ids?.simkl else { + let request = URLRequest(url: url) + + // Simkl recommends /redirect over the legacy /search/id endpoint: it returns + // 301 with the simkl id in the Location URL — no JSON body to parse. We + // need to capture that 301 instead of letting URLSession follow it. + let (_, response) = try await URLSession.shared.data(for: request, delegate: NoRedirectDelegate.shared) + guard let http = response as? HTTPURLResponse, + http.statusCode == 301, + let location = http.value(forHTTPHeaderField: "Location"), + let parsed = parseSimklRedirectLocation(location) else { return await resolveDestinationByTitle(for: credit) } - return mediaDestination(type: result.type, simklID: simklID, fallbackMediaType: credit.media_type) + return mediaDestination(type: parsed.type, simklID: parsed.simklID, fallbackMediaType: credit.media_type) } catch { reportError(error) return await resolveDestinationByTitle(for: credit) } } + // Parses the Location header from /redirect, which looks like + // "//simkl.com/{tv|movies|anime}/{simklID}/{slug}?client_id=…". + // Returns nil for the not-found case ("//simkl.com?client_id=…"). + fileprivate static func parseSimklRedirectLocation(_ location: String) -> (type: String, simklID: Int)? { + let normalized = location.hasPrefix("//") ? "https:" + location : location + guard let url = URL(string: normalized) else { return nil } + + let segments = url.pathComponents.filter { $0 != "/" } + guard segments.count >= 2 else { return nil } + let type = segments[0] + guard ["tv", "movies", "anime"].contains(type), let simklID = Int(segments[1]) else { return nil } + return (type: type, simklID: simklID) + } + private static func resolveDestinationByTitle(for credit: TMDBPersonCredit) async -> MediaDestination? { let searchType = credit.media_type == "movie" ? "movie" : "tv" let results = await SearchResultsView.fetchResults(searchText: credit.displayTitle, type: searchType) diff --git a/simalytics/ViewModels/AnimeDetailViewModel.swift b/simalytics/ViewModels/AnimeDetailViewModel.swift index 2d11cb4..4402612 100644 --- a/simalytics/ViewModels/AnimeDetailViewModel.swift +++ b/simalytics/ViewModels/AnimeDetailViewModel.swift @@ -45,7 +45,7 @@ extension AnimeDetailView { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") let body: [String: Any] = [ - "shows": [ + "anime": [ [ "title": title, "ids": [ @@ -74,9 +74,40 @@ extension AnimeDetailView { } } + // Batched variant for callers (e.g. up-next sync) that need watched data + // for many anime at once. Chunked to stay under Simkl's 100-item cap when + // extended=episodes is set. + static func getAnimeWatchlistBatch(_ simklIDs: [Int], _ accessToken: String) async -> [AnimeWatchlistModel] { + guard !simklIDs.isEmpty else { return [] } + let chunkSize = 100 + let chunks = stride(from: 0, to: simklIDs.count, by: chunkSize).map { + Array(simklIDs[$0.. AnimeWatchlistModel? { do { - let urlComponents = URLComponents(string: "https://api.simkl.com/sync/watched?extended=specials")! + let urlComponents = URLComponents(string: "https://api.simkl.com/sync/watched?extended=episodes,specials")! var request = URLRequest(url: urlComponents.url!) request.httpMethod = "POST" @@ -105,7 +136,7 @@ extension AnimeDetailView { request.setValue(SIMKL_CLIENT_ID, forHTTPHeaderField: "simkl-api-key") request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") let body: [String: Any] = [ - "shows": [ + "anime": [ [ "rating": rating, "ids": [ @@ -134,7 +165,7 @@ extension AnimeDetailView { let formatter = ISO8601DateFormatter() let dateString = formatter.string(from: Date()) let body: [String: Any] = [ - "shows": [ + "anime": [ [ "title": title, "ids": [ @@ -228,7 +259,7 @@ extension AnimeWatchlistButton { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") let body: [String: Any] = [ - "shows": [ + "anime": [ [ "ids": [ "simkl": simkl_id @@ -248,7 +279,7 @@ extension AnimeWatchlistButton { request.setValue(SIMKL_CLIENT_ID, forHTTPHeaderField: "simkl-api-key") request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") let body: [String: Any] = [ - "shows": [ + "anime": [ [ "to": list, "ids": [ diff --git a/simalytics/ViewModels/ExploreViewModel.swift b/simalytics/ViewModels/ExploreViewModel.swift index c6fb641..aed7231 100644 --- a/simalytics/ViewModels/ExploreViewModel.swift +++ b/simalytics/ViewModels/ExploreViewModel.swift @@ -8,74 +8,34 @@ import Foundation import Sentry -func getTrendingMovies() async -> [TrendingMovieModel] { - do { - var urlComponents = URLComponents(string: "https://api.simkl.com/movies/trending/today")! - urlComponents.queryItems = [ - URLQueryItem(name: "extended", value: "overview,theater,metadata,tmdb,genres"), - URLQueryItem(name: "client_id", value: SIMKL_CLIENT_ID), - ] - print(urlComponents.url!) +// Trending data is served from the CDN host data.simkl.in per the current +// Simkl docs. Each file ships title/poster/ids and is cached for ~1h. +private let SIMKL_TRENDING_BASE = "https://data.simkl.in/discover/trending" - var request = URLRequest(url: urlComponents.url!) - request.setValue("application/json", forHTTPHeaderField: "Content-Type") +private func fetchTrendingArray(_ type: String) async -> [T] { + guard let url = URL(string: "\(SIMKL_TRENDING_BASE)/\(type)/today_100.json") else { return [] } + do { + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") let (data, response) = try await URLSession.shared.data(for: request) - guard (response as? HTTPURLResponse)?.statusCode == 200 else { - return [] - } + guard (response as? HTTPURLResponse)?.statusCode == 200 else { return [] } - return try JSONDecoder().decode([TrendingMovieModel].self, from: data) + return try JSONDecoder().decode([T].self, from: data) } catch { SentrySDK.capture(error: error) return [] } } -func getTrendingAnimes() async -> [TrendingAnimeModel] { - do { - var urlComponents = URLComponents(string: "https://api.simkl.com/anime/trending/today")! - urlComponents.queryItems = [ - URLQueryItem(name: "extended", value: "overview,metadata,tmdb,genres"), - URLQueryItem(name: "client_id", value: SIMKL_CLIENT_ID), - ] - print(urlComponents.url!) - - var request = URLRequest(url: urlComponents.url!) - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let (data, response) = try await URLSession.shared.data(for: request) - guard (response as? HTTPURLResponse)?.statusCode == 200 else { - return [] - } +func getTrendingMovies() async -> [TrendingMovieModel] { + await fetchTrendingArray("movies") +} - return try JSONDecoder().decode([TrendingAnimeModel].self, from: data) - } catch { - SentrySDK.capture(error: error) - return [] - } +func getTrendingAnimes() async -> [TrendingAnimeModel] { + await fetchTrendingArray("anime") } func getTrendingShows() async -> [TrendingShowModel] { - do { - var urlComponents = URLComponents(string: "https://api.simkl.com/tv/trending/today")! - urlComponents.queryItems = [ - URLQueryItem(name: "extended", value: "overview,metadata,tmdb,genres"), - URLQueryItem(name: "client_id", value: SIMKL_CLIENT_ID), - ] - print(urlComponents.url!) - - var request = URLRequest(url: urlComponents.url!) - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let (data, response) = try await URLSession.shared.data(for: request) - guard (response as? HTTPURLResponse)?.statusCode == 200 else { - return [] - } - - return try JSONDecoder().decode([TrendingShowModel].self, from: data) - } catch { - SentrySDK.capture(error: error) - return [] - } + await fetchTrendingArray("tv") } diff --git a/simalytics/ViewModels/MemoViewModel.swift b/simalytics/ViewModels/MemoViewModel.swift index 673b072..0a55d2a 100644 --- a/simalytics/ViewModels/MemoViewModel.swift +++ b/simalytics/ViewModels/MemoViewModel.swift @@ -99,7 +99,7 @@ func addMemoToAnime(accessToken: String, simkl: Int, memoText: String, isPrivate request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") let body: [String: Any] = [ - "shows": [ + "anime": [ [ "status": status, "memo": [ diff --git a/simalytics/ViewModels/ShowDetailViewModel.swift b/simalytics/ViewModels/ShowDetailViewModel.swift index a40b4ca..57349f1 100644 --- a/simalytics/ViewModels/ShowDetailViewModel.swift +++ b/simalytics/ViewModels/ShowDetailViewModel.swift @@ -34,9 +34,40 @@ extension ShowDetailView { } } + // Batched variant for callers (e.g. up-next sync) that need watched data + // for many shows at once. Simkl caps /sync/watched at 100 items per request + // when extended=episodes is set, so we chunk. + static func getShowWatchlistBatch(_ simklIDs: [Int], _ accessToken: String) async -> [ShowWatchlistModel] { + guard !simklIDs.isEmpty else { return [] } + let chunkSize = 100 + let chunks = stride(from: 0, to: simklIDs.count, by: chunkSize).map { + Array(simklIDs[$0.. ShowWatchlistModel? { do { - let urlComponents = URLComponents(string: "https://api.simkl.com/sync/watched?extended=specials")! + let urlComponents = URLComponents(string: "https://api.simkl.com/sync/watched?extended=episodes,specials")! var request = URLRequest(url: urlComponents.url!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") diff --git a/simalytics/ViewModels/Sync.swift b/simalytics/ViewModels/Sync.swift index 7699ad0..4922f15 100644 --- a/simalytics/ViewModels/Sync.swift +++ b/simalytics/ViewModels/Sync.swift @@ -35,96 +35,40 @@ func syncLatestActivities( let (data, _) = try await URLSession.shared.data(for: request) let result = try JSONDecoder().decode(LastActivitiesModel.self, from: data) - // Phase 1: fetch every list in parallel. processUpNextEpisodes is - // intentionally NOT in this group — it reads SDShows/SDAnimes that - // these tasks are writing, so racing them produces empty results - // and stamps the 6h cache too early. - await withTaskGroup(of: Void.self) { group in - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreMoviesPlanToWatch(accessToken, result.movies?.plantowatch, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreMoviesDropped(accessToken, result.movies?.dropped, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreMoviesCompleted(accessToken, result.movies?.completed, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreMoviesRemovedFromList(accessToken, result.movies?.removed_from_list, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreMoviesRatedAt(accessToken, result.movies?.rated_at, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreTVPlanToWatch(accessToken, result.tv_shows?.plantowatch, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreTVCompleted(accessToken, result.tv_shows?.completed, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreTVHold(accessToken, result.tv_shows?.hold, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreTVDropped(accessToken, result.tv_shows?.dropped, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreTVWatching(accessToken, result.tv_shows?.watching, context, forceRefresh: forceRefresh) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreTVRemovedFromList(accessToken, result.tv_shows?.removed_from_list, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreTVRatedAt(accessToken, result.tv_shows?.rated_at, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreAnimePlanToWatch(accessToken, result.anime?.plantowatch, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreAnimeDropped(accessToken, result.anime?.dropped, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreAnimeCompleted(accessToken, result.anime?.completed, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreAnimeHold(accessToken, result.anime?.hold, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreAnimeRatedAt(accessToken, result.anime?.rated_at, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreAnimeRemovedFromList(accessToken, result.anime?.removed_from_list, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await fetchAndStoreAnimeWatching(accessToken, result.anime?.watching, context, forceRefresh: forceRefresh) - } - group.addTask { - let context = ModelContext(modelContainer) - await syncLatestTrending(accessToken, context) - } - group.addTask { - let context = ModelContext(modelContainer) - await refreshStaleData(accessToken, context) - } - } + // Trending (CDN) and refreshStaleData (per-id detail endpoints) are the + // only paths Simkl docs explicitly allow to run in parallel. /sync/* + // must be sequential per the documented rate-limit guidance, so we run + // those one at a time while trending/stale refresh run concurrently in + // the background. processUpNextEpisodes is intentionally NOT in this + // stage — it reads SDShows/SDAnimes that these tasks are writing. + // + // Each sync call gets a fresh ModelContext: SwiftData contexts aren't + // designed to outlive a single unit of work and shouldn't be reused + // across awaits that may resume on different threads. + async let trendingTask: Void = syncLatestTrending(accessToken, ModelContext(modelContainer)) + async let staleTask: Void = refreshStaleData(accessToken, ModelContext(modelContainer)) + + await fetchAndStoreMoviesPlanToWatch(accessToken, result.movies?.plantowatch, ModelContext(modelContainer)) + await fetchAndStoreMoviesDropped(accessToken, result.movies?.dropped, ModelContext(modelContainer)) + await fetchAndStoreMoviesCompleted(accessToken, result.movies?.completed, ModelContext(modelContainer)) + await fetchAndStoreMoviesRemovedFromList(accessToken, result.movies?.removed_from_list, ModelContext(modelContainer)) + await fetchAndStoreMoviesRatedAt(accessToken, result.movies?.rated_at, ModelContext(modelContainer)) + await fetchAndStoreTVPlanToWatch(accessToken, result.tv_shows?.plantowatch, ModelContext(modelContainer)) + await fetchAndStoreTVCompleted(accessToken, result.tv_shows?.completed, ModelContext(modelContainer)) + await fetchAndStoreTVHold(accessToken, result.tv_shows?.hold, ModelContext(modelContainer)) + await fetchAndStoreTVDropped(accessToken, result.tv_shows?.dropped, ModelContext(modelContainer)) + await fetchAndStoreTVWatching(accessToken, result.tv_shows?.watching, ModelContext(modelContainer), forceRefresh: forceRefresh) + await fetchAndStoreTVRemovedFromList(accessToken, result.tv_shows?.removed_from_list, ModelContext(modelContainer)) + await fetchAndStoreTVRatedAt(accessToken, result.tv_shows?.rated_at, ModelContext(modelContainer)) + await fetchAndStoreAnimePlanToWatch(accessToken, result.anime?.plantowatch, ModelContext(modelContainer)) + await fetchAndStoreAnimeDropped(accessToken, result.anime?.dropped, ModelContext(modelContainer)) + await fetchAndStoreAnimeCompleted(accessToken, result.anime?.completed, ModelContext(modelContainer)) + await fetchAndStoreAnimeHold(accessToken, result.anime?.hold, ModelContext(modelContainer)) + await fetchAndStoreAnimeRatedAt(accessToken, result.anime?.rated_at, ModelContext(modelContainer)) + await fetchAndStoreAnimeRemovedFromList(accessToken, result.anime?.removed_from_list, ModelContext(modelContainer)) + await fetchAndStoreAnimeWatching(accessToken, result.anime?.watching, ModelContext(modelContainer), forceRefresh: forceRefresh) + + _ = await (trendingTask, staleTask) // Phase 2: now that SDShows/SDAnimes are populated, compute up next. let upNextContext = ModelContext(modelContainer) @@ -1589,8 +1533,21 @@ func processUpNextEpisodes( sdAnimesFD.propertiesToFetch = [\.simkl] let sdAnimesIds = try context.fetch(sdAnimesFD) + // Batch the /sync/watched lookups: docs warn against per-item calls when + // /sync/all-items is also in use. One request handles up to 100 shows. + // We index by simkl with a last-write-wins merge so an unexpected + // duplicate from the server can't crash the sync (uniqueKeysWithValues + // would). + let showWatchedBatch = await ShowDetailView.getShowWatchlistBatch( + sdShowsIds.map { $0.simkl }, accessToken + ) + let showWatchedByID = Dictionary( + showWatchedBatch.map { ($0.simkl, $0) }, + uniquingKeysWith: { _, last in last } + ) + for show in sdShowsIds { - let watchedEpisodes = await ShowDetailView.getShowWatchlist(show.simkl, accessToken) + let watchedEpisodes = showWatchedByID[show.simkl] guard let watched = watchedEpisodes, @@ -1673,8 +1630,16 @@ func processUpNextEpisodes( } } + let animeWatchedBatch = await AnimeDetailView.getAnimeWatchlistBatch( + sdAnimesIds.map { $0.simkl }, accessToken + ) + let animeWatchedByID = Dictionary( + animeWatchedBatch.map { ($0.simkl, $0) }, + uniquingKeysWith: { _, last in last } + ) + for anime in sdAnimesIds { - let watchedEpisodes = await AnimeDetailView.getAnimeWatchlist(anime.simkl, accessToken) + let watchedEpisodes = animeWatchedByID[anime.simkl] guard let watched = watchedEpisodes, diff --git a/simalytics/Views/UpNextView.swift b/simalytics/Views/UpNextView.swift index b36f8ab..44ebf7b 100644 --- a/simalytics/Views/UpNextView.swift +++ b/simalytics/Views/UpNextView.swift @@ -115,13 +115,23 @@ struct UpNextView: View { // SwiftData update the episode text in-place (S7E1 → S7E2). invalidateUpNextCache(modelContainer: context.container) - await ShowDetailView.markEpisodeWatched( - auth.simklAccessToken, - mediaItem.title ?? "", - mediaItem.simkl, - postSeason, - postEpisode - ) + if isAnime { + await AnimeDetailView.markEpisodeWatched( + auth.simklAccessToken, + mediaItem.title ?? "", + mediaItem.simkl, + postSeason, + postEpisode + ) + } else { + await ShowDetailView.markEpisodeWatched( + auth.simklAccessToken, + mediaItem.title ?? "", + mediaItem.simkl, + postSeason, + postEpisode + ) + } await syncLatestActivities( auth.simklAccessToken, modelContainer: context.container, From c68b93068bcff421a05de31ea97cab79bc362bb0 Mon Sep 17 00:00:00 2001 From: Nick Reisenauer Date: Fri, 22 May 2026 13:24:50 -0700 Subject: [PATCH 2/5] fix: address Copilot PR review on sync races, silent failures, burst /redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ViewModels/ActorDetailViewModel.swift | 17 +++++++++-- .../ViewModels/AnimeDetailViewModel.swift | 10 ++++++- .../ViewModels/ShowDetailViewModel.swift | 10 ++++++- simalytics/ViewModels/Sync.swift | 28 ++++++++++--------- 4 files changed, 47 insertions(+), 18 deletions(-) diff --git a/simalytics/ViewModels/ActorDetailViewModel.swift b/simalytics/ViewModels/ActorDetailViewModel.swift index 022948d..8558fa3 100644 --- a/simalytics/ViewModels/ActorDetailViewModel.swift +++ b/simalytics/ViewModels/ActorDetailViewModel.swift @@ -45,16 +45,27 @@ extension ActorDetailView { } static func filmographyItems(from details: TMDBPersonDetails) async -> [ActorFilmographyItem] { - let credits = details.sortedFilmographyCredits + let credits = Array(details.sortedFilmographyCredits.prefix(80)) + // Bound concurrency so we don't burst 80 simultaneous /redirect calls + // through Simkl's 10 GET/sec/client_id ceiling. Detail endpoints are + // documented as parallel-allowed but /redirect isn't on the parallel + // allowlist, and a fallback to /search/{type} can fire from here too. + let maxConcurrent = 5 return await withTaskGroup(of: (Int, ActorFilmographyItem).self) { group in - for (index, credit) in credits.prefix(80).enumerated() { + var indexedItems: [(Int, ActorFilmographyItem)] = [] + + for (index, credit) in credits.enumerated() { + if index >= maxConcurrent { + if let finished = await group.next() { + indexedItems.append(finished) + } + } group.addTask { let destination = await resolveDestination(for: credit) return (index, ActorFilmographyItem(credit: credit, destination: destination)) } } - var indexedItems: [(Int, ActorFilmographyItem)] = [] for await item in group { indexedItems.append(item) } diff --git a/simalytics/ViewModels/AnimeDetailViewModel.swift b/simalytics/ViewModels/AnimeDetailViewModel.swift index 4402612..ee59429 100644 --- a/simalytics/ViewModels/AnimeDetailViewModel.swift +++ b/simalytics/ViewModels/AnimeDetailViewModel.swift @@ -96,7 +96,15 @@ extension AnimeDetailView { request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await URLSession.shared.data(for: request) - guard (response as? HTTPURLResponse)?.statusCode == 200 else { continue } + // A non-200 here drops watched state for up to 100 anime — surface + // it to Sentry so rate-limit / auth issues don't fail silently. + if let status = (response as? HTTPURLResponse)?.statusCode, status != 200 { + reportError(NSError( + domain: "Simkl", code: status, + userInfo: [NSLocalizedDescriptionKey: "Batched /sync/watched (anime) returned HTTP \(status) for \(chunk.count) ids"] + )) + continue + } combined.append(contentsOf: try JSONDecoder().decode([AnimeWatchlistModel].self, from: data)) } catch { reportError(error) diff --git a/simalytics/ViewModels/ShowDetailViewModel.swift b/simalytics/ViewModels/ShowDetailViewModel.swift index 57349f1..c372d92 100644 --- a/simalytics/ViewModels/ShowDetailViewModel.swift +++ b/simalytics/ViewModels/ShowDetailViewModel.swift @@ -56,7 +56,15 @@ extension ShowDetailView { request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await URLSession.shared.data(for: request) - guard (response as? HTTPURLResponse)?.statusCode == 200 else { continue } + // A non-200 here drops watched state for up to 100 shows — surface + // it to Sentry so rate-limit / auth issues don't fail silently. + if let status = (response as? HTTPURLResponse)?.statusCode, status != 200 { + reportError(NSError( + domain: "Simkl", code: status, + userInfo: [NSLocalizedDescriptionKey: "Batched /sync/watched (tv) returned HTTP \(status) for \(chunk.count) ids"] + )) + continue + } combined.append(contentsOf: try JSONDecoder().decode([ShowWatchlistModel].self, from: data)) } catch { reportError(error) diff --git a/simalytics/ViewModels/Sync.swift b/simalytics/ViewModels/Sync.swift index 4922f15..bbd19f2 100644 --- a/simalytics/ViewModels/Sync.swift +++ b/simalytics/ViewModels/Sync.swift @@ -35,19 +35,16 @@ func syncLatestActivities( let (data, _) = try await URLSession.shared.data(for: request) let result = try JSONDecoder().decode(LastActivitiesModel.self, from: data) - // Trending (CDN) and refreshStaleData (per-id detail endpoints) are the - // only paths Simkl docs explicitly allow to run in parallel. /sync/* - // must be sequential per the documented rate-limit guidance, so we run - // those one at a time while trending/stale refresh run concurrently in - // the background. processUpNextEpisodes is intentionally NOT in this - // stage — it reads SDShows/SDAnimes that these tasks are writing. + // Phase 1: every task that mutates SDLastSync(id: 1) runs sequentially. + // Each function fetches the row, writes its own field, then saves the + // whole row — so concurrent contexts can clobber each other's field + // updates (last write wins on the entire row). The /sync/* endpoints + // also need to be sequential per Simkl's documented rate-limit + // guidance. Each call gets a fresh ModelContext so the context stays + // confined to a single unit of work. // - // Each sync call gets a fresh ModelContext: SwiftData contexts aren't - // designed to outlive a single unit of work and shouldn't be reused - // across awaits that may resume on different threads. - async let trendingTask: Void = syncLatestTrending(accessToken, ModelContext(modelContainer)) - async let staleTask: Void = refreshStaleData(accessToken, ModelContext(modelContainer)) - + // processUpNextEpisodes is intentionally NOT in this stage — it reads + // SDShows/SDAnimes that these tasks are writing. await fetchAndStoreMoviesPlanToWatch(accessToken, result.movies?.plantowatch, ModelContext(modelContainer)) await fetchAndStoreMoviesDropped(accessToken, result.movies?.dropped, ModelContext(modelContainer)) await fetchAndStoreMoviesCompleted(accessToken, result.movies?.completed, ModelContext(modelContainer)) @@ -68,7 +65,12 @@ func syncLatestActivities( await fetchAndStoreAnimeRemovedFromList(accessToken, result.anime?.removed_from_list, ModelContext(modelContainer)) await fetchAndStoreAnimeWatching(accessToken, result.anime?.watching, ModelContext(modelContainer), forceRefresh: forceRefresh) - _ = await (trendingTask, staleTask) + // Trending (CDN) and stale-data refresh (per-id detail endpoints) also + // write to SDLastSync, so they're sequenced here for the same + // last-write-wins reason. Their internal network work parallelizes + // independently — only the final SDLastSync writes need ordering. + await syncLatestTrending(accessToken, ModelContext(modelContainer)) + await refreshStaleData(accessToken, ModelContext(modelContainer)) // Phase 2: now that SDShows/SDAnimes are populated, compute up next. let upNextContext = ModelContext(modelContainer) From 664fbf154aacfe43a357fa948de2d98aa69d0921 Mon Sep 17 00:00:00 2001 From: Nick Reisenauer Date: Fri, 22 May 2026 13:33:49 -0700 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20address=20Copilot=20PR=20review=20pa?= =?UTF-8?q?ss=202=20=E2=80=94=20anime=20specials=20in=20UpNext,=20cache=20?= =?UTF-8?q?stamp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ViewModels/AnimeDetailViewModel.swift | 17 +++++++--- .../ViewModels/ShowDetailViewModel.swift | 17 +++++++--- simalytics/ViewModels/Sync.swift | 34 ++++++++++++++----- 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/simalytics/ViewModels/AnimeDetailViewModel.swift b/simalytics/ViewModels/AnimeDetailViewModel.swift index ee59429..06f244e 100644 --- a/simalytics/ViewModels/AnimeDetailViewModel.swift +++ b/simalytics/ViewModels/AnimeDetailViewModel.swift @@ -76,14 +76,21 @@ extension AnimeDetailView { // Batched variant for callers (e.g. up-next sync) that need watched data // for many anime at once. Chunked to stay under Simkl's 100-item cap when - // extended=episodes is set. - static func getAnimeWatchlistBatch(_ simklIDs: [Int], _ accessToken: String) async -> [AnimeWatchlistModel] { - guard !simklIDs.isEmpty else { return [] } + // extended=episodes is set. `hadFailures` lets the caller skip stamping + // a "fresh" cache when partial data came back. + struct WatchlistBatch { + let items: [AnimeWatchlistModel] + let hadFailures: Bool + } + + static func getAnimeWatchlistBatch(_ simklIDs: [Int], _ accessToken: String) async -> WatchlistBatch { + guard !simklIDs.isEmpty else { return WatchlistBatch(items: [], hadFailures: false) } let chunkSize = 100 let chunks = stride(from: 0, to: simklIDs.count, by: chunkSize).map { Array(simklIDs[$0.. AnimeWatchlistModel? { diff --git a/simalytics/ViewModels/ShowDetailViewModel.swift b/simalytics/ViewModels/ShowDetailViewModel.swift index c372d92..336db2b 100644 --- a/simalytics/ViewModels/ShowDetailViewModel.swift +++ b/simalytics/ViewModels/ShowDetailViewModel.swift @@ -36,14 +36,21 @@ extension ShowDetailView { // Batched variant for callers (e.g. up-next sync) that need watched data // for many shows at once. Simkl caps /sync/watched at 100 items per request - // when extended=episodes is set, so we chunk. - static func getShowWatchlistBatch(_ simklIDs: [Int], _ accessToken: String) async -> [ShowWatchlistModel] { - guard !simklIDs.isEmpty else { return [] } + // when extended=episodes is set, so we chunk. `hadFailures` lets the + // caller skip stamping a "fresh" cache when partial data came back. + struct WatchlistBatch { + let items: [ShowWatchlistModel] + let hadFailures: Bool + } + + static func getShowWatchlistBatch(_ simklIDs: [Int], _ accessToken: String) async -> WatchlistBatch { + guard !simklIDs.isEmpty else { return WatchlistBatch(items: [], hadFailures: false) } let chunkSize = 100 let chunks = stride(from: 0, to: simklIDs.count, by: chunkSize).map { Array(simklIDs[$0.. ShowWatchlistModel? { diff --git a/simalytics/ViewModels/Sync.swift b/simalytics/ViewModels/Sync.swift index bbd19f2..122387d 100644 --- a/simalytics/ViewModels/Sync.swift +++ b/simalytics/ViewModels/Sync.swift @@ -1539,12 +1539,13 @@ func processUpNextEpisodes( // /sync/all-items is also in use. One request handles up to 100 shows. // We index by simkl with a last-write-wins merge so an unexpected // duplicate from the server can't crash the sync (uniqueKeysWithValues - // would). + // would). `hadFailures` propagates so we can skip stamping the 6h cache + // when any chunk failed (otherwise stale rows stay stale for 6h). let showWatchedBatch = await ShowDetailView.getShowWatchlistBatch( sdShowsIds.map { $0.simkl }, accessToken ) let showWatchedByID = Dictionary( - showWatchedBatch.map { ($0.simkl, $0) }, + showWatchedBatch.items.map { ($0.simkl, $0) }, uniquingKeysWith: { _, last in last } ) @@ -1636,7 +1637,7 @@ func processUpNextEpisodes( sdAnimesIds.map { $0.simkl }, accessToken ) let animeWatchedByID = Dictionary( - animeWatchedBatch.map { ($0.simkl, $0) }, + animeWatchedBatch.items.map { ($0.simkl, $0) }, uniquingKeysWith: { _, last in last } ) @@ -1663,20 +1664,29 @@ func processUpNextEpisodes( .watched ?? false } + // Exclude specials (season 0 per getAnimeEpisodes mapping). SDAnimes + // doesn't persist next_to_watch_info_season, and the UpNext swipe + // path hardcodes season=1 for anime — so surfacing a special as + // "next" would POST to season 1 + the special's episode number and + // mark the wrong episode (or no-op). Until anime season is plumbed + // through SDAnimes + UpNext, restrict to main-run. let unwatched = allEpisodes .filter { $0.aired == true } + .filter { ($0.season ?? 1) != 0 } .filter { episode in guard let epNum = episode.episode else { return false } let seasonNum = episode.season ?? 1 return !watchedLookup(seasonNum, epNum) } - let actuallyWatchedEpisodes = allEpisodes.filter { episode in - guard let epNum = episode.episode else { return false } - let seasonNum = episode.season ?? 1 - return watchedLookup(seasonNum, epNum) - } + let actuallyWatchedEpisodes = allEpisodes + .filter { ($0.season ?? 1) != 0 } + .filter { episode in + guard let epNum = episode.episode else { return false } + let seasonNum = episode.season ?? 1 + return watchedLookup(seasonNum, epNum) + } // Find the highest watched episode (latest) let highestWatched = @@ -1715,7 +1725,13 @@ func processUpNextEpisodes( } } - syncRecord!.changes_api = now.ISO8601Format() + // Only stamp the 6h cache as fresh if every batch chunk succeeded. + // If a /sync/watched chunk failed (rate limit, auth, network), the + // affected shows/anime kept their previous next_to_watch_info — we + // need the next scheduled run to retry instead of skipping for 6h. + if !showWatchedBatch.hadFailures && !animeWatchedBatch.hadFailures { + syncRecord!.changes_api = now.ISO8601Format() + } try context.save() } catch { SentrySDK.capture(error: error) From 45e4272a18b9aab9ad6f2fbb56fc8cea009facc4 Mon Sep 17 00:00:00 2001 From: Nick Reisenauer Date: Fri, 22 May 2026 16:26:42 -0700 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20address=20Copilot=20PR=20review?= =?UTF-8?q?=20pass=203=20=E2=80=94=20share=20batch=20helper,=20simplify=20?= =?UTF-8?q?sync=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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() 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) --- .../Models/API/Common/SharedTypes.swift | 4 +- .../API/Watchlist/AnimeWatchlistModel.swift | 2 +- .../API/Watchlist/ShowWatchlistModel.swift | 2 +- .../ViewModels/AnimeDetailViewModel.swift | 51 ++------------ .../ViewModels/ShowDetailViewModel.swift | 51 ++------------ simalytics/ViewModels/SimklWatchedBatch.swift | 66 +++++++++++++++++++ simalytics/ViewModels/Sync.swift | 52 +++++++++------ 7 files changed, 113 insertions(+), 115 deletions(-) create mode 100644 simalytics/ViewModels/SimklWatchedBatch.swift diff --git a/simalytics/Models/API/Common/SharedTypes.swift b/simalytics/Models/API/Common/SharedTypes.swift index 8aa3bcc..0739cf2 100644 --- a/simalytics/Models/API/Common/SharedTypes.swift +++ b/simalytics/Models/API/Common/SharedTypes.swift @@ -21,7 +21,7 @@ struct Ratings: Codable { // MARK: - Watchlist Structures -struct WatchlistSeason: Codable { +struct WatchlistSeason: Codable, Sendable { let number: Int? let episodes_total: Int? let episodes_aired: Int? @@ -30,7 +30,7 @@ struct WatchlistSeason: Codable { var episodes: [WatchlistEpisode]? } -struct WatchlistEpisode: Codable { +struct WatchlistEpisode: Codable, Sendable { let number: Int? var watched: Bool? let aired: Bool? diff --git a/simalytics/Models/API/Watchlist/AnimeWatchlistModel.swift b/simalytics/Models/API/Watchlist/AnimeWatchlistModel.swift index 28c0310..928a002 100644 --- a/simalytics/Models/API/Watchlist/AnimeWatchlistModel.swift +++ b/simalytics/Models/API/Watchlist/AnimeWatchlistModel.swift @@ -7,7 +7,7 @@ import Foundation -struct AnimeWatchlistModel: Codable { +struct AnimeWatchlistModel: Codable, Sendable { let list: String? let last_watched_at: String? let simkl: Int diff --git a/simalytics/Models/API/Watchlist/ShowWatchlistModel.swift b/simalytics/Models/API/Watchlist/ShowWatchlistModel.swift index 33adef1..84c1eb7 100644 --- a/simalytics/Models/API/Watchlist/ShowWatchlistModel.swift +++ b/simalytics/Models/API/Watchlist/ShowWatchlistModel.swift @@ -7,7 +7,7 @@ import Foundation -struct ShowWatchlistModel: Codable { +struct ShowWatchlistModel: Codable, Sendable { let list: String? let last_watched_at: String? let simkl: Int diff --git a/simalytics/ViewModels/AnimeDetailViewModel.swift b/simalytics/ViewModels/AnimeDetailViewModel.swift index 06f244e..00810f8 100644 --- a/simalytics/ViewModels/AnimeDetailViewModel.swift +++ b/simalytics/ViewModels/AnimeDetailViewModel.swift @@ -75,51 +75,12 @@ extension AnimeDetailView { } // Batched variant for callers (e.g. up-next sync) that need watched data - // for many anime at once. Chunked to stay under Simkl's 100-item cap when - // extended=episodes is set. `hadFailures` lets the caller skip stamping - // a "fresh" cache when partial data came back. - struct WatchlistBatch { - let items: [AnimeWatchlistModel] - let hadFailures: Bool - } - - static func getAnimeWatchlistBatch(_ simklIDs: [Int], _ accessToken: String) async -> WatchlistBatch { - guard !simklIDs.isEmpty else { return WatchlistBatch(items: [], hadFailures: false) } - let chunkSize = 100 - let chunks = stride(from: 0, to: simklIDs.count, by: chunkSize).map { - Array(simklIDs[$0.. SimklWatchedBatch { + await simklWatchedBatch( + simklIDs: simklIDs, type: "anime", accessToken: accessToken, decode: AnimeWatchlistModel.self + ) } static func getAnimeWatchlist(_ simkl_id: Int, _ accessToken: String) async -> AnimeWatchlistModel? { diff --git a/simalytics/ViewModels/ShowDetailViewModel.swift b/simalytics/ViewModels/ShowDetailViewModel.swift index 336db2b..d011d62 100644 --- a/simalytics/ViewModels/ShowDetailViewModel.swift +++ b/simalytics/ViewModels/ShowDetailViewModel.swift @@ -35,51 +35,12 @@ extension ShowDetailView { } // Batched variant for callers (e.g. up-next sync) that need watched data - // for many shows at once. Simkl caps /sync/watched at 100 items per request - // when extended=episodes is set, so we chunk. `hadFailures` lets the - // caller skip stamping a "fresh" cache when partial data came back. - struct WatchlistBatch { - let items: [ShowWatchlistModel] - let hadFailures: Bool - } - - static func getShowWatchlistBatch(_ simklIDs: [Int], _ accessToken: String) async -> WatchlistBatch { - guard !simklIDs.isEmpty else { return WatchlistBatch(items: [], hadFailures: false) } - let chunkSize = 100 - let chunks = stride(from: 0, to: simklIDs.count, by: chunkSize).map { - Array(simklIDs[$0.. SimklWatchedBatch { + await simklWatchedBatch( + simklIDs: simklIDs, type: "tv", accessToken: accessToken, decode: ShowWatchlistModel.self + ) } static func getShowWatchlist(_ simkl_id: Int, _ accessToken: String) async -> ShowWatchlistModel? { diff --git a/simalytics/ViewModels/SimklWatchedBatch.swift b/simalytics/ViewModels/SimklWatchedBatch.swift new file mode 100644 index 0000000..fb38818 --- /dev/null +++ b/simalytics/ViewModels/SimklWatchedBatch.swift @@ -0,0 +1,66 @@ +// +// SimklWatchedBatch.swift +// simalytics +// +// Shared batched POST helper for /sync/watched. The TV and anime callers +// only differ in the `type` field they put in the request body and the +// model they decode the response into — everything else (chunking, auth +// headers, status reporting, hadFailures bookkeeping) is identical. +// + +import Foundation +import Sentry + +// Result of a batched /sync/watched lookup. `hadFailures` lets callers +// decide whether the data is complete enough to stamp a "fresh" cache. +struct SimklWatchedBatch: Sendable { + let items: [T] + let hadFailures: Bool +} + +// POSTs `simklIDs` to /sync/watched in 100-item chunks (Simkl's documented +// cap when extended=episodes is set) and decodes each chunk into `[T]`. +// Non-200 chunks are reported to Sentry and flagged via `hadFailures` +// rather than thrown so a partial result is still usable. +func simklWatchedBatch( + simklIDs: [Int], + type: String, + accessToken: String, + decode: T.Type +) async -> SimklWatchedBatch { + guard !simklIDs.isEmpty else { return SimklWatchedBatch(items: [], hadFailures: false) } + let chunkSize = 100 + let chunks = stride(from: 0, to: simklIDs.count, by: chunkSize).map { + Array(simklIDs[$0.. Void) async { + await handler(accessToken, activity, ModelContext(modelContainer)) + } + + await step(result.movies?.plantowatch, fetchAndStoreMoviesPlanToWatch) + await step(result.movies?.dropped, fetchAndStoreMoviesDropped) + await step(result.movies?.completed, fetchAndStoreMoviesCompleted) + await step(result.movies?.removed_from_list, fetchAndStoreMoviesRemovedFromList) + await step(result.movies?.rated_at, fetchAndStoreMoviesRatedAt) + await step(result.tv_shows?.plantowatch, fetchAndStoreTVPlanToWatch) + await step(result.tv_shows?.completed, fetchAndStoreTVCompleted) + await step(result.tv_shows?.hold, fetchAndStoreTVHold) + await step(result.tv_shows?.dropped, fetchAndStoreTVDropped) + await step(result.tv_shows?.watching) { token, activity, ctx in + await fetchAndStoreTVWatching(token, activity, ctx, forceRefresh: forceRefresh) + } + await step(result.tv_shows?.removed_from_list, fetchAndStoreTVRemovedFromList) + await step(result.tv_shows?.rated_at, fetchAndStoreTVRatedAt) + await step(result.anime?.plantowatch, fetchAndStoreAnimePlanToWatch) + await step(result.anime?.dropped, fetchAndStoreAnimeDropped) + await step(result.anime?.completed, fetchAndStoreAnimeCompleted) + await step(result.anime?.hold, fetchAndStoreAnimeHold) + await step(result.anime?.rated_at, fetchAndStoreAnimeRatedAt) + await step(result.anime?.removed_from_list, fetchAndStoreAnimeRemovedFromList) + await step(result.anime?.watching) { token, activity, ctx in + await fetchAndStoreAnimeWatching(token, activity, ctx, forceRefresh: forceRefresh) + } // Trending (CDN) and stale-data refresh (per-id detail endpoints) also // write to SDLastSync, so they're sequenced here for the same From d6c2ee02c307d1c00ce9918a424362f06a3627d1 Mon Sep 17 00:00:00 2001 From: Nick Reisenauer Date: Fri, 22 May 2026 16:35:47 -0700 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20address=20Copilot=20PR=20review=20?= =?UTF-8?q?pass=204=20=E2=80=94=20drop=20unused=20import=20+=20parameter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 / SimklWatchedBatch) so the explicit phantom argument was dead. Co-Authored-By: Claude Opus 4.7 (1M context) --- simalytics/ViewModels/AnimeDetailViewModel.swift | 4 +--- simalytics/ViewModels/ShowDetailViewModel.swift | 4 +--- simalytics/ViewModels/SimklWatchedBatch.swift | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/simalytics/ViewModels/AnimeDetailViewModel.swift b/simalytics/ViewModels/AnimeDetailViewModel.swift index 00810f8..622c830 100644 --- a/simalytics/ViewModels/AnimeDetailViewModel.swift +++ b/simalytics/ViewModels/AnimeDetailViewModel.swift @@ -78,9 +78,7 @@ extension AnimeDetailView { // for many anime at once. Thin wrapper around the shared helper so the // call site stays clean and TV/anime behaviour can't drift apart. static func getAnimeWatchlistBatch(_ simklIDs: [Int], _ accessToken: String) async -> SimklWatchedBatch { - await simklWatchedBatch( - simklIDs: simklIDs, type: "anime", accessToken: accessToken, decode: AnimeWatchlistModel.self - ) + await simklWatchedBatch(simklIDs: simklIDs, type: "anime", accessToken: accessToken) } static func getAnimeWatchlist(_ simkl_id: Int, _ accessToken: String) async -> AnimeWatchlistModel? { diff --git a/simalytics/ViewModels/ShowDetailViewModel.swift b/simalytics/ViewModels/ShowDetailViewModel.swift index d011d62..0278a8e 100644 --- a/simalytics/ViewModels/ShowDetailViewModel.swift +++ b/simalytics/ViewModels/ShowDetailViewModel.swift @@ -38,9 +38,7 @@ extension ShowDetailView { // for many shows at once. Thin wrapper around the shared helper so the // call site stays clean and TV/anime behaviour can't drift apart. static func getShowWatchlistBatch(_ simklIDs: [Int], _ accessToken: String) async -> SimklWatchedBatch { - await simklWatchedBatch( - simklIDs: simklIDs, type: "tv", accessToken: accessToken, decode: ShowWatchlistModel.self - ) + await simklWatchedBatch(simklIDs: simklIDs, type: "tv", accessToken: accessToken) } static func getShowWatchlist(_ simkl_id: Int, _ accessToken: String) async -> ShowWatchlistModel? { diff --git a/simalytics/ViewModels/SimklWatchedBatch.swift b/simalytics/ViewModels/SimklWatchedBatch.swift index fb38818..17969ef 100644 --- a/simalytics/ViewModels/SimklWatchedBatch.swift +++ b/simalytics/ViewModels/SimklWatchedBatch.swift @@ -9,7 +9,6 @@ // import Foundation -import Sentry // Result of a batched /sync/watched lookup. `hadFailures` lets callers // decide whether the data is complete enough to stamp a "fresh" cache. @@ -25,8 +24,7 @@ struct SimklWatchedBatch: Sendable { func simklWatchedBatch( simklIDs: [Int], type: String, - accessToken: String, - decode: T.Type + accessToken: String ) async -> SimklWatchedBatch { guard !simklIDs.isEmpty else { return SimklWatchedBatch(items: [], hadFailures: false) } let chunkSize = 100