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/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/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/ActorDetailViewModel.swift b/simalytics/ViewModels/ActorDetailViewModel.swift index 84fcdc9..8558fa3 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 { @@ -28,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) } @@ -50,11 +78,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 +91,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..622c830 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,16 @@ extension AnimeDetailView { } } + // Batched variant for callers (e.g. up-next sync) that need watched data + // 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) + } + static func getAnimeWatchlist(_ simkl_id: Int, _ accessToken: String) async -> 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 +112,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 +141,7 @@ extension AnimeDetailView { let formatter = ISO8601DateFormatter() let dateString = formatter.string(from: Date()) let body: [String: Any] = [ - "shows": [ + "anime": [ [ "title": title, "ids": [ @@ -228,7 +235,7 @@ extension AnimeWatchlistButton { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") let body: [String: Any] = [ - "shows": [ + "anime": [ [ "ids": [ "simkl": simkl_id @@ -248,7 +255,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..0278a8e 100644 --- a/simalytics/ViewModels/ShowDetailViewModel.swift +++ b/simalytics/ViewModels/ShowDetailViewModel.swift @@ -34,9 +34,16 @@ extension ShowDetailView { } } + // Batched variant for callers (e.g. up-next sync) that need watched data + // 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) + } + static func getShowWatchlist(_ simkl_id: Int, _ accessToken: String) async -> 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/SimklWatchedBatch.swift b/simalytics/ViewModels/SimklWatchedBatch.swift new file mode 100644 index 0000000..17969ef --- /dev/null +++ b/simalytics/ViewModels/SimklWatchedBatch.swift @@ -0,0 +1,64 @@ +// +// 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 + +// 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 +) 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 + // 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) @@ -1589,8 +1545,22 @@ 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). `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.items.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 +1643,16 @@ func processUpNextEpisodes( } } + let animeWatchedBatch = await AnimeDetailView.getAnimeWatchlistBatch( + sdAnimesIds.map { $0.simkl }, accessToken + ) + let animeWatchedByID = Dictionary( + animeWatchedBatch.items.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, @@ -1696,20 +1674,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 = @@ -1748,7 +1735,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) 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,