Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions simalytics/Models/API/Common/SharedTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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?
Expand Down
9 changes: 0 additions & 9 deletions simalytics/Models/API/External/ActorModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}
43 changes: 17 additions & 26 deletions simalytics/Models/API/Sync/AnimeModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions simalytics/Models/API/Sync/LastActivitiesModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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?
Expand All @@ -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?
Expand Down
15 changes: 7 additions & 8 deletions simalytics/Models/API/Sync/MoviesModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}

Expand All @@ -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
Expand Down
16 changes: 6 additions & 10 deletions simalytics/Models/API/Sync/TVModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion simalytics/Models/API/Watchlist/AnimeWatchlistModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import Foundation

struct AnimeWatchlistModel: Codable {
struct AnimeWatchlistModel: Codable, Sendable {
let list: String?
let last_watched_at: String?
let simkl: Int
Expand Down
2 changes: 1 addition & 1 deletion simalytics/Models/API/Watchlist/ShowWatchlistModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import Foundation

struct ShowWatchlistModel: Codable {
struct ShowWatchlistModel: Codable, Sendable {
let list: String?
let last_watched_at: String?
let simkl: Int
Expand Down
71 changes: 58 additions & 13 deletions simalytics/ViewModels/ActorDetailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand All @@ -50,36 +78,53 @@ 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),
]

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)
Comment thread
NickReisenauer marked this conversation as resolved.
}

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)
Expand Down
19 changes: 13 additions & 6 deletions simalytics/ViewModels/AnimeDetailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ extension AnimeDetailView {
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")

let body: [String: Any] = [
"shows": [
"anime": [
[
"title": title,
"ids": [
Expand Down Expand Up @@ -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<AnimeWatchlistModel> {
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"
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -134,7 +141,7 @@ extension AnimeDetailView {
let formatter = ISO8601DateFormatter()
let dateString = formatter.string(from: Date())
let body: [String: Any] = [
"shows": [
"anime": [
[
"title": title,
"ids": [
Expand Down Expand Up @@ -228,7 +235,7 @@ extension AnimeWatchlistButton {
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")

let body: [String: Any] = [
"shows": [
"anime": [
[
"ids": [
"simkl": simkl_id
Expand All @@ -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": [
Expand Down
Loading