From ca0de798b3ab5774f78ffcc65db593e3a655e707 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sun, 9 Feb 2025 07:14:15 -0500 Subject: [PATCH 01/38] Initial Swift 6 support, and improved response handling with async API --- Common/DateParser.swift | 30 +- .../Authentication/AuthenticationInfo.swift | 2 +- Common/Models/Authentication/DeviceCode.swift | 32 +- Common/Wrapper/Calendars.swift | 20 +- Common/Wrapper/Certifications.swift | 2 +- Common/Wrapper/Checkin.swift | 2 +- Common/Wrapper/Comments.swift | 22 +- Common/Wrapper/CompletionHandlers.swift | 230 +++---- Common/Wrapper/Enums.swift | 4 +- Common/Wrapper/Episodes.swift | 16 +- Common/Wrapper/Genres.swift | 2 +- Common/Wrapper/Languages.swift | 2 +- Common/Wrapper/Lists.swift | 4 +- Common/Wrapper/Movies.swift | 6 +- Common/Wrapper/People.swift | 6 +- Common/Wrapper/Recommendations.swift | 4 +- Common/Wrapper/Route.swift | 2 +- Common/Wrapper/Search.swift | 4 +- Common/Wrapper/Seasons.swift | 16 +- Common/Wrapper/SharedFunctions.swift | 32 +- Common/Wrapper/Shows.swift | 12 +- Common/Wrapper/Sync.swift | 18 +- Common/Wrapper/TraktManager.swift | 611 +++++++----------- Common/Wrapper/URLSessionProtocol.swift | 2 +- Common/Wrapper/Users.swift | 58 +- Package.swift | 12 +- Tests/TraktKitTests/HelperFunctions.swift | 8 +- Tests/TraktKitTests/TraktManagerTests.swift | 42 ++ 28 files changed, 565 insertions(+), 636 deletions(-) create mode 100644 Tests/TraktKitTests/TraktManagerTests.swift diff --git a/Common/DateParser.swift b/Common/DateParser.swift index 8ee76a5..d1000c4 100644 --- a/Common/DateParser.swift +++ b/Common/DateParser.swift @@ -23,32 +23,24 @@ internal extension Date { enum DateParserError: Error { case failedToParseDateFromString(String) - case typeUnhandled(Any?) } // MARK: - Class - static func dateFromString(_ string: Any?) throws -> Date { - if let dateString = string as? String { - - let count = dateString.count - if count <= 10 { - ISO8601DateFormatter.dateFormat = "yyyy-MM-dd" - } else if count == 23 { - ISO8601DateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss ZZZ" - } else { - ISO8601DateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" - } + static func dateFromString(_ dateString: String) throws(DateParserError) -> Date { + let count = dateString.count + if count <= 10 { + ISO8601DateFormatter.dateFormat = "yyyy-MM-dd" + } else if count == 23 { + ISO8601DateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss ZZZ" + } else { + ISO8601DateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + } - if let date = ISO8601DateFormatter.date(from: dateString) { - return date - } else { - throw DateParserError.failedToParseDateFromString("String to parse: \(dateString), date format: \(String(describing: ISO8601DateFormatter.dateFormat))") - } - } else if let date = string as? Date { + if let date = ISO8601DateFormatter.date(from: dateString) { return date } else { - throw DateParserError.typeUnhandled(string) + throw .failedToParseDateFromString("String to parse: \(dateString), date format: \(String(describing: ISO8601DateFormatter.dateFormat))") } } diff --git a/Common/Models/Authentication/AuthenticationInfo.swift b/Common/Models/Authentication/AuthenticationInfo.swift index af30c53..7e4dfcf 100644 --- a/Common/Models/Authentication/AuthenticationInfo.swift +++ b/Common/Models/Authentication/AuthenticationInfo.swift @@ -8,7 +8,7 @@ import Foundation -public struct AuthenticationInfo: Decodable, Hashable { +public struct AuthenticationInfo: Codable, Hashable { public let accessToken: String public let tokenType: String public let expiresIn: TimeInterval diff --git a/Common/Models/Authentication/DeviceCode.swift b/Common/Models/Authentication/DeviceCode.swift index fcee36b..02b4f07 100644 --- a/Common/Models/Authentication/DeviceCode.swift +++ b/Common/Models/Authentication/DeviceCode.swift @@ -13,28 +13,26 @@ public struct DeviceCode: Codable { public let deviceCode: String public let userCode: String public let verificationURL: String - public let expiresIn: Int - public let interval: Int - - #if canImport(UIKit) - #if canImport(CoreImage) - public func getQRCode() -> UIImage? { - let data = self.verificationURL.data(using: String.Encoding.ascii) + public let expiresIn: TimeInterval + public let interval: TimeInterval - if let filter = CIFilter(name: "CIQRCodeGenerator") { - filter.setValue(data, forKey: "inputMessage") - let transform = CGAffineTransform(scaleX: 3, y: 3) +#if canImport(UIKit) && canImport(CoreImage) + public func getQRCode(scale: CGFloat = 3) -> UIImage? { + guard + let data = "\(verificationURL)/\(userCode)".data(using: .ascii), + let filter = CIFilter(name: "CIQRCodeGenerator") + else { return nil } - if let output = filter.outputImage?.transformed(by: transform) { - return UIImage(ciImage: output) - } + filter.setValue(data, forKey: "inputMessage") + + guard let output = filter.outputImage?.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) else { + return nil } - return nil + return UIImage(ciImage: output) } - #endif - #endif - +#endif + enum CodingKeys: String, CodingKey { case deviceCode = "device_code" case userCode = "user_code" diff --git a/Common/Wrapper/Calendars.swift b/Common/Wrapper/Calendars.swift index b5d4f2f..16d7324 100644 --- a/Common/Wrapper/Calendars.swift +++ b/Common/Wrapper/Calendars.swift @@ -20,7 +20,7 @@ extension TraktManager { */ @discardableResult public func myShows(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "calendars/my/shows/\(dateString)/\(days)", + guard let request = try? mutableRequest(forPath: "calendars/my/shows/\(dateString)/\(days)", withQuery: [:], isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -38,7 +38,7 @@ extension TraktManager { */ @discardableResult public func myNewShows(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "calendars/my/shows/new/\(dateString)/\(days)", + guard let request = try? mutableRequest(forPath: "calendars/my/shows/new/\(dateString)/\(days)", withQuery: [:], isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -56,7 +56,7 @@ extension TraktManager { */ @discardableResult public func mySeasonPremieres(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "calendars/my/shows/premieres/\(dateString)/\(days)", + guard let request = try? mutableRequest(forPath: "calendars/my/shows/premieres/\(dateString)/\(days)", withQuery: [:], isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -74,7 +74,7 @@ extension TraktManager { */ @discardableResult public func myMovies(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "calendars/my/movies/\(dateString)/\(days)", + guard let request = try? mutableRequest(forPath: "calendars/my/movies/\(dateString)/\(days)", withQuery: [:], isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -91,7 +91,7 @@ extension TraktManager { */ @discardableResult public func myDVDReleases(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "calendars/my/dvd/\(dateString)/\(days)", + guard let request = try? mutableRequest(forPath: "calendars/my/dvd/\(dateString)/\(days)", withQuery: [:], isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -107,7 +107,7 @@ extension TraktManager { */ @discardableResult public func allShows(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "calendars/all/shows/\(dateString)/\(days)", + guard let request = try? mutableRequest(forPath: "calendars/all/shows/\(dateString)/\(days)", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -123,7 +123,7 @@ extension TraktManager { */ @discardableResult public func allNewShows(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "calendars/all/shows/new/\(dateString)/\(days)", + guard let request = try? mutableRequest(forPath: "calendars/all/shows/new/\(dateString)/\(days)", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -139,7 +139,7 @@ extension TraktManager { */ @discardableResult public func allSeasonPremieres(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "calendars/all/shows/premieres/\(dateString)/\(days)", + guard let request = try? mutableRequest(forPath: "calendars/all/shows/premieres/\(dateString)/\(days)", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -165,7 +165,7 @@ extension TraktManager { } } - guard let request = mutableRequest(forPath: "calendars/all/movies/\(dateString)/\(days)", + guard let request = try? mutableRequest(forPath: "calendars/all/movies/\(dateString)/\(days)", withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -177,7 +177,7 @@ extension TraktManager { */ @discardableResult public func allDVD(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "calendars/all/dvd/\(dateString)/\(days)", + guard let request = try? mutableRequest(forPath: "calendars/all/dvd/\(dateString)/\(days)", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } diff --git a/Common/Wrapper/Certifications.swift b/Common/Wrapper/Certifications.swift index cbde48a..9138606 100644 --- a/Common/Wrapper/Certifications.swift +++ b/Common/Wrapper/Certifications.swift @@ -17,7 +17,7 @@ extension TraktManager { */ @discardableResult public func getCertifications(completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "certifications", + guard let request = try? mutableRequest(forPath: "certifications", withQuery: [:], isAuthorized: true, withHTTPMethod: .GET) else { return nil } diff --git a/Common/Wrapper/Checkin.swift b/Common/Wrapper/Checkin.swift index 2185827..c984aae 100644 --- a/Common/Wrapper/Checkin.swift +++ b/Common/Wrapper/Checkin.swift @@ -25,7 +25,7 @@ extension TraktManager { */ @discardableResult public func deleteActiveCheckins(completionHandler: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "checkin", + guard let request = try? mutableRequest(forPath: "checkin", withQuery: [:], isAuthorized: true, withHTTPMethod: .DELETE) else { return nil } diff --git a/Common/Wrapper/Comments.swift b/Common/Wrapper/Comments.swift index b40de44..f6085aa 100644 --- a/Common/Wrapper/Comments.swift +++ b/Common/Wrapper/Comments.swift @@ -29,7 +29,7 @@ extension TraktManager { */ @discardableResult public func getComment(commentID id: T, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "comments/\(id)", + guard let request = try? mutableRequest(forPath: "comments/\(id)", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -45,7 +45,7 @@ extension TraktManager { @discardableResult public func updateComment(commentID id: T, newComment comment: String, isSpoiler spoiler: Bool? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { let body = TraktCommentBody(comment: comment, spoiler: spoiler) - guard var request = mutableRequest(forPath: "comments/\(id)", + guard var request = try? mutableRequest(forPath: "comments/\(id)", withQuery: [:], isAuthorized: true, withHTTPMethod: .PUT) else { return nil } @@ -62,7 +62,7 @@ extension TraktManager { @discardableResult public func deleteComment(commentID id: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { guard - let request = mutableRequest(forPath: "comments/\(id)", + let request = try? mutableRequest(forPath: "comments/\(id)", withQuery: [:], isAuthorized: true, withHTTPMethod: .DELETE) else { return nil } @@ -78,7 +78,7 @@ extension TraktManager { */ @discardableResult public func getReplies(commentID id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "comments/\(id)/replies", + guard let request = try? mutableRequest(forPath: "comments/\(id)/replies", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -107,7 +107,7 @@ extension TraktManager { */ @discardableResult public func getAttachedMediaItem(commentID id: T, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "comments/\(id)/item", + guard let request = try? mutableRequest(forPath: "comments/\(id)/item", withQuery: [:], isAuthorized: true, withHTTPMethod: .POST) else { return nil } @@ -124,7 +124,7 @@ extension TraktManager { */ @discardableResult public func getUsersWhoLikedComment(commentID id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "comments/\(id)/likes", + guard let request = try? mutableRequest(forPath: "comments/\(id)/likes", withQuery: [:], isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -141,7 +141,7 @@ extension TraktManager { */ @discardableResult public func likeComment(commentID id: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "comments/\(id)/like", + guard let request = try? mutableRequest(forPath: "comments/\(id)/like", withQuery: [:], isAuthorized: false, withHTTPMethod: .POST) else { return nil } @@ -156,7 +156,7 @@ extension TraktManager { */ @discardableResult public func removeLikeOnComment(commentID id: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "comments/\(id)/like", + guard let request = try? mutableRequest(forPath: "comments/\(id)/like", withQuery: [:], isAuthorized: false, withHTTPMethod: .DELETE) else { return nil } @@ -174,7 +174,7 @@ extension TraktManager { */ @discardableResult public func getTrendingComments(commentType: CommentType, mediaType: Type2, includeReplies: Bool, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "comments/trending/\(commentType.rawValue)/\(mediaType.rawValue)", + guard let request = try? mutableRequest(forPath: "comments/trending/\(commentType.rawValue)/\(mediaType.rawValue)", withQuery: ["include_replies": "\(includeReplies)"], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -192,7 +192,7 @@ extension TraktManager { */ @discardableResult public func getRecentComments(commentType: CommentType, mediaType: Type2, includeReplies: Bool, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "comments/recent/\(commentType.rawValue)/\(mediaType.rawValue)", + guard let request = try? mutableRequest(forPath: "comments/recent/\(commentType.rawValue)/\(mediaType.rawValue)", withQuery: ["include_replies": "\(includeReplies)"], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -210,7 +210,7 @@ extension TraktManager { */ @discardableResult public func getRecentlyUpdatedComments(commentType: CommentType, mediaType: Type2, includeReplies: Bool, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "comments/updates/\(commentType.rawValue)/\(mediaType.rawValue)", + guard let request = try? mutableRequest(forPath: "comments/updates/\(commentType.rawValue)/\(mediaType.rawValue)", withQuery: ["include_replies": "\(includeReplies)"], isAuthorized: false, withHTTPMethod: .GET) else { return nil } diff --git a/Common/Wrapper/CompletionHandlers.swift b/Common/Wrapper/CompletionHandlers.swift index 00e7138..7ec0fcb 100644 --- a/Common/Wrapper/CompletionHandlers.swift +++ b/Common/Wrapper/CompletionHandlers.swift @@ -57,38 +57,41 @@ extension TraktManager { case error(error: Error?) } - public enum TraktKitError: Error { - case couldNotParseData - case handlingRetry - } - public enum TraktError: Error { - /// Bad Request - request couldn't be parsed + /// Bad Request (400) - request couldn't be parsed case badRequest - /// Oauth must be provided + /// Oauth must be provided (401) case unauthorized - /// Forbidden - invalid API key or unapproved app + /// Forbidden - invalid API key or unapproved app (403) case forbidden - /// Not Found - method exists, but no record found + /// Not Found - method exists, but no record found (404) case noRecordFound - /// Method Not Found - method doesn't exist + /// Method Not Found - method doesn't exist (405) case noMethodFound - /// Conflict - resource already created + /// Conflict - resource already created (409) case resourceAlreadyCreated - /// Account Limit Exceeded - list count, item count, etc + /// Precondition Failed - use application/json content type (412) + case preconditionFailed + /// Account Limit Exceeded - list count, item count, etc (420) case accountLimitExceeded - /// Locked User Account - have the user contact support + /// Unprocessable Entity - validation errors (422) + case unprocessableEntity + /// Locked User Account - have the user contact support (423) case accountLocked - /// VIP Only - user must upgrade to VIP + /// VIP Only - user must upgrade to VIP (426) case vipOnly - /// Rate Limit Exceeded + /// Rate Limit Exceede (429) + case retry(after: TimeInterval) + /// Rate Limit Exceeded, retry interval not available (429) case rateLimitExceeded(HTTPURLResponse) - /// Service Unavailable - server overloaded (try again in 30s) + /// Server Error - please open a support ticket (500) + case serverError + /// Service Unavailable - server overloaded (try again in 30s) (502 / 503 / 504) case serverOverloaded - /// Service Unavailable - Cloudflare error + /// Service Unavailable - Cloudflare error (520 / 521 / 522) case cloudflareError /// Full url response - case unhandled(HTTPURLResponse) + case unhandled(URLResponse) } // MARK: - Completion handlers @@ -166,79 +169,90 @@ extension TraktManager { // MARK: - Error handling - private func handleResponse(response: URLResponse?, retry: @escaping (() -> Void)) throws { - guard let httpResponse = response as? HTTPURLResponse else { throw TraktKitError.couldNotParseData } - + private func handleResponse(response: URLResponse?) throws(TraktError) { + guard let response else { return } + guard let httpResponse = response as? HTTPURLResponse else { throw .unhandled(response) + } + guard 200...299 ~= httpResponse.statusCode else { switch httpResponse.statusCode { - case StatusCodes.BadRequest: - throw TraktError.badRequest - case StatusCodes.Unauthorized: - throw TraktError.unauthorized - case StatusCodes.Forbidden: - throw TraktError.forbidden - case StatusCodes.NotFound: - throw TraktError.noRecordFound - case StatusCodes.MethodNotFound: - throw TraktError.noMethodFound - case StatusCodes.Conflict: - throw TraktError.resourceAlreadyCreated - case StatusCodes.AccountLimitExceeded: - throw TraktError.accountLimitExceeded - case StatusCodes.acountLocked: - throw TraktError.accountLocked - case StatusCodes.vipOnly: - throw TraktError.vipOnly - case StatusCodes.RateLimitExceeded: - if let retryAfter = httpResponse.allHeaderFields["retry-after"] as? String, - let retryInterval = TimeInterval(retryAfter) { - DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval) { - retry() - } - /// To ensure completionHandler isn't called when retrying. - throw TraktKitError.handlingRetry + case 400: throw .badRequest + case 401: throw .unauthorized + case 403: throw .forbidden + case 404: throw .noRecordFound + case 405: throw .noMethodFound + case 409: throw .resourceAlreadyCreated + case 412: throw .preconditionFailed + case 420: throw .accountLimitExceeded + case 422: throw .unprocessableEntity + case 423: throw .accountLocked + case 426: throw .vipOnly + case 429: + let rawRetryAfter = httpResponse.allHeaderFields["retry-after"] + if let retryAfterString = rawRetryAfter as? String, + let retryAfter = TimeInterval(retryAfterString) { + throw .retry(after: retryAfter) + } else if let retryAfter = rawRetryAfter as? TimeInterval { + throw .retry(after: retryAfter) } else { - throw TraktError.rateLimitExceeded(httpResponse) + throw .rateLimitExceeded(httpResponse) } - - case 503, 504: - throw TraktError.serverOverloaded - case 500...600: - throw TraktError.cloudflareError + case 500: throw .serverError + // Try again in 30 seconds throw + case 502, 503, 504: throw .serverOverloaded + case 500...600: throw .cloudflareError default: - throw TraktError.unhandled(httpResponse) + throw .unhandled(httpResponse) } } } - + + private func handle(response: URLResponse?) async throws(TraktError) { + do { + try await withCheckedThrowingContinuation { continuation in + do { + try handleResponse(response: response) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } catch let error as TraktError { + throw error + } catch { + fatalError("`handleResponse` threw random error") + } + } + // MARK: - Perform Requests func perform(request: URLRequest) async throws -> T { - let (data, _) = try await session.data(for: request) + // TODO: Call `handleResponse` for error handling and retries. + let (data, response) = try await session.data(for: request) + try await handle(response: response) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom(customDateDecodingStrategy) - let object = try decoder.decode(T.self, from: data) - return object + return try decoder.decode(T.self, from: data) } /// Data func performRequest(request: URLRequest, completion: @escaping DataResultCompletionHandler) -> URLSessionDataTaskProtocol? { let datatask = session._dataTask(with: request) { [weak self] data, response, error in - guard let self = self else { return } - guard error == nil else { + guard let self else { return } + if let error { completion(.error(error: error)) return } // Check response - do { - try self.handleResponse(response: response, retry: { - _ = self.performRequest(request: request, completion: completion) - }) + do throws(TraktError) { + try self.handleResponse(response: response) } catch { switch error { - case TraktKitError.handlingRetry: - break + case .retry(let after): + DispatchQueue.global().asyncAfter(deadline: .now() + after) { [weak self, completion] in + _ = self?.performRequest(request: request, completion: completion) + } default: completion(.error(error: error)) } @@ -259,21 +273,21 @@ extension TraktManager { /// Success / Failure func performRequest(request: URLRequest, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { let datatask = session._dataTask(with: request) { [weak self] data, response, error in - guard let self = self else { return } + guard let self else { return } guard error == nil else { completion(.fail) return } // Check response - do { - try self.handleResponse(response: response, retry: { - _ = self.performRequest(request: request, completion: completion) - }) + do throws(TraktError) { + try self.handleResponse(response: response) } catch { switch error { - case TraktKitError.handlingRetry: - break + case .retry(let after): + DispatchQueue.global().asyncAfter(deadline: .now() + after) { [weak self, completion] in + _ = self?.performRequest(request: request, completion: completion) + } default: completion(.fail) } @@ -289,8 +303,8 @@ extension TraktManager { /// Checkin func performRequest(request: URLRequest, completion: @escaping checkinCompletionHandler) -> URLSessionDataTaskProtocol? { let datatask = session._dataTask(with: request) { [weak self] data, response, error in - guard let self = self else { return } - guard error == nil else { + guard let self else { return } + if let error { completion(.error(error: error)) return } @@ -316,14 +330,14 @@ extension TraktManager { } // Check response - do { - try self.handleResponse(response: response, retry: { - _ = self.performRequest(request: request, completion: completion) - }) + do throws(TraktError) { + try self.handleResponse(response: response) } catch { switch error { - case TraktKitError.handlingRetry: - break + case .retry(let after): + DispatchQueue.global().asyncAfter(deadline: .now() + after) { [weak self, completion] in + _ = self?.performRequest(request: request, completion: completion) + } default: completion(.error(error: error)) } @@ -361,21 +375,21 @@ extension TraktManager { /// Array of TraktProtocol objects func performRequest(request: URLRequest, completion: @escaping ((_ result: ObjectsResultType) -> Void)) -> URLSessionDataTaskProtocol? { let dataTask = session._dataTask(with: request) { [weak self] data, response, error in - guard let self = self else { return } - guard error == nil else { + guard let self else { return } + if let error { completion(.error(error: error)) return } - + // Check response - do { - try self.handleResponse(response: response, retry: { - _ = self.performRequest(request: request, completion: completion) - }) + do throws(TraktError) { + try self.handleResponse(response: response) } catch { switch error { - case TraktKitError.handlingRetry: - break + case .retry(let after): + DispatchQueue.global().asyncAfter(deadline: .now() + after) { [weak self, completion] in + _ = self?.performRequest(request: request, completion: completion) + } default: completion(.error(error: error)) } @@ -405,23 +419,23 @@ extension TraktManager { /// Array of ObjectsResultTypePagination objects func performRequest(request: URLRequest, completion: @escaping ((_ result: ObjectsResultTypePagination) -> Void)) -> URLSessionDataTaskProtocol? { let dataTask = session._dataTask(with: request) { [weak self] data, response, error in - guard let self = self else { return } - guard error == nil else { + guard let self else { return } + if let error { completion(.error(error: error)) return } - + guard let httpResponse = response as? HTTPURLResponse else { return completion(.error(error: nil)) } // Check response - do { - try self.handleResponse(response: response, retry: { - _ = self.performRequest(request: request, completion: completion) - }) + do throws(TraktError) { + try self.handleResponse(response: response) } catch { switch error { - case TraktKitError.handlingRetry: - break + case .retry(let after): + DispatchQueue.global().asyncAfter(deadline: .now() + after) { [weak self, completion] in + _ = self?.performRequest(request: request, completion: completion) + } default: completion(.error(error: error)) } @@ -463,23 +477,23 @@ extension TraktManager { // Watching func performRequest(request: URLRequest, completion: @escaping WatchingCompletion) -> URLSessionDataTaskProtocol? { let dataTask = session._dataTask(with: request) { [weak self] data, response, error in - guard let self = self else { return } - guard error == nil else { + guard let self else { return } + if let error { completion(.error(error: error)) return } - + guard let httpResponse = response as? HTTPURLResponse else { return completion(.error(error: nil)) } // Check response - do { - try self.handleResponse(response: response, retry: { - _ = self.performRequest(request: request, completion: completion) - }) + do throws(TraktError) { + try self.handleResponse(response: response) } catch { switch error { - case TraktKitError.handlingRetry: - break + case .retry(let after): + DispatchQueue.global().asyncAfter(deadline: .now() + after) { [weak self, completion] in + _ = self?.performRequest(request: request, completion: completion) + } default: completion(.error(error: error)) } diff --git a/Common/Wrapper/Enums.swift b/Common/Wrapper/Enums.swift index 880c298..5850a59 100644 --- a/Common/Wrapper/Enums.swift +++ b/Common/Wrapper/Enums.swift @@ -86,14 +86,14 @@ public struct StatusCodes { } /// What to search for -public enum SearchType: String { +public enum SearchType: String, Sendable { case movie case show case episode case person case list - public struct Field { + public struct Field: Sendable { public let title: String } public struct Fields { diff --git a/Common/Wrapper/Episodes.swift b/Common/Wrapper/Episodes.swift index 745aa98..a01f986 100644 --- a/Common/Wrapper/Episodes.swift +++ b/Common/Wrapper/Episodes.swift @@ -19,7 +19,7 @@ extension TraktManager { */ @discardableResult public func getEpisodeSummary(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)", + guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)", withQuery: ["extended": extended.queryString()], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -44,7 +44,7 @@ extension TraktManager { path += "/\(language)" } - guard let request = mutableRequest(forPath: path, + guard let request = try? mutableRequest(forPath: path, withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -71,7 +71,7 @@ extension TraktManager { } } - guard let request = mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)/comments", + guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)/comments", withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -106,7 +106,7 @@ extension TraktManager { } } - guard let request = mutableRequest(forPath: path, + guard let request = try? mutableRequest(forPath: path, withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -121,7 +121,7 @@ extension TraktManager { */ @discardableResult public func getEpisodeRatings(showID id: T, seasonNumber: NSNumber, episodeNumber: NSNumber, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "shows/\(id)/seasons/\(seasonNumber)/episodes/\(episodeNumber)/ratings", + guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(seasonNumber)/episodes/\(episodeNumber)/ratings", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -136,7 +136,7 @@ extension TraktManager { */ @discardableResult public func getEpisodeStatistics(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, completion: @escaping statsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)/stats", + guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)/stats", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -151,7 +151,7 @@ extension TraktManager { */ @discardableResult public func getUsersWatchingEpisode(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)/watching", + guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)/watching", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -174,7 +174,7 @@ extension TraktManager { */ @discardableResult public func getPeopleInEpisode(showID id: T, season: NSNumber, episode: NSNumber, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)/people", + guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)/people", withQuery: ["extended": extended.queryString()], isAuthorized: false, withHTTPMethod: .GET) else { return nil } diff --git a/Common/Wrapper/Genres.swift b/Common/Wrapper/Genres.swift index 2cde3bf..4d2b6ba 100644 --- a/Common/Wrapper/Genres.swift +++ b/Common/Wrapper/Genres.swift @@ -15,7 +15,7 @@ extension TraktManager { */ @discardableResult public func listGenres(type: WatchedType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "genres/\(type)", + guard let request = try? mutableRequest(forPath: "genres/\(type)", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { diff --git a/Common/Wrapper/Languages.swift b/Common/Wrapper/Languages.swift index 0a3424e..03b84d1 100644 --- a/Common/Wrapper/Languages.swift +++ b/Common/Wrapper/Languages.swift @@ -15,7 +15,7 @@ extension TraktManager { */ @discardableResult public func listLanguages(type: WatchedType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "languages/\(type)", + guard let request = try? mutableRequest(forPath: "languages/\(type)", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { diff --git a/Common/Wrapper/Lists.swift b/Common/Wrapper/Lists.swift index 0d3a1de..e3d88d5 100644 --- a/Common/Wrapper/Lists.swift +++ b/Common/Wrapper/Lists.swift @@ -18,7 +18,7 @@ extension TraktManager { @discardableResult public func getTrendingLists(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { guard - let request = mutableRequest(forPath: "lists/trending", + let request = try? mutableRequest(forPath: "lists/trending", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { @@ -37,7 +37,7 @@ extension TraktManager { @discardableResult public func getPopularLists(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { guard - let request = mutableRequest(forPath: "lists/popular", + let request = try? mutableRequest(forPath: "lists/popular", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { diff --git a/Common/Wrapper/Movies.swift b/Common/Wrapper/Movies.swift index 83e1881..28b4b82 100644 --- a/Common/Wrapper/Movies.swift +++ b/Common/Wrapper/Movies.swift @@ -89,7 +89,7 @@ extension TraktManager { */ @discardableResult public func getWeekendBoxOffice(extended: [ExtendedType] = [.Min], completion: @escaping BoxOfficeMoviesCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "movies/boxoffice", + guard let request = try? mutableRequest(forPath: "movies/boxoffice", withQuery: ["extended": extended.queryString()], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -146,7 +146,7 @@ extension TraktManager { path += "/\(country)" } - guard let request = mutableRequest(forPath: path, + guard let request = try? mutableRequest(forPath: path, withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -207,7 +207,7 @@ extension TraktManager { } } - guard let request = mutableRequest(forPath: path, + guard let request = try? mutableRequest(forPath: path, withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } diff --git a/Common/Wrapper/People.swift b/Common/Wrapper/People.swift index 03c7079..89144f9 100644 --- a/Common/Wrapper/People.swift +++ b/Common/Wrapper/People.swift @@ -19,7 +19,7 @@ extension TraktManager { */ @discardableResult public func getPersonDetails(personID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "people/\(id)", + guard let request = try? mutableRequest(forPath: "people/\(id)", withQuery: ["extended": extended.queryString()], isAuthorized: false, withHTTPMethod: .GET) else { @@ -76,7 +76,7 @@ extension TraktManager { } } - guard let request = mutableRequest(forPath: path, + guard let request = try? mutableRequest(forPath: path, withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -88,7 +88,7 @@ extension TraktManager { @discardableResult private func getCredits(type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "people/\(id)/\(type)", + guard let request = try? mutableRequest(forPath: "people/\(id)/\(type)", withQuery: ["extended": extended.queryString()], isAuthorized: false, withHTTPMethod: .GET) else { diff --git a/Common/Wrapper/Recommendations.swift b/Common/Wrapper/Recommendations.swift index a46848e..9c80147 100644 --- a/Common/Wrapper/Recommendations.swift +++ b/Common/Wrapper/Recommendations.swift @@ -57,7 +57,7 @@ extension TraktManager { @discardableResult private func getRecommendations(_ type: WatchedType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "recommendations/\(type)", + guard let request = try? mutableRequest(forPath: "recommendations/\(type)", withQuery: [:], isAuthorized: true, withHTTPMethod: .GET) else { @@ -71,7 +71,7 @@ extension TraktManager { @discardableResult private func hideRecommendation(type: WatchedType, id: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "recommendations/\(type)/\(id)", + guard let request = try? mutableRequest(forPath: "recommendations/\(type)/\(id)", withQuery: [:], isAuthorized: true, withHTTPMethod: .DELETE) else { diff --git a/Common/Wrapper/Route.swift b/Common/Wrapper/Route.swift index 6acfebb..11970ef 100644 --- a/Common/Wrapper/Route.swift +++ b/Common/Wrapper/Route.swift @@ -54,7 +54,7 @@ public class Route { } } - return traktManager.mutableRequest(forPath: path, + return try! traktManager.mutableRequest(forPath: path, withQuery: query, isAuthorized: requiresAuthentication, withHTTPMethod: method)! diff --git a/Common/Wrapper/Search.swift b/Common/Wrapper/Search.swift index c5b08ce..3bb7fdd 100644 --- a/Common/Wrapper/Search.swift +++ b/Common/Wrapper/Search.swift @@ -50,7 +50,7 @@ extension TraktManager { } // - guard let request = mutableRequest( + guard let request = try? mutableRequest( forPath: "search/\(typesString)", withQuery: query, isAuthorized: false, @@ -84,7 +84,7 @@ extension TraktManager { } } - guard let request = mutableRequest(forPath: "search/\(id.name)/\(id.id)", + guard let request = try? mutableRequest(forPath: "search/\(id.name)/\(id.id)", withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } diff --git a/Common/Wrapper/Seasons.swift b/Common/Wrapper/Seasons.swift index c7ec285..4668f83 100644 --- a/Common/Wrapper/Seasons.swift +++ b/Common/Wrapper/Seasons.swift @@ -22,7 +22,7 @@ extension TraktManager { */ @discardableResult public func getSeasons(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping SeasonsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "shows/\(id)/seasons", + guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons", withQuery: ["extended": extended.queryString()], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -45,7 +45,7 @@ extension TraktManager { var query = ["extended": extended.queryString()] query["translations"] = language - guard let request = mutableRequest(forPath: "shows/\(id)/seasons/\(season)", + guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)", withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -71,7 +71,7 @@ extension TraktManager { } } - guard let request = mutableRequest(forPath: "shows/\(id)/seasons/\(season)/comments", + guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/comments", withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -106,7 +106,7 @@ extension TraktManager { } } - guard let request = mutableRequest(forPath: path, + guard let request = try? mutableRequest(forPath: path, withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -121,7 +121,7 @@ extension TraktManager { */ @discardableResult public func getSeasonRatings(showID id: T, season: NSNumber, completion: @escaping RatingDistributionCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "shows/\(id)/seasons/\(season)/ratings", + guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/ratings", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -136,7 +136,7 @@ extension TraktManager { */ @discardableResult public func getSeasonStatistics(showID id: T, season: NSNumber, completion: @escaping statsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "shows/\(id)/seasons/\(season)/stats", + guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/stats", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -151,7 +151,7 @@ extension TraktManager { */ @discardableResult public func getUsersWatchingSeasons(showID id: T, season: NSNumber, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "shows/\(id)/seasons/\(season)/watching", + guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/watching", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -173,7 +173,7 @@ extension TraktManager { */ @discardableResult public func getPeopleInSeason(showID id: T, season: NSNumber, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "shows/\(id)/seasons/\(season)/people", + guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/people", withQuery: ["extended": extended.queryString()], isAuthorized: false, withHTTPMethod: .GET) else { return nil } diff --git a/Common/Wrapper/SharedFunctions.swift b/Common/Wrapper/SharedFunctions.swift index bd8dcc5..874715d 100644 --- a/Common/Wrapper/SharedFunctions.swift +++ b/Common/Wrapper/SharedFunctions.swift @@ -29,7 +29,7 @@ internal extension TraktManager { } } - guard let request = mutableRequest(forPath: "\(type)/trending", + guard let request = try? mutableRequest(forPath: "\(type)/trending", withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -56,7 +56,7 @@ internal extension TraktManager { } } - guard let request = mutableRequest(forPath: "\(type)/popular", + guard let request = try? mutableRequest(forPath: "\(type)/popular", withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -77,7 +77,7 @@ internal extension TraktManager { } } - guard let request = mutableRequest(forPath: "\(type)/played/\(period.rawValue)", + guard let request = try? mutableRequest(forPath: "\(type)/played/\(period.rawValue)", withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -98,7 +98,7 @@ internal extension TraktManager { } } - guard let request = mutableRequest(forPath: "\(type)/watched/\(period.rawValue)", + guard let request = try? mutableRequest(forPath: "\(type)/watched/\(period.rawValue)", withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -119,7 +119,7 @@ internal extension TraktManager { } } - guard let request = mutableRequest(forPath: "\(type)/collected/\(period.rawValue)", + guard let request = try? mutableRequest(forPath: "\(type)/collected/\(period.rawValue)", withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -140,7 +140,7 @@ internal extension TraktManager { } } - guard let request = mutableRequest(forPath: "\(type)/anticipated", + guard let request = try? mutableRequest(forPath: "\(type)/anticipated", withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -166,7 +166,7 @@ internal extension TraktManager { path.append(startDateString) } - guard let request = mutableRequest(forPath: path, + guard let request = try? mutableRequest(forPath: path, withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -177,7 +177,7 @@ internal extension TraktManager { // MARK: - Summary func getSummary(_ type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "\(type)/\(id)", + guard let request = try? mutableRequest(forPath: "\(type)/\(id)", withQuery: ["extended": extended.queryString()], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -188,7 +188,7 @@ internal extension TraktManager { // MARK: - Aliases func getAliases(_ type: WatchedType, id: T, completion: @escaping AliasCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "\(type)/\(id)/aliases", + guard let request = try? mutableRequest(forPath: "\(type)/\(id)/aliases", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -204,7 +204,7 @@ internal extension TraktManager { path += "/\(language)" } - guard let request = mutableRequest(forPath: path, + guard let request = try? mutableRequest(forPath: path, withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -224,7 +224,7 @@ internal extension TraktManager { } } - guard let request = mutableRequest(forPath: "\(type)/\(id)/comments", + guard let request = try? mutableRequest(forPath: "\(type)/\(id)/comments", withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -235,7 +235,7 @@ internal extension TraktManager { // MARK: - People func getPeople(_ type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "\(type)/\(id)/people", + guard let request = try? mutableRequest(forPath: "\(type)/\(id)/people", withQuery: ["extended": extended.queryString()], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -246,7 +246,7 @@ internal extension TraktManager { // MARK: - Ratings func getRatings(_ type: WatchedType, id: T, completion: @escaping RatingDistributionCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "\(type)/\(id)/ratings", + guard let request = try? mutableRequest(forPath: "\(type)/\(id)/ratings", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -257,7 +257,7 @@ internal extension TraktManager { // MARK: - Related func getRelated(_ type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ((_ result: ObjectsResultType) -> Void)) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "\(type)/\(id)/related", + guard let request = try? mutableRequest(forPath: "\(type)/\(id)/related", withQuery: ["extended": extended.queryString()], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -268,7 +268,7 @@ internal extension TraktManager { // MARK: - Stats func getStatistics(_ type: WatchedType, id: T, completion: @escaping statsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "\(type)/\(id)/stats", + guard let request = try? mutableRequest(forPath: "\(type)/\(id)/stats", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -279,7 +279,7 @@ internal extension TraktManager { // MARK: - Watching func getUsersWatching(_ type: WatchedType, id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "\(type)/\(id)/watching", + guard let request = try? mutableRequest(forPath: "\(type)/\(id)/watching", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } diff --git a/Common/Wrapper/Shows.swift b/Common/Wrapper/Shows.swift index 6f6b7de..53c70d3 100644 --- a/Common/Wrapper/Shows.swift +++ b/Common/Wrapper/Shows.swift @@ -111,7 +111,7 @@ extension TraktManager { } } let path = "shows/updates/id/\(startDate.dateString(withFormat: "yyyy-MM-dd'T'HH:mm:ss").addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? startDate.dateString(withFormat: "yyyy-MM-dd"))" - guard let request = mutableRequest(forPath: path, + guard let request = try? mutableRequest(forPath: path, withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -195,7 +195,7 @@ extension TraktManager { } } - guard let request = mutableRequest(forPath: path, + guard let request = try? mutableRequest(forPath: path, withQuery: query, isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -213,7 +213,7 @@ extension TraktManager { @discardableResult public func getShowCollectionProgress(showID id: T, hidden: Bool = false, specials: Bool = false, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { guard - let request = mutableRequest(forPath: "shows/\(id)/progress/collection", + let request = try? mutableRequest(forPath: "shows/\(id)/progress/collection", withQuery: ["hidden": "\(hidden)", "specials": "\(specials)"], isAuthorized: true, @@ -232,7 +232,7 @@ extension TraktManager { @discardableResult public func getShowWatchedProgress(showID id: T, hidden: Bool = false, specials: Bool = false, completion: @escaping ShowWatchedProgressCompletionHandler) -> URLSessionDataTaskProtocol? { guard - let request = mutableRequest(forPath: "shows/\(id)/progress/watched", + let request = try? mutableRequest(forPath: "shows/\(id)/progress/watched", withQuery: ["hidden": "\(hidden)", "specials": "\(specials)"], isAuthorized: true, @@ -310,7 +310,7 @@ extension TraktManager { */ @discardableResult public func getNextEpisode(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "shows/\(id)/next_episode", + guard let request = try? mutableRequest(forPath: "shows/\(id)/next_episode", withQuery: ["extended": extended.queryString()], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -327,7 +327,7 @@ extension TraktManager { */ @discardableResult public func getLastEpisode(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "shows/\(id)/last_episode", + guard let request = try? mutableRequest(forPath: "shows/\(id)/last_episode", withQuery: ["extended": extended.queryString()], isAuthorized: false, withHTTPMethod: .GET) else { return nil } diff --git a/Common/Wrapper/Sync.swift b/Common/Wrapper/Sync.swift index b1fc45b..b15679e 100644 --- a/Common/Wrapper/Sync.swift +++ b/Common/Wrapper/Sync.swift @@ -23,7 +23,7 @@ extension TraktManager { */ @discardableResult public func lastActivities(completion: @escaping LastActivitiesCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "sync/last_activities", + guard let request = try? mutableRequest(forPath: "sync/last_activities", withQuery: [:], isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -47,7 +47,7 @@ extension TraktManager { */ @discardableResult public func getPlaybackProgress(type: WatchedType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "sync/playback/\(type)", + guard let request = try? mutableRequest(forPath: "sync/playback/\(type)", withQuery: [:], isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -63,7 +63,7 @@ extension TraktManager { */ @discardableResult public func removePlaybackItem(id: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "sync/playback/\(id)", + guard let request = try? mutableRequest(forPath: "sync/playback/\(id)", withQuery: [:], isAuthorized: true, withHTTPMethod: .DELETE) else { return nil } @@ -88,7 +88,7 @@ extension TraktManager { */ @discardableResult public func getCollection(type: WatchedType, extended: [ExtendedType] = [.Min], completion: @escaping CollectionCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "sync/collection/\(type)", + guard let request = try? mutableRequest(forPath: "sync/collection/\(type)", withQuery: ["extended": extended.queryString()], isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -158,7 +158,7 @@ extension TraktManager { public func getWatchedShows(extended: [ExtendedType] = [.Min], completion: @escaping WatchedShowsCompletionHandler) -> URLSessionDataTaskProtocol? { guard - let request = mutableRequest(forPath: "sync/watched/shows", + let request = try? mutableRequest(forPath: "sync/watched/shows", withQuery: ["extended": extended.queryString()], isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -183,7 +183,7 @@ extension TraktManager { @discardableResult public func getWatchedMovies(extended: [ExtendedType] = [.Min], completion: @escaping WatchedMoviesCompletionHandler) -> URLSessionDataTaskProtocol? { guard - let request = mutableRequest(forPath: "sync/watched/movies", + let request = try? mutableRequest(forPath: "sync/watched/movies", withQuery: ["extended": extended.queryString()], isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -231,7 +231,7 @@ extension TraktManager { } guard - let request = mutableRequest(forPath: path, + let request = try? mutableRequest(forPath: path, withQuery: query, isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -300,7 +300,7 @@ extension TraktManager { } guard - let request = mutableRequest(forPath: path, + let request = try? mutableRequest(forPath: path, withQuery: [:], isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -369,7 +369,7 @@ extension TraktManager { } } - guard let request = mutableRequest(forPath: "sync/watchlist/\(watchType.rawValue)", + guard let request = try? mutableRequest(forPath: "sync/watchlist/\(watchType.rawValue)", withQuery: query, isAuthorized: true, withHTTPMethod: .GET) else { return nil } diff --git a/Common/Wrapper/TraktManager.swift b/Common/Wrapper/TraktManager.swift index 195c648..35f63cf 100644 --- a/Common/Wrapper/TraktManager.swift +++ b/Common/Wrapper/TraktManager.swift @@ -7,24 +7,76 @@ // import Foundation +import os public extension Notification.Name { static let TraktAccountStatusDidChange = Notification.Name(rawValue: "signedInToTrakt") } +@preconcurrency public class TraktManager { - + // TODO List: // 1. Create a limit object, double check every paginated API call is marked as paginated // 2. Call completion with custom error when creating request fails - + + // MARK: - Types + + public enum TraktKitError: Error { + case missingClientInfo + case malformedURL + case userNotAuthorized + case couldNotParseData + case invalidRefreshToken + } + + public enum TraktTokenError: Error { + // 404 + case invalidDeviceCode + // 409 + case alreadyUsed + // 410 + case expired + // 418 + case denied + // 429 + case tooManyRequests + case unexpectedStatusCode + case missingAccessCode + } + + public enum RefreshState { + case noTokens, validTokens, refreshTokens, expiredTokens + } + + /// Returns the local token state. This could be wrong if a user revokes application access from Trakt.tv + public var refreshState: RefreshState { + guard let expiredDate = UserDefaults.standard.object(forKey: Constants.tokenExpirationDefaultsKey) as? Date else { + return .noTokens + } + let refreshDate = expiredDate.addingTimeInterval(-Constants.oneMonth) + let now = Date() + + if now >= expiredDate { + return .expiredTokens + } + + if now >= refreshDate { + return .refreshTokens + } + + return .validTokens + } + // MARK: - Properties - + private enum Constants { static let tokenExpirationDefaultsKey = "accessTokenExpirationDate" static let oneMonth: TimeInterval = 2629800 } - + + static let logger = Logger(subsystem: "TraktKit", category: "TraktManager") + // MARK: Internal private var staging: Bool? private var clientID: String? @@ -38,25 +90,27 @@ public class TraktManager { encoder.dateEncodingStrategy = .iso8601 return encoder }() - + // Keys let accessTokenKey = "accessToken" let refreshTokenKey = "refreshToken" - + let session: URLSessionProtocol - + public lazy var explore: ExploreResource = ExploreResource(traktManager: self) - + // MARK: Public + + @preconcurrency public static let sharedManager = TraktManager() - + public var isSignedIn: Bool { get { return accessToken != nil } } public var oauthURL: URL? - + private var _accessToken: String? public var accessToken: String? { get { @@ -69,7 +123,7 @@ public class TraktManager { return accessTokenString } } - + return nil } set { @@ -81,13 +135,11 @@ public class TraktManager { } else { // Save to keychain let succeeded = MLKeychain.setString(value: newValue!, forKey: accessTokenKey) - #if DEBUG - print("Saved access token: \(succeeded)") - #endif + Self.logger.debug("Saved access token \(succeeded ? "successfully" : "failed")") } } } - + private var _refreshToken: String? public var refreshToken: String? { get { @@ -100,7 +152,7 @@ public class TraktManager { return refreshTokenString } } - + return nil } set { @@ -112,89 +164,80 @@ public class TraktManager { } else { // Save to keychain let succeeded = MLKeychain.setString(value: newValue!, forKey: refreshTokenKey) - #if DEBUG - print("Saved refresh token: \(succeeded)") - #endif + Self.logger.debug("Saved refresh token \(succeeded ? "successfully" : "failed")") } } } - + // MARK: - Lifecycle - + public init(session: URLSessionProtocol = URLSession(configuration: .default)) { self.session = session } - + // MARK: - Setup - + public func set(clientID: String, clientSecret secret: String, redirectURI: String, staging: Bool = false) { self.clientID = clientID self.clientSecret = secret self.redirectURI = redirectURI self.staging = staging - + self.baseURL = !staging ? "trakt.tv" : "staging.trakt.tv" self.APIBaseURL = !staging ? "api.trakt.tv" : "api-staging.trakt.tv" self.oauthURL = URL(string: "https://\(baseURL!)/oauth/authorize?response_type=code&client_id=\(clientID)&redirect_uri=\(redirectURI)") } - + internal func createErrorWithStatusCode(_ statusCode: Int) -> NSError { - let message: String - - if let traktMessage = StatusCodes.message(for: statusCode) { - message = traktMessage + let message = if let traktMessage = StatusCodes.message(for: statusCode) { + traktMessage } else { - message = "Request Failed: Gateway timed out (\(statusCode))" + "Request Failed: Gateway timed out (\(statusCode))" } - + let userInfo = [ "title": "Error", NSLocalizedDescriptionKey: message, NSLocalizedFailureReasonErrorKey: "", NSLocalizedRecoverySuggestionErrorKey: "" ] - let TraktKitIncorrectStatusError = NSError(domain: "com.litteral.TraktKit", code: statusCode, userInfo: userInfo) - - return TraktKitIncorrectStatusError + return NSError(domain: "com.litteral.TraktKit", code: statusCode, userInfo: userInfo) } - + // MARK: - Actions - + public func signOut() { accessToken = nil refreshToken = nil UserDefaults.standard.removeObject(forKey: Constants.tokenExpirationDefaultsKey) } - - internal func mutableRequestForURL(_ url: URL?, authorization: Bool, HTTPMethod: Method) -> URLRequest? { - guard - let url = url else { return nil } + + internal func mutableRequestForURL(_ url: URL, authorization: Bool, HTTPMethod: Method) throws -> URLRequest { var request = URLRequest(url: url) request.httpMethod = HTTPMethod.rawValue - + request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.addValue("2", forHTTPHeaderField: "trakt-api-version") - if let clientID = clientID { + if let clientID { request.addValue(clientID, forHTTPHeaderField: "trakt-api-key") } - + if authorization { - if let accessToken = accessToken { + if let accessToken { request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - } - else { - return nil + } else { + throw TraktKitError.userNotAuthorized } } - + return request } - - internal func mutableRequest(forPath path: String, withQuery query: [String: String], isAuthorized authorized: Bool, withHTTPMethod httpMethod: Method) -> URLRequest? { - guard let apiBaseURL = APIBaseURL else { preconditionFailure("Call `set(clientID:clientSecret:redirectURI:staging:)` before making any API requests") } + + internal func mutableRequest(forPath path: String, withQuery query: [String: String], isAuthorized authorized: Bool, withHTTPMethod httpMethod: Method) throws -> URLRequest? { + guard let apiBaseURL = APIBaseURL else { throw TraktKitError.missingClientInfo } let urlString = "https://\(apiBaseURL)/" + path guard var components = URLComponents(string: urlString) else { return nil } - + if query.isEmpty == false { var queryItems: [URLQueryItem] = [] for (key, value) in query { @@ -202,12 +245,12 @@ public class TraktManager { } components.queryItems = queryItems } - + guard let url = components.url else { return nil } var request = URLRequest(url: url) request.httpMethod = httpMethod.rawValue request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - + request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.addValue("2", forHTTPHeaderField: "trakt-api-version") if let clientID = clientID { @@ -219,10 +262,10 @@ public class TraktManager { request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } } - + return request } - + func post(_ path: String, query: [String: String] = [:], body: Body) -> URLRequest? { guard let apiBaseURL = APIBaseURL else { preconditionFailure("Call `set(clientID:clientSecret:redirectURI:staging:)` before making any API requests") } let urlString = "https://\(apiBaseURL)/" + path @@ -234,21 +277,21 @@ public class TraktManager { } components.queryItems = queryItems } - + guard let url = components.url else { return nil } var request = URLRequest(url: url) request.httpMethod = Method.POST.rawValue - + request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.addValue("2", forHTTPHeaderField: "trakt-api-version") if let clientID = clientID { request.addValue(clientID, forHTTPHeaderField: "trakt-api-key") } - + if let accessToken = accessToken { request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } - + do { request.httpBody = try jsonEncoder.encode(body) } catch { @@ -258,361 +301,201 @@ public class TraktManager { } // MARK: - Authentication - - public func getTokenFromAuthorizationCode(code: String, completionHandler: SuccessCompletionHandler?) throws { + + public func getToken(authorizationCode code: String) async throws -> AuthenticationInfo { guard - let clientID = clientID, - let clientSecret = clientSecret, + let baseURL, + let clientID, + let clientSecret, let redirectURI = redirectURI else { - completionHandler?(.fail) - return + throw TraktKitError.missingClientInfo } - - let urlString = "https://\(baseURL!)/oauth/token" - let url = URL(string: urlString) - guard var request = mutableRequestForURL(url, authorization: false, HTTPMethod: .POST) else { - completionHandler?(.fail) - return + + let urlString = "https://\(baseURL)/oauth/token" + guard let url = URL(string: urlString) else { + throw TraktKitError.malformedURL } - + var request = try mutableRequestForURL(url, authorization: false, HTTPMethod: .POST) + let json = [ "code": code, "client_id": clientID, "client_secret": clientSecret, "redirect_uri": redirectURI, "grant_type": "authorization_code", - ] + ] request.httpBody = try JSONSerialization.data(withJSONObject: json, options: []) - - session._dataTask(with: request) { [weak self] data, response, error in - guard - let welf = self else { return } - guard error == nil else { - completionHandler?(.fail) - return - } - - // Check response - guard - let HTTPResponse = response as? HTTPURLResponse, - HTTPResponse.statusCode == StatusCodes.Success else { - completionHandler?(.fail) - return - } - - // Check data - guard - let data = data else { - completionHandler?(.fail) - return - } - - do { - let decoder = JSONDecoder() - let authenticationInfo = try decoder.decode(AuthenticationInfo.self, from: data) - #if DEBUG - print(authenticationInfo) - print("[\(#function)] Access token is \(String(describing: welf.accessToken))") - print("[\(#function)] Refresh token is \(String(describing: welf.refreshToken))") - #endif - - welf.accessToken = authenticationInfo.accessToken - welf.refreshToken = authenticationInfo.refreshToken - // Save expiration date - let expiresDate = Date(timeIntervalSinceNow: authenticationInfo.expiresIn) - UserDefaults.standard.set(expiresDate, forKey: Constants.tokenExpirationDefaultsKey) - - // Post notification - DispatchQueue.main.async { - NotificationCenter.default.post(name: .TraktAccountStatusDidChange, object: nil) - } - - completionHandler?(.success) - } catch { - completionHandler?(.fail) - } - }.resume() + + let authenticationInfo: AuthenticationInfo = try await perform(request: request) + await saveCredentials(for: authenticationInfo, postAccountStatusChange: true) + return authenticationInfo } - - public func getAppCode(completionHandler: @escaping (_ result: DeviceCode?) -> Void) { - guard let clientID = clientID else { - completionHandler(nil) - return + + // MARK: - Authentication - Devices + + public func getAppCode() async throws -> DeviceCode { + guard + let APIBaseURL, + let clientID + else { + throw TraktKitError.missingClientInfo } - let urlString = "https://\(APIBaseURL!)/oauth/device/code/" - - let url = URL(string: urlString) - guard var request = mutableRequestForURL(url, authorization: false, HTTPMethod: .POST) else { - completionHandler(nil) - return + let urlString = "https://\(APIBaseURL)/oauth/device/code/" + + guard let url = URL(string: urlString) else { + throw TraktKitError.malformedURL } - let json = ["client_id": clientID] - do { - request.httpBody = try JSONSerialization.data(withJSONObject: json, options: []) - - session._dataTask(with: request) { data, response, error in - guard error == nil else { - completionHandler(nil) - return - } - - // Check response - guard - let HTTPResponse = response as? HTTPURLResponse, - HTTPResponse.statusCode == StatusCodes.Success - else { - completionHandler(nil) - return - } - - // Check data - guard let data = data else { - completionHandler(nil) + + var request = try mutableRequestForURL(url, authorization: false, HTTPMethod: .POST) + request.httpBody = try JSONSerialization.data(withJSONObject: json, options: []) + + return try await perform(request: request) + } + + public func pollForAccessToken(deviceCode: DeviceCode) async throws { + let startTime = Date() + + while true { + let (tokenResponse, statusCode) = try await requestAccessToken(code: deviceCode.deviceCode) + + switch statusCode { + case 200: + if let tokenResponse { + await saveCredentials(for: tokenResponse, postAccountStatusChange: true) return } - do { - let deviceCode = try JSONDecoder().decode(DeviceCode.self, from: data) - completionHandler(deviceCode) - } catch { - completionHandler(nil) - } - }.resume() - } catch { - completionHandler(nil) + throw TraktTokenError.missingAccessCode + + case 400: + // Pending - continue polling + break + case 404: throw TraktTokenError.invalidDeviceCode + case 409: throw TraktTokenError.alreadyUsed + case 410: throw TraktTokenError.expired + case 418: throw TraktTokenError.denied + case 429: + // Too many requests - wait before polling again + try await Task.sleep(for: .seconds(10)) + continue + default: + throw TraktTokenError.unexpectedStatusCode + } + + // Stop polling if `expires_in` time has elapsed + if Date().timeIntervalSince(startTime) >= deviceCode.expiresIn { + throw TraktTokenError.expired + } + + try await Task.sleep(for: .seconds(deviceCode.interval)) } } - - public func getTokenFromDevice(code: DeviceCode?, completionHandler: ProgressCompletionHandler?) { + + private func requestAccessToken(code: String) async throws -> (AuthenticationInfo?, Int) { + // Build request guard - let clientID = self.clientID, - let clientSecret = self.clientSecret, - let deviceCode = code + let APIBaseURL, + let clientID, + let clientSecret else { - completionHandler?(.fail(0)) - return + throw TraktKitError.missingClientInfo } - - let urlString = "https://\(APIBaseURL!)/oauth/device/token" - let url = URL(string: urlString) - guard var request = mutableRequestForURL(url, authorization: false, HTTPMethod: .POST) else { - completionHandler?(.fail(0)) - return + + let urlString = "https://\(APIBaseURL)/oauth/device/token" + guard let url = URL(string: urlString) else { + throw TraktKitError.malformedURL } - + var request = try mutableRequestForURL(url, authorization: false, HTTPMethod: .POST) + let json = [ - "code": deviceCode.deviceCode, + "code": code, "client_id": clientID, "client_secret": clientSecret, ] - guard let body = try? JSONSerialization.data(withJSONObject: json, options: []) else { - completionHandler?(.fail(0)) - return - } - request.httpBody = body - self.isWaitingToToken = true - - DispatchQueue.global().async { - var i = 1 - while self.isWaitingToToken { - if i >= deviceCode.expiresIn { - self.isWaitingToToken = false - continue - } - self.send(request: request, count: i) { result in - completionHandler?(result) - switch result { - case .success: - self.isWaitingToToken = false - case .fail(let progress): - if progress == 0 { - self.isWaitingToToken = false - } - } - } - i += 1 - sleep(1) - } + request.httpBody = try JSONSerialization.data(withJSONObject: json, options: []) + + // Make response + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) } + + // Don't throw, we want to check the `responseCode` even if the token response cannot be decoded. + let tokenResponse = try? JSONDecoder().decode(AuthenticationInfo.self, from: data) + + return (tokenResponse, httpResponse.statusCode) } - - private func send(request: URLRequest, count: Int, completionHandler: ProgressCompletionHandler?) { - session._dataTask(with: request) { [weak self] data, response, error in - guard let self = self else { return } - guard error == nil else { - completionHandler?(.fail(0)) - return - } - - // Check response - if let HTTPResponse = response as? HTTPURLResponse, - HTTPResponse.statusCode == StatusCodes.BadRequest { - completionHandler?(.fail(count)) - return - } - - guard let HTTPResponse = response as? HTTPURLResponse, - HTTPResponse.statusCode == StatusCodes.Success else { - completionHandler?(.fail(0)) - return - } - - // Check data - guard let data = data else { - completionHandler?(.fail(0)) - return - } - - do { - if let accessTokenDict = try JSONSerialization.jsonObject(with: data, options: []) as? [String: AnyObject] { - self.saveCredentials(accessTokenDict) - completionHandler?(.success) - } - } catch { - completionHandler?(.fail(0)) - } - }.resume() - } - - private func saveCredentials(_ credentials: [String: AnyObject]) { - self.accessToken = credentials["access_token"] as? String - self.refreshToken = credentials["refresh_token"] as? String - - #if DEBUG - print("[\(#function)] Access token is \(String(describing: self.accessToken))") - print("[\(#function)] Refresh token is \(String(describing: self.refreshToken))") - #endif - + + // TODO: Find replacement for posting `TraktAccountStatusDidChange` to alert apps of account change. + private func saveCredentials(for authInfo: AuthenticationInfo, postAccountStatusChange: Bool = false) async { + self.accessToken = authInfo.accessToken + self.refreshToken = authInfo.refreshToken + // Save expiration date - let timeInterval = credentials["expires_in"] as! NSNumber - let expiresDate = Date(timeIntervalSinceNow: timeInterval.doubleValue) - + let expiresDate = Date(timeIntervalSinceNow: authInfo.expiresIn) UserDefaults.standard.set(expiresDate, forKey: "accessTokenExpirationDate") UserDefaults.standard.synchronize() - + // Post notification - DispatchQueue.main.async { - NotificationCenter.default.post(name: .TraktAccountStatusDidChange, object: nil) + if postAccountStatusChange { + await MainActor.run { + NotificationCenter.default.post(name: .TraktAccountStatusDidChange, object: nil) + } } } - + // MARK: Refresh access token - - public enum RefreshState { - case noTokens, validTokens, refreshTokens, expiredTokens - } - - public enum RefreshTokenError: Error { - case missingRefreshToken, invalidRequest, invalidRefreshToken, unsuccessfulNetworkResponse(Int), missingData, expiredTokens - } - - /// Returns the local token state. This could be wrong if a user revokes application access from Trakt.tv - public var refreshState: RefreshState { - guard let expiredDate = UserDefaults.standard.object(forKey: Constants.tokenExpirationDefaultsKey) as? Date else { - return .noTokens - } - let refreshDate = expiredDate.addingTimeInterval(-Constants.oneMonth) - let now = Date() - - if now >= expiredDate { - return .expiredTokens - } - - if now >= refreshDate { - return .refreshTokens - } - - return .validTokens - } - - public func checkToRefresh(completion: @escaping (_ result: Swift.Result) -> Void) { + + public func checkToRefresh() async throws { switch refreshState { case .refreshTokens: - do { - try getAccessTokenFromRefreshToken(completionHandler: completion) - } catch { - completion(.failure(error)) - } + try await getAccessTokenFromRefreshToken() case .expiredTokens: - completion(.failure(RefreshTokenError.expiredTokens)) + throw TraktKitError.invalidRefreshToken default: - completion(.success(())) + break } } - - public func getAccessTokenFromRefreshToken(completionHandler: @escaping (_ result: Swift.Result) -> Void) throws { + + /** + Use the `refresh_token` to get a new `access_token` without asking the user to re-authenticate. The `access_token` is valid for 24 hours before it needs to be refreshed again. + */ + public func getAccessTokenFromRefreshToken() async throws { guard - let clientID = clientID, - let clientSecret = clientSecret, - let redirectURI = redirectURI, - let rToken = refreshToken - else { - completionHandler(.failure(RefreshTokenError.missingRefreshToken)) - return + let baseURL, + let clientID, + let clientSecret, + let redirectURI + else { + throw TraktKitError.missingClientInfo } - - let urlString = "https://\(baseURL!)/oauth/token" - let url = URL(string: urlString) - guard var request = mutableRequestForURL(url, authorization: false, HTTPMethod: .POST) else { - completionHandler(.failure(RefreshTokenError.invalidRequest)) - return + + guard let refreshToken else { throw TraktKitError.invalidRefreshToken } + + // Create request + guard let url = URL(string: "https://\(baseURL)/oauth/token") else { + throw TraktKitError.malformedURL } - + var request = try mutableRequestForURL(url, authorization: false, HTTPMethod: .POST) + let json = [ - "refresh_token": rToken, + "refresh_token": refreshToken, "client_id": clientID, "client_secret": clientSecret, "redirect_uri": redirectURI, "grant_type": "refresh_token", - ] + ] request.httpBody = try JSONSerialization.data(withJSONObject: json, options: []) - - session._dataTask(with: request) { [weak self] (data, response, error) -> Void in - guard let welf = self else { return } - if let error = error { - completionHandler(.failure(error)) - return - } - - // Check response - guard let HTTPResponse = response as? HTTPURLResponse else { return } - - switch HTTPResponse.statusCode { - case 401: - completionHandler(.failure(RefreshTokenError.invalidRefreshToken)) - case 200...299: // Success - break - default: - completionHandler(.failure(RefreshTokenError.unsuccessfulNetworkResponse(HTTPResponse.statusCode))) - } - - // Check data - guard let data = data else { - completionHandler(.failure(RefreshTokenError.missingData)) - return - } - - do { - let decoder = JSONDecoder() - let authenticationInfo = try decoder.decode(AuthenticationInfo.self, from: data) - #if DEBUG - print(authenticationInfo) - print("[\(#function)] Access token is \(String(describing: welf.accessToken))") - print("[\(#function)] Refresh token is \(String(describing: welf.refreshToken))") - #endif - - welf.accessToken = authenticationInfo.accessToken - welf.refreshToken = authenticationInfo.refreshToken - // Save expiration date - let expiresDate = Date(timeIntervalSinceNow: authenticationInfo.expiresIn) - UserDefaults.standard.set(expiresDate, forKey: Constants.tokenExpirationDefaultsKey) - UserDefaults.standard.synchronize() - - completionHandler(.success(())) - } catch { - completionHandler(.failure(error)) - } - }.resume() + + // Make request and handle response + do { + let authenticationInfo: AuthenticationInfo = try await perform(request: request) + await saveCredentials(for: authenticationInfo) + } catch TraktError.unauthorized { + throw TraktKitError.invalidRefreshToken + } catch { + throw error + } } } diff --git a/Common/Wrapper/URLSessionProtocol.swift b/Common/Wrapper/URLSessionProtocol.swift index cd181e3..3d29d6d 100644 --- a/Common/Wrapper/URLSessionProtocol.swift +++ b/Common/Wrapper/URLSessionProtocol.swift @@ -42,7 +42,7 @@ class MockURLSession: URLSessionProtocol { var nextStatusCode: Int = StatusCodes.Success var nextError: Error? - private (set) var lastURL: URL? + private(set) var lastURL: URL? func successHttpURLResponse(request: URLRequest) -> URLResponse { return HTTPURLResponse(url: request.url!, statusCode: nextStatusCode, httpVersion: "HTTP/1.1", headerFields: nil)! diff --git a/Common/Wrapper/Users.swift b/Common/Wrapper/Users.swift index 8119c11..28309e6 100644 --- a/Common/Wrapper/Users.swift +++ b/Common/Wrapper/Users.swift @@ -30,7 +30,7 @@ extension TraktManager { */ @discardableResult public func getSettings(completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "users/settings", + guard let request = try? mutableRequest(forPath: "users/settings", withQuery: [:], isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -46,7 +46,7 @@ extension TraktManager { */ @discardableResult public func getFollowRequests(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "users/requests", + guard let request = try? mutableRequest(forPath: "users/requests", withQuery: [:], isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -64,7 +64,7 @@ extension TraktManager { */ @discardableResult public func approveFollowRequest(requestID id: NSNumber, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "users/requests/\(id)", + guard let request = try? mutableRequest(forPath: "users/requests/\(id)", withQuery: [:], isAuthorized: true, withHTTPMethod: .POST) else { return nil } @@ -80,7 +80,7 @@ extension TraktManager { */ @discardableResult public func denyFollowRequest(requestID id: NSNumber, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "users/requests/\(id)", + guard let request = try? mutableRequest(forPath: "users/requests/\(id)", withQuery: [:], isAuthorized: true, withHTTPMethod: .DELETE) else { return nil } @@ -110,7 +110,7 @@ extension TraktManager { } } - guard let request = mutableRequest(forPath: "users/hidden/\(section.rawValue)", + guard let request = try? mutableRequest(forPath: "users/hidden/\(section.rawValue)", withQuery: query, isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -153,7 +153,7 @@ extension TraktManager { */ @discardableResult public func getLikes(type: LikeType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "users/likes/\(type.rawValue)", + guard let request = try? mutableRequest(forPath: "users/likes/\(type.rawValue)", withQuery: [:], isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -170,7 +170,7 @@ extension TraktManager { @discardableResult public func getUserProfile(username: String = "me", extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { let authorization = username == "me" ? true : false - guard let request = mutableRequest(forPath: "users/\(username)", + guard let request = try? mutableRequest(forPath: "users/\(username)", withQuery: ["extended": extended.queryString()], isAuthorized: authorization, withHTTPMethod: .GET) else { return nil } @@ -189,7 +189,7 @@ extension TraktManager { @discardableResult public func getUserCollection(username: String = "me", type: MediaType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { let authorization = username == "me" ? true : false - guard let request = mutableRequest(forPath: "users/\(username)/collection/\(type.rawValue)", + guard let request = try? mutableRequest(forPath: "users/\(username)/collection/\(type.rawValue)", withQuery: [:], isAuthorized: authorization, withHTTPMethod: .GET) else { return nil } @@ -226,7 +226,7 @@ extension TraktManager { } guard - let request = mutableRequest(forPath: path, + let request = try? mutableRequest(forPath: path, withQuery: query, isAuthorized: authorization, withHTTPMethod: .GET) else { return nil } @@ -244,7 +244,7 @@ extension TraktManager { public func getCustomLists(username: String = "me", completion: @escaping ListsCompletionHandler) -> URLSessionDataTaskProtocol? { let authorization = username == "me" ? true : false guard - let request = mutableRequest(forPath: "users/\(username)/lists", + let request = try? mutableRequest(forPath: "users/\(username)/lists", withQuery: [:], isAuthorized: authorization, withHTTPMethod: .GET) else { return nil } @@ -275,7 +275,7 @@ extension TraktManager { ] // Request - guard var request = mutableRequest(forPath: "users/me/lists", withQuery: [:], isAuthorized: true, withHTTPMethod: .POST) else { return nil } + guard var request = try? mutableRequest(forPath: "users/me/lists", withQuery: [:], isAuthorized: true, withHTTPMethod: .POST) else { return nil } request.httpBody = try JSONSerialization.data(withJSONObject: json, options: []) return performRequest(request: request, completion: completion) @@ -292,7 +292,7 @@ extension TraktManager { public func getCustomList(username: String = "me", listID: T, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { let authorization = username == "me" ? true : false guard - let request = mutableRequest(forPath: "users/\(username)/lists/\(listID)", + let request = try? mutableRequest(forPath: "users/\(username)/lists/\(listID)", withQuery: [:], isAuthorized: authorization, withHTTPMethod: .GET) else { return nil } @@ -316,7 +316,7 @@ extension TraktManager { json["allow_comments"] = allowComments // Request - guard var request = mutableRequest(forPath: "users/me/lists/\(listID)", withQuery: [:], isAuthorized: true, withHTTPMethod: .PUT) else { return nil } + guard var request = try? mutableRequest(forPath: "users/me/lists/\(listID)", withQuery: [:], isAuthorized: true, withHTTPMethod: .PUT) else { return nil } request.httpBody = try JSONSerialization.data(withJSONObject: json, options: []) return performRequest(request: request, completion: completion) @@ -330,7 +330,7 @@ extension TraktManager { @discardableResult public func deleteCustomList(username: String = "me", listID: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { guard - let request = mutableRequest(forPath: "users/\(username)/lists/\(listID)", + let request = try? mutableRequest(forPath: "users/\(username)/lists/\(listID)", withQuery: [:], isAuthorized: true, withHTTPMethod: .DELETE) else { return nil } @@ -347,7 +347,7 @@ extension TraktManager { @discardableResult public func likeList(username: String = "me", listID: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { guard - let request = mutableRequest(forPath: "users/\(username)/lists/\(listID)/like", + let request = try? mutableRequest(forPath: "users/\(username)/lists/\(listID)/like", withQuery: [:], isAuthorized: true, withHTTPMethod: .POST) else { return nil } @@ -362,7 +362,7 @@ extension TraktManager { @discardableResult public func removeListLike(username: String = "me", listID: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { guard - let request = mutableRequest(forPath: "users/\(username)/lists/\(listID)/like", + let request = try? mutableRequest(forPath: "users/\(username)/lists/\(listID)/like", withQuery: [:], isAuthorized: true, withHTTPMethod: .DELETE) else { return nil } @@ -386,7 +386,7 @@ extension TraktManager { path += "/\(value)" } - guard let request = mutableRequest(forPath: path, + guard let request = try? mutableRequest(forPath: path, withQuery: ["extended": extended.queryString()], isAuthorized: authorization, withHTTPMethod: .GET) else { return nil } @@ -441,7 +441,7 @@ extension TraktManager { @discardableResult public func getUserAllListComments(username: String = "me", listID: String, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTaskProtocol? { guard - let request = mutableRequest(forPath: "users/\(username)/lists/\(listID)/comments", + let request = try? mutableRequest(forPath: "users/\(username)/lists/\(listID)/comments", withQuery: [:], isAuthorized: false, withHTTPMethod: .GET) else { return nil } @@ -459,7 +459,7 @@ extension TraktManager { */ @discardableResult public func followUser(username: String, completion: @escaping FollowUserCompletion) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "users/\(username)/follow", + guard let request = try? mutableRequest(forPath: "users/\(username)/follow", withQuery: [:], isAuthorized: true, withHTTPMethod: .POST) else { return nil } @@ -473,7 +473,7 @@ extension TraktManager { */ @discardableResult public func unfollowUser(username: String, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = mutableRequest(forPath: "users/\(username)/follow", + guard let request = try? mutableRequest(forPath: "users/\(username)/follow", withQuery: [:], isAuthorized: true, withHTTPMethod: .DELETE) else { return nil } @@ -490,7 +490,7 @@ extension TraktManager { @discardableResult public func getUserFollowers(username: String = "me", completion: @escaping FollowersCompletion) -> URLSessionDataTaskProtocol? { let authorization = username == "me" ? true : false - guard let request = mutableRequest(forPath: "users/\(username)/followers", + guard let request = try? mutableRequest(forPath: "users/\(username)/followers", withQuery: [:], isAuthorized: authorization, withHTTPMethod: .GET) else { return nil } @@ -508,7 +508,7 @@ extension TraktManager { public func getUserFollowing(username: String = "me", completion: @escaping FollowersCompletion) -> URLSessionDataTaskProtocol? { let authorization = username == "me" ? true : false guard - let request = mutableRequest(forPath: "users/\(username)/following", + let request = try? mutableRequest(forPath: "users/\(username)/following", withQuery: [:], isAuthorized: authorization, withHTTPMethod: .GET) else { return nil } @@ -525,7 +525,7 @@ extension TraktManager { @discardableResult public func getUserFriends(username: String = "me", completion: @escaping FriendsCompletion) -> URLSessionDataTaskProtocol? { let authorization = username == "me" ? true : false - guard let request = mutableRequest(forPath: "users/\(username)/friends", + guard let request = try? mutableRequest(forPath: "users/\(username)/friends", withQuery: [:], isAuthorized: authorization, withHTTPMethod: .GET) else { return nil } @@ -573,7 +573,7 @@ extension TraktManager { } let authorization = username == "me" ? true : false - guard let request = mutableRequest(forPath: path, + guard let request = try? mutableRequest(forPath: path, withQuery: query, isAuthorized: authorization, withHTTPMethod: .GET) else { return nil } @@ -602,7 +602,7 @@ extension TraktManager { let authorization = username == "me" ? true : false guard - let request = mutableRequest(forPath: path, + let request = try? mutableRequest(forPath: path, withQuery: [:], isAuthorized: authorization, withHTTPMethod: .GET) else { return nil } @@ -619,7 +619,7 @@ extension TraktManager { @discardableResult public func getUserWatchlist(username: String = "me", type: WatchedType, extended: [ExtendedType] = [.Min], completion: @escaping ListItemCompletionHandler) -> URLSessionDataTaskProtocol? { let authorization = username == "me" ? true : false - guard let request = mutableRequest(forPath: "users/\(username)/watchlist/\(type.rawValue)", + guard let request = try? mutableRequest(forPath: "users/\(username)/watchlist/\(type.rawValue)", withQuery: ["extended": extended.queryString()], isAuthorized: authorization, withHTTPMethod: .GET) else { return nil } @@ -638,7 +638,7 @@ extension TraktManager { // Should this function have a special completion handler? If it returns no data it is obvious that the user // is not watching anything, but checking a boolean in the completion block is also nice let authorization = username == "me" ? true : false - guard let request = mutableRequest(forPath: "users/\(username)/watching", + guard let request = try? mutableRequest(forPath: "users/\(username)/watching", withQuery: [:], isAuthorized: authorization, withHTTPMethod: .GET) else { return nil } @@ -655,7 +655,7 @@ extension TraktManager { @discardableResult public func getUserWatched(username: String = "me", type: MediaType, extended: [ExtendedType] = [.Min], completion: @escaping UserWatchedCompletion) -> URLSessionDataTaskProtocol? { let authorization = username == "me" ? true : false - guard var request = mutableRequest(forPath: "users/\(username)/watched/\(type.rawValue)", + guard var request = try? mutableRequest(forPath: "users/\(username)/watched/\(type.rawValue)", withQuery: ["extended": extended.queryString()], isAuthorized: authorization, withHTTPMethod: .GET) else { return nil } @@ -673,7 +673,7 @@ extension TraktManager { @discardableResult public func getUserStats(username: String = "me", completion: @escaping UserStatsCompletion) -> URLSessionDataTaskProtocol? { let authorization = username == "me" ? true : false - guard let request = mutableRequest(forPath: "users/\(username)/stats", + guard let request = try? mutableRequest(forPath: "users/\(username)/stats", withQuery: [:], isAuthorized: authorization, withHTTPMethod: .GET) else { return nil } diff --git a/Package.swift b/Package.swift index bdfb1b0..86a1a8b 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.6 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,10 +6,10 @@ import PackageDescription let package = Package( name: "TraktKit", platforms: [ - .macOS(.v12), - .iOS(.v15), - .tvOS(.v15), - .watchOS(.v8) + .macOS(.v14), + .iOS(.v16), + .tvOS(.v16), + .watchOS(.v9) ], products: [ .library( @@ -30,5 +30,5 @@ let package = Package( ] ), ], - swiftLanguageVersions: [.version("5.7")] + swiftLanguageModes: [.version("6.0")] ) diff --git a/Tests/TraktKitTests/HelperFunctions.swift b/Tests/TraktKitTests/HelperFunctions.swift index 42e919f..a52de8e 100644 --- a/Tests/TraktKitTests/HelperFunctions.swift +++ b/Tests/TraktKitTests/HelperFunctions.swift @@ -61,10 +61,10 @@ extension Bundle { return Bundle(for: bundleClass) #endif } - - private static var testingBundle = packageBundle(for: "TraktKit_TraktKitTests") - private static var modulebundle = packageBundle() - + + private static let testingBundle = packageBundle(for: "TraktKit_TraktKitTests") + private static let modulebundle = packageBundle() + /// Searches all bundles for the correct one since Swift Package bundles don't work. /// - Parameter bundleName: "packageName_ProductName" private static func packageBundle(for bundleName: String = "TraktKit_TraktKit") -> Bundle { diff --git a/Tests/TraktKitTests/TraktManagerTests.swift b/Tests/TraktKitTests/TraktManagerTests.swift new file mode 100644 index 0000000..b85f4fb --- /dev/null +++ b/Tests/TraktKitTests/TraktManagerTests.swift @@ -0,0 +1,42 @@ +// +// TraktManagerTests.swift +// TraktKit +// +// Created by Maximilian Litteral on 2/8/25. +// + +import Foundation +import Testing +@testable import TraktKit + +@Suite +class TraktManagerTests { + + let session = MockURLSession() + lazy var traktManager = TestTraktManager(session: session) + + deinit { + session.nextData = nil + session.nextStatusCode = StatusCodes.Success + session.nextError = nil + } + + @Test + func pollForAccessTokenInvalidDeviceCode() async throws { + session.nextStatusCode = 404 + session.nextData = Data() + + let deviceCodeJSON: [String: Any] = [ + "device_code": "d9c126a7706328d808914cfd1e40274b6e009f684b1aca271b9b3f90b3630d64", + "user_code": "5055CC52", + "verification_url": "https://trakt.tv/activate", + "expires_in": 600, + "interval": 5 + ] + let deviceCode = try JSONDecoder().decode(DeviceCode.self, from: try JSONSerialization.data(withJSONObject: deviceCodeJSON)) + + await #expect(throws: TraktManager.TraktTokenError.invalidDeviceCode, performing: { + try await traktManager.pollForAccessToken(deviceCode: deviceCode) + }) + } +} From 9034b050b3f05d9e2e29c890fad213721190507e Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Tue, 18 Feb 2025 22:05:10 -0500 Subject: [PATCH 02/38] Use typealias for all models to conform to Codable, Hashable, and Sendable --- .../Authentication/AuthenticationInfo.swift | 4 +- Common/Models/Authentication/DeviceCode.swift | 2 +- Common/Models/BodyPost.swift | 2 +- Common/Models/Calendar/CalendarMovie.swift | 2 +- Common/Models/Calendar/CalendarShow.swift | 2 +- .../Models/Certifications/Certification.swift | 4 +- Common/Models/Checkin/TraktCheckin.swift | 6 +- .../Comments/TraktAttachedMediaItem.swift | 2 +- Common/Models/Comments/TraktComment.swift | 2 +- .../Comments/TraktCommentLikedUser.swift | 2 +- .../Comments/TraktTrendingComments.swift | 2 +- Common/Models/Filters.swift | 2 +- Common/Models/Genres/Genre.swift | 2 +- Common/Models/Languages/Language.swift | 2 +- .../Models/Movies/TraktAnticipatedMovie.swift | 2 +- .../Models/Movies/TraktBoxOfficeMovie.swift | 2 +- .../Models/Movies/TraktDVDReleaseMovie.swift | 2 +- Common/Models/Movies/TraktMostMovie.swift | 2 +- Common/Models/Movies/TraktMovie.swift | 2 +- Common/Models/Movies/TraktMovieRelease.swift | 5 +- .../Models/Movies/TraktMovieTranslation.swift | 2 +- Common/Models/Movies/TraktTrendingMovie.swift | 2 +- Common/Models/Movies/TraktWatchedMovie.swift | 2 +- Common/Models/People/CastAndCrew.swift | 6 +- Common/Models/People/Person.swift | 2 +- Common/Models/People/TraktCastMember.swift | 8 +- Common/Models/People/TraktCrewMember.swift | 8 +- Common/Models/Scrobbble/ScrobbleResult.swift | 2 +- Common/Models/Shared/Alias.swift | 2 +- Common/Models/Shared/RatingDistribution.swift | 4 +- Common/Models/Shared/Update.swift | 2 +- .../Models/Shows/ShowCollectionProgress.swift | 6 +- .../Models/Shows/TraktAnticipatedShow.swift | 2 +- Common/Models/Shows/TraktEpisode.swift | 27 ++++- .../Shows/TraktEpisodeTranslation.swift | 9 +- Common/Models/Shows/TraktMostShow.swift | 2 +- Common/Models/Shows/TraktSeason.swift | 2 +- Common/Models/Shows/TraktShow.swift | 4 +- .../Models/Shows/TraktShowTranslation.swift | 2 +- Common/Models/Shows/TraktTrendingShow.swift | 2 +- .../Models/Shows/TraktWatchedEpisodes.swift | 2 +- .../Models/Shows/TraktWatchedProgress.swift | 6 +- Common/Models/Shows/TraktWatchedSeason.swift | 2 +- Common/Models/Shows/TraktWatchedShow.swift | 2 +- Common/Models/Structures.swift | 26 ++-- Common/Models/Sync/AddRatingsResult.swift | 6 +- .../Models/Sync/AddToCollectionResult.swift | 6 +- Common/Models/Sync/AddToHistoryResult.swift | 14 +-- Common/Models/Sync/PlaybackProgress.swift | 2 +- .../Sync/RemoveFromCollectionResult.swift | 6 +- .../Sync/RemoveFromWatchlistResult.swift | 6 +- Common/Models/Sync/RemoveRatingsResult.swift | 6 +- Common/Models/Sync/TraktCollectedItem.swift | 18 +-- Common/Models/Sync/TraktHistoryItem.swift | 2 +- Common/Models/Sync/TraktRating.swift | 2 +- Common/Models/TraktSearchResult.swift | 2 +- Common/Models/TraktVideo.swift | 32 +++++ Common/Models/Users/AccountSettings.swift | 91 +++++++++++++- Common/Models/Users/FollowRequest.swift | 4 +- Common/Models/Users/FollowUserResult.swift | 2 +- Common/Models/Users/Friend.swift | 2 +- Common/Models/Users/HiddenItem.swift | 2 +- Common/Models/Users/HideItemResult.swift | 6 +- Common/Models/Users/Likes.swift | 4 +- Common/Models/Users/ListItemPostResult.swift | 12 +- .../Models/Users/RemoveListItemResult.swift | 6 +- Common/Models/Users/TraktList.swift | 6 +- Common/Models/Users/TraktListItem.swift | 2 +- Common/Models/Users/TraktUser.swift | 8 +- Common/Models/Users/TraktWatchedItem.swift | 2 +- Common/Models/Users/TraktWatching.swift | 2 +- Common/Models/Users/UnhideItemResult.swift | 6 +- Common/Models/Users/UserStats.swift | 16 +-- Common/Models/Users/UsersComments.swift | 2 +- Common/Wrapper/CompletionHandlers.swift | 111 +++++++++++------- 75 files changed, 370 insertions(+), 201 deletions(-) create mode 100644 Common/Models/TraktVideo.swift diff --git a/Common/Models/Authentication/AuthenticationInfo.swift b/Common/Models/Authentication/AuthenticationInfo.swift index 7e4dfcf..9d240fc 100644 --- a/Common/Models/Authentication/AuthenticationInfo.swift +++ b/Common/Models/Authentication/AuthenticationInfo.swift @@ -8,7 +8,9 @@ import Foundation -public struct AuthenticationInfo: Codable, Hashable { +public typealias TraktObject = Codable & Hashable & Sendable + +public struct AuthenticationInfo: TraktObject { public let accessToken: String public let tokenType: String public let expiresIn: TimeInterval diff --git a/Common/Models/Authentication/DeviceCode.swift b/Common/Models/Authentication/DeviceCode.swift index 02b4f07..c8504aa 100644 --- a/Common/Models/Authentication/DeviceCode.swift +++ b/Common/Models/Authentication/DeviceCode.swift @@ -9,7 +9,7 @@ import UIKit #endif -public struct DeviceCode: Codable { +public struct DeviceCode: TraktObject { public let deviceCode: String public let userCode: String public let verificationURL: String diff --git a/Common/Models/BodyPost.swift b/Common/Models/BodyPost.swift index 6d663a3..ef380fe 100644 --- a/Common/Models/BodyPost.swift +++ b/Common/Models/BodyPost.swift @@ -56,7 +56,7 @@ class TraktCommentBody: TraktSingleObjectBody { } /// ID used to sync with Trakt. -public struct SyncId: Codable, Hashable { +public struct SyncId: TraktObject { /// Trakt id of the movie / show / season / episode public let trakt: Int diff --git a/Common/Models/Calendar/CalendarMovie.swift b/Common/Models/Calendar/CalendarMovie.swift index 356b766..de19ea3 100644 --- a/Common/Models/Calendar/CalendarMovie.swift +++ b/Common/Models/Calendar/CalendarMovie.swift @@ -8,7 +8,7 @@ import Foundation -public struct CalendarMovie: Codable, Hashable { +public struct CalendarMovie: TraktObject { public let released: Date public let movie: TraktMovie } diff --git a/Common/Models/Calendar/CalendarShow.swift b/Common/Models/Calendar/CalendarShow.swift index 451ad3e..30f38b1 100644 --- a/Common/Models/Calendar/CalendarShow.swift +++ b/Common/Models/Calendar/CalendarShow.swift @@ -8,7 +8,7 @@ import Foundation -public struct CalendarShow: Codable, Hashable { +public struct CalendarShow: TraktObject { public let firstAired: Date public let episode: TraktEpisode public let show: TraktShow diff --git a/Common/Models/Certifications/Certification.swift b/Common/Models/Certifications/Certification.swift index 7375c5e..16a2e4d 100644 --- a/Common/Models/Certifications/Certification.swift +++ b/Common/Models/Certifications/Certification.swift @@ -8,14 +8,14 @@ import Foundation -public struct Certifications: Codable, Hashable { +public struct Certifications: TraktObject { public let us: [Certification] enum CodingKeys: String, CodingKey { case us } - public struct Certification: Codable, Hashable { + public struct Certification: TraktObject { public let name: String public let slug: String public let description: String diff --git a/Common/Models/Checkin/TraktCheckin.swift b/Common/Models/Checkin/TraktCheckin.swift index c47348d..dbc3303 100644 --- a/Common/Models/Checkin/TraktCheckin.swift +++ b/Common/Models/Checkin/TraktCheckin.swift @@ -11,12 +11,12 @@ import Foundation /** The sharing object is optional and will apply the user's settings if not sent. If sharing is sent, each key will override the user's setting for that social network. Send true to post or false to not post on the indicated social network. You can see which social networks a user has connected with the /users/settings method. */ -public struct ShareSettings: Codable, Hashable { +public struct ShareSettings: TraktObject { public let twitter: Bool public let tumblr: Bool } -public struct TraktCheckinBody: Codable { +public struct TraktCheckinBody: TraktObject { /// `movie` or `episode` must not be nil public let movie: SyncId? /// `movie` or `episode` must not be nil @@ -54,7 +54,7 @@ public struct TraktCheckinBody: Codable { } } -public struct TraktCheckinResponse: Codable, Hashable { +public struct TraktCheckinResponse: TraktObject { /// A unique history id (64-bit integer) used to reference this checkin directly. public let id: Int diff --git a/Common/Models/Comments/TraktAttachedMediaItem.swift b/Common/Models/Comments/TraktAttachedMediaItem.swift index 39e1bdb..b65f9ba 100644 --- a/Common/Models/Comments/TraktAttachedMediaItem.swift +++ b/Common/Models/Comments/TraktAttachedMediaItem.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktAttachedMediaItem: Codable, Hashable { +public struct TraktAttachedMediaItem: TraktObject { public let type: String public let movie: TraktMovie? public let show: TraktShow? diff --git a/Common/Models/Comments/TraktComment.swift b/Common/Models/Comments/TraktComment.swift index d004481..2075378 100644 --- a/Common/Models/Comments/TraktComment.swift +++ b/Common/Models/Comments/TraktComment.swift @@ -8,7 +8,7 @@ import Foundation -public struct Comment: Codable, Hashable { +public struct Comment: TraktObject { public let id: Int public let parentId: Int public let createdAt: Date diff --git a/Common/Models/Comments/TraktCommentLikedUser.swift b/Common/Models/Comments/TraktCommentLikedUser.swift index ddb3c09..049f87e 100644 --- a/Common/Models/Comments/TraktCommentLikedUser.swift +++ b/Common/Models/Comments/TraktCommentLikedUser.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktCommentLikedUser: Codable, Hashable { +public struct TraktCommentLikedUser: TraktObject { public let likedAt: Date public let user: User diff --git a/Common/Models/Comments/TraktTrendingComments.swift b/Common/Models/Comments/TraktTrendingComments.swift index c7c1023..65bc512 100644 --- a/Common/Models/Comments/TraktTrendingComments.swift +++ b/Common/Models/Comments/TraktTrendingComments.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktTrendingComment: Codable, Hashable { +public struct TraktTrendingComment: TraktObject { public let type: String public let comment: Comment public let movie: TraktMovie? diff --git a/Common/Models/Filters.swift b/Common/Models/Filters.swift index 55bb8b4..afff9b9 100644 --- a/Common/Models/Filters.swift +++ b/Common/Models/Filters.swift @@ -8,7 +8,7 @@ import Foundation -public protocol FilterType { +public protocol FilterType: Sendable { func value() -> (key: String, value: String) } diff --git a/Common/Models/Genres/Genre.swift b/Common/Models/Genres/Genre.swift index 5834b4b..72c04b9 100644 --- a/Common/Models/Genres/Genre.swift +++ b/Common/Models/Genres/Genre.swift @@ -8,7 +8,7 @@ import Foundation -public struct Genres: Codable, Hashable { +public struct Genres: TraktObject { public let name: String public let slug: String } diff --git a/Common/Models/Languages/Language.swift b/Common/Models/Languages/Language.swift index ee8dbab..430a9de 100644 --- a/Common/Models/Languages/Language.swift +++ b/Common/Models/Languages/Language.swift @@ -8,7 +8,7 @@ import Foundation -public struct Languages: Codable, Hashable { +public struct Languages: TraktObject { public let name: String public let code: String } diff --git a/Common/Models/Movies/TraktAnticipatedMovie.swift b/Common/Models/Movies/TraktAnticipatedMovie.swift index 49bcefc..a937b7f 100644 --- a/Common/Models/Movies/TraktAnticipatedMovie.swift +++ b/Common/Models/Movies/TraktAnticipatedMovie.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktAnticipatedMovie: Codable, Hashable { +public struct TraktAnticipatedMovie: TraktObject { // Extended: Min public let listCount: Int public let movie: TraktMovie diff --git a/Common/Models/Movies/TraktBoxOfficeMovie.swift b/Common/Models/Movies/TraktBoxOfficeMovie.swift index 3d378b5..fb534ee 100644 --- a/Common/Models/Movies/TraktBoxOfficeMovie.swift +++ b/Common/Models/Movies/TraktBoxOfficeMovie.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktBoxOfficeMovie: Codable, Hashable { +public struct TraktBoxOfficeMovie: TraktObject { // Extended: Min public let revenue: Int public let movie: TraktMovie diff --git a/Common/Models/Movies/TraktDVDReleaseMovie.swift b/Common/Models/Movies/TraktDVDReleaseMovie.swift index 4043ad1..b445230 100644 --- a/Common/Models/Movies/TraktDVDReleaseMovie.swift +++ b/Common/Models/Movies/TraktDVDReleaseMovie.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktDVDReleaseMovie: Codable, Hashable { +public struct TraktDVDReleaseMovie: TraktObject { // Extended: Min public let released: Date public let movie: TraktMovie diff --git a/Common/Models/Movies/TraktMostMovie.swift b/Common/Models/Movies/TraktMostMovie.swift index 163314a..67f95fc 100644 --- a/Common/Models/Movies/TraktMostMovie.swift +++ b/Common/Models/Movies/TraktMostMovie.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktMostMovie: Codable, Hashable { +public struct TraktMostMovie: TraktObject { // Extended: Min public let watcherCount: Int diff --git a/Common/Models/Movies/TraktMovie.swift b/Common/Models/Movies/TraktMovie.swift index 6cd1d41..ee88b9d 100644 --- a/Common/Models/Movies/TraktMovie.swift +++ b/Common/Models/Movies/TraktMovie.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktMovie: Codable, Hashable { +public struct TraktMovie: TraktObject { // Extended: Min public let title: String diff --git a/Common/Models/Movies/TraktMovieRelease.swift b/Common/Models/Movies/TraktMovieRelease.swift index 5f266d0..eab5a73 100644 --- a/Common/Models/Movies/TraktMovieRelease.swift +++ b/Common/Models/Movies/TraktMovieRelease.swift @@ -8,11 +8,12 @@ import Foundation -public struct TraktMovieRelease: Codable, Hashable { +public struct TraktMovieRelease: TraktObject { public let country: String public let certification: String public let releaseDate: Date public let releaseType: ReleaseType + /// The `note` might have optional info such as the film festival name for a premiere release or Blu-ray specs for a `physical` release. public let note: String? enum CodingKeys: String, CodingKey { @@ -23,7 +24,7 @@ public struct TraktMovieRelease: Codable, Hashable { case note } - public enum ReleaseType: String, Codable { + public enum ReleaseType: String, TraktObject { case unknown case premiere case limited diff --git a/Common/Models/Movies/TraktMovieTranslation.swift b/Common/Models/Movies/TraktMovieTranslation.swift index e39ab37..277ac81 100644 --- a/Common/Models/Movies/TraktMovieTranslation.swift +++ b/Common/Models/Movies/TraktMovieTranslation.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktMovieTranslation: Codable, Hashable { +public struct TraktMovieTranslation: TraktObject { public let title: String public let overview: String public let tagline: String diff --git a/Common/Models/Movies/TraktTrendingMovie.swift b/Common/Models/Movies/TraktTrendingMovie.swift index 25cc747..cf0f5b1 100644 --- a/Common/Models/Movies/TraktTrendingMovie.swift +++ b/Common/Models/Movies/TraktTrendingMovie.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktTrendingMovie: Codable, Hashable { +public struct TraktTrendingMovie: TraktObject { // Extended: Min public let watchers: Int public let movie: TraktMovie diff --git a/Common/Models/Movies/TraktWatchedMovie.swift b/Common/Models/Movies/TraktWatchedMovie.swift index fc8f500..2e96ad0 100644 --- a/Common/Models/Movies/TraktWatchedMovie.swift +++ b/Common/Models/Movies/TraktWatchedMovie.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktWatchedMovie: Codable, Hashable { +public struct TraktWatchedMovie: TraktObject { // Extended: Min public let plays: Int // Total number of plays public let lastWatchedAt: Date diff --git a/Common/Models/People/CastAndCrew.swift b/Common/Models/People/CastAndCrew.swift index 1ea9013..a763078 100644 --- a/Common/Models/People/CastAndCrew.swift +++ b/Common/Models/People/CastAndCrew.swift @@ -8,7 +8,7 @@ import Foundation -public struct CastAndCrew: Hashable { +public struct CastAndCrew: TraktObject { public let cast: [Cast]? public let guestStars: [Cast]? public let directors: [Crew]? @@ -45,7 +45,7 @@ public struct CastAndCrew: H } } -extension CastAndCrew: Decodable { +extension CastAndCrew { public init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) cast = try values.decodeIfPresent([Cast].self, forKey: .cast) @@ -64,9 +64,7 @@ extension CastAndCrew: Decodable { crew = try crewContainer?.decodeIfPresent([Crew].self, forKey: .crew) lighting = try crewContainer?.decodeIfPresent([Crew].self, forKey: .lighting) } -} -extension CastAndCrew: Encodable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(cast, forKey: .cast) diff --git a/Common/Models/People/Person.swift b/Common/Models/People/Person.swift index 25e9e71..cef0e2f 100644 --- a/Common/Models/People/Person.swift +++ b/Common/Models/People/Person.swift @@ -8,7 +8,7 @@ import Foundation // Actor/Actress/Crew member -public struct Person: Codable, Hashable { +public struct Person: TraktObject { // Extended: Min public let name: String public let ids: ID diff --git a/Common/Models/People/TraktCastMember.swift b/Common/Models/People/TraktCastMember.swift index de1f6d9..2e4667f 100644 --- a/Common/Models/People/TraktCastMember.swift +++ b/Common/Models/People/TraktCastMember.swift @@ -9,7 +9,7 @@ import Foundation /// Cast member for (show/season/episode)/.../people API -public struct TVCastMember: Codable, Hashable { +public struct TVCastMember: TraktObject { public let characters: [String] @available(*, deprecated, renamed: "characters") public let character: String @@ -26,7 +26,7 @@ public struct TVCastMember: Codable, Hashable { } /// Cast member for /movies/.../people API -public struct MovieCastMember: Codable, Hashable { +public struct MovieCastMember: TraktObject { public let characters: [String] @available(*, deprecated, renamed: "characters") public let character: String @@ -40,7 +40,7 @@ public struct MovieCastMember: Codable, Hashable { } /// Cast member for /people/.../shows API -public struct PeopleTVCastMember: Codable, Hashable { +public struct PeopleTVCastMember: TraktObject { public let characters: [String] @available(*, deprecated, renamed: "characters") public let character: String @@ -58,7 +58,7 @@ public struct PeopleTVCastMember: Codable, Hashable { } /// Cast member for /people/.../movies API -public struct PeopleMovieCastMember: Codable, Hashable { +public struct PeopleMovieCastMember: TraktObject { public let characters: [String] @available(*, deprecated, renamed: "characters") public let character: String diff --git a/Common/Models/People/TraktCrewMember.swift b/Common/Models/People/TraktCrewMember.swift index 796752f..4d8eff5 100644 --- a/Common/Models/People/TraktCrewMember.swift +++ b/Common/Models/People/TraktCrewMember.swift @@ -9,7 +9,7 @@ import Foundation /// Cast member for (show/season/episode)/people API -public struct TVCrewMember: Codable, Hashable { +public struct TVCrewMember: TraktObject { public let jobs: [String] @available(*, deprecated, renamed: "jobs") public let job: String @@ -26,7 +26,7 @@ public struct TVCrewMember: Codable, Hashable { } /// Cast member for /movies/.../people API -public struct MovieCrewMember: Codable, Hashable { +public struct MovieCrewMember: TraktObject { public let jobs: [String] @available(*, deprecated, renamed: "jobs") public let job: String @@ -40,7 +40,7 @@ public struct MovieCrewMember: Codable, Hashable { } /// Cast member for /people/.../shows API -public struct PeopleTVCrewMember: Codable, Hashable { +public struct PeopleTVCrewMember: TraktObject { public let jobs: [String] @available(*, deprecated, renamed: "jobs") public let job: String @@ -57,7 +57,7 @@ public struct PeopleTVCrewMember: Codable, Hashable { /// Cast member for /people/.../movies API -public struct PeopleMovieCrewMember: Codable, Hashable { +public struct PeopleMovieCrewMember: TraktObject { public let jobs: [String] @available(*, deprecated, renamed: "jobs") public let job: String diff --git a/Common/Models/Scrobbble/ScrobbleResult.swift b/Common/Models/Scrobbble/ScrobbleResult.swift index e27a7ae..25b453c 100644 --- a/Common/Models/Scrobbble/ScrobbleResult.swift +++ b/Common/Models/Scrobbble/ScrobbleResult.swift @@ -8,7 +8,7 @@ import Foundation -public struct ScrobbleResult: Codable, Hashable { +public struct ScrobbleResult: TraktObject { public let id: Int public let action: String public let progress: Float diff --git a/Common/Models/Shared/Alias.swift b/Common/Models/Shared/Alias.swift index 8a2b2b0..665674d 100644 --- a/Common/Models/Shared/Alias.swift +++ b/Common/Models/Shared/Alias.swift @@ -8,7 +8,7 @@ import Foundation -public struct Alias: Codable, Hashable { +public struct Alias: TraktObject { public let title: String public let country: String } diff --git a/Common/Models/Shared/RatingDistribution.swift b/Common/Models/Shared/RatingDistribution.swift index 108e4f0..e3c733a 100644 --- a/Common/Models/Shared/RatingDistribution.swift +++ b/Common/Models/Shared/RatingDistribution.swift @@ -8,12 +8,12 @@ import Foundation -public struct RatingDistribution: Codable, Hashable { +public struct RatingDistribution: TraktObject { public let rating: Double public let votes: Int public let distribution: Distribution - public struct Distribution: Codable, Hashable { + public struct Distribution: TraktObject { public let one: Int public let two: Int public let three: Int diff --git a/Common/Models/Shared/Update.swift b/Common/Models/Shared/Update.swift index 01827af..e89d8e6 100644 --- a/Common/Models/Shared/Update.swift +++ b/Common/Models/Shared/Update.swift @@ -8,7 +8,7 @@ import Foundation -public struct Update: Codable, Hashable { +public struct Update: TraktObject { public let updatedAt: Date public let movie: TraktMovie? public let show: TraktShow? diff --git a/Common/Models/Shows/ShowCollectionProgress.swift b/Common/Models/Shows/ShowCollectionProgress.swift index 4c38ed5..94bb40a 100644 --- a/Common/Models/Shows/ShowCollectionProgress.swift +++ b/Common/Models/Shows/ShowCollectionProgress.swift @@ -8,7 +8,7 @@ import Foundation -public struct ShowCollectionProgress: Codable, Hashable { +public struct ShowCollectionProgress: TraktObject { public let aired: Int public let completed: Int @@ -26,14 +26,14 @@ public struct ShowCollectionProgress: Codable, Hashable { case nextEpisode = "next_episode" } - public struct CollectedSeason: Codable, Hashable { + public struct CollectedSeason: TraktObject { public let number: Int public let aired: Int public let completed: Int public let episodes: [CollectedEpisode] } - public struct CollectedEpisode: Codable, Hashable { + public struct CollectedEpisode: TraktObject { public let number: Int public let completed: Bool public let collectedAt: Date? diff --git a/Common/Models/Shows/TraktAnticipatedShow.swift b/Common/Models/Shows/TraktAnticipatedShow.swift index e0a06ff..5836222 100644 --- a/Common/Models/Shows/TraktAnticipatedShow.swift +++ b/Common/Models/Shows/TraktAnticipatedShow.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktAnticipatedShow: Codable, Hashable { +public struct TraktAnticipatedShow: TraktObject { // Extended: Min public let listCount: Int diff --git a/Common/Models/Shows/TraktEpisode.swift b/Common/Models/Shows/TraktEpisode.swift index ac5065b..27216c4 100644 --- a/Common/Models/Shows/TraktEpisode.swift +++ b/Common/Models/Shows/TraktEpisode.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktEpisode: Codable, Hashable { +public struct TraktEpisode: TraktObject { // Extended: Min public let season: Int @@ -26,7 +26,9 @@ public struct TraktEpisode: Codable, Hashable { public let absoluteNumber: Int? public let runtime: Int? public let commentCount: Int? - + /// When getting full extended info, the `episodeType` field can have a value of `standard`, `series_premiere` (season 1, episode 1), `season_premiere` (episode 1), `mid_season_finale`,` mid_season_premiere` (the next episode after the mid season finale), `season_finale`, or `series_finale` (last episode to air for an ended show). + public let episodeType: String? + enum CodingKeys: String, CodingKey { case season case number @@ -42,9 +44,25 @@ public struct TraktEpisode: Codable, Hashable { case absoluteNumber = "number_abs" case runtime case commentCount = "comment_count" + case episodeType = "episode_type" } - public init(season: Int, number: Int, title: String? = nil, ids: EpisodeId, overview: String? = nil, rating: Double? = nil, votes: Int? = nil, firstAired: Date? = nil, updatedAt: Date? = nil, availableTranslations: [String]? = nil, absoluteNumber: Int? = nil, runtime: Int? = nil, commentCount: Int? = nil) { + public init( + season: Int, + number: Int, + title: String? = nil, + ids: EpisodeId, + overview: String? = nil, + rating: Double? = nil, + votes: Int? = nil, + firstAired: Date? = nil, + updatedAt: Date? = nil, + availableTranslations: [String]? = nil, + absoluteNumber: Int? = nil, + runtime: Int? = nil, + commentCount: Int? = nil, + episodeType: String? = nil + ) { self.season = season self.number = number self.title = title @@ -56,7 +74,8 @@ public struct TraktEpisode: Codable, Hashable { self.updatedAt = updatedAt self.availableTranslations = availableTranslations self.absoluteNumber = absoluteNumber - self.runtime = runtime self.commentCount = commentCount + self.runtime = runtime + self.episodeType = episodeType } } diff --git a/Common/Models/Shows/TraktEpisodeTranslation.swift b/Common/Models/Shows/TraktEpisodeTranslation.swift index 121edbc..ececd8c 100644 --- a/Common/Models/Shows/TraktEpisodeTranslation.swift +++ b/Common/Models/Shows/TraktEpisodeTranslation.swift @@ -8,8 +8,15 @@ import Foundation -public struct TraktEpisodeTranslation: Codable, Hashable { +public struct TraktEpisodeTranslation: TraktObject { public let title: String public let overview: String public let language: String } + +public struct TraktSeasonTranslation: TraktObject { + public let title: String + public let overview: String + public let language: String + public let country: String +} diff --git a/Common/Models/Shows/TraktMostShow.swift b/Common/Models/Shows/TraktMostShow.swift index a649873..bb1f4ee 100644 --- a/Common/Models/Shows/TraktMostShow.swift +++ b/Common/Models/Shows/TraktMostShow.swift @@ -9,7 +9,7 @@ import Foundation /// Used for most played, watched, and collected shows -public struct TraktMostShow: Codable, Hashable { +public struct TraktMostShow: TraktObject { // Extended: Min public let watcherCount: Int diff --git a/Common/Models/Shows/TraktSeason.swift b/Common/Models/Shows/TraktSeason.swift index e5ba92a..2248136 100644 --- a/Common/Models/Shows/TraktSeason.swift +++ b/Common/Models/Shows/TraktSeason.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktSeason: Codable, Hashable { +public struct TraktSeason: TraktObject { // Extended: Min public let number: Int diff --git a/Common/Models/Shows/TraktShow.swift b/Common/Models/Shows/TraktShow.swift index 5f19227..d66f379 100644 --- a/Common/Models/Shows/TraktShow.swift +++ b/Common/Models/Shows/TraktShow.swift @@ -8,7 +8,7 @@ import Foundation -public struct Airs: Codable, Hashable { +public struct Airs: TraktObject { public let day: String? public let time: String? public let timezone: String? @@ -20,7 +20,7 @@ public struct Airs: Codable, Hashable { } } -public struct TraktShow: Codable, Hashable { +public struct TraktShow: TraktObject { // Extended: Min public let title: String diff --git a/Common/Models/Shows/TraktShowTranslation.swift b/Common/Models/Shows/TraktShowTranslation.swift index dc0af9e..1ace1c8 100644 --- a/Common/Models/Shows/TraktShowTranslation.swift +++ b/Common/Models/Shows/TraktShowTranslation.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktShowTranslation: Codable, Hashable { +public struct TraktShowTranslation: TraktObject { public let title: String? public let overview: String public let language: String diff --git a/Common/Models/Shows/TraktTrendingShow.swift b/Common/Models/Shows/TraktTrendingShow.swift index 7c389e1..f272183 100644 --- a/Common/Models/Shows/TraktTrendingShow.swift +++ b/Common/Models/Shows/TraktTrendingShow.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktTrendingShow: Codable, Hashable { +public struct TraktTrendingShow: TraktObject { // Extended: Min public let watchers: Int diff --git a/Common/Models/Shows/TraktWatchedEpisodes.swift b/Common/Models/Shows/TraktWatchedEpisodes.swift index 3e6e59d..625ce50 100644 --- a/Common/Models/Shows/TraktWatchedEpisodes.swift +++ b/Common/Models/Shows/TraktWatchedEpisodes.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktWatchedEpisodes: Codable, Hashable { +public struct TraktWatchedEpisodes: TraktObject { // Extended: Min public let number: Int public let plays: Int diff --git a/Common/Models/Shows/TraktWatchedProgress.swift b/Common/Models/Shows/TraktWatchedProgress.swift index c565a87..b5827bf 100644 --- a/Common/Models/Shows/TraktWatchedProgress.swift +++ b/Common/Models/Shows/TraktWatchedProgress.swift @@ -9,7 +9,7 @@ import Foundation /// Watched progress. Shows/Progress/Watched -public struct TraktShowWatchedProgress: Codable, Hashable { +public struct TraktShowWatchedProgress: TraktObject { // Extended: Min /// Number of episodes that have aired @@ -30,7 +30,7 @@ public struct TraktShowWatchedProgress: Codable, Hashable { } } -public struct TraktSeasonWatchedProgress: Codable, Hashable { +public struct TraktSeasonWatchedProgress: TraktObject { // Extended: Min /// Season number @@ -42,7 +42,7 @@ public struct TraktSeasonWatchedProgress: Codable, Hashable { public let episodes: [TraktEpisodeWatchedProgress] } -public struct TraktEpisodeWatchedProgress: Codable, Hashable { +public struct TraktEpisodeWatchedProgress: TraktObject { // Extended: Min /// Season number diff --git a/Common/Models/Shows/TraktWatchedSeason.swift b/Common/Models/Shows/TraktWatchedSeason.swift index acd9213..68b2a10 100644 --- a/Common/Models/Shows/TraktWatchedSeason.swift +++ b/Common/Models/Shows/TraktWatchedSeason.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktWatchedSeason: Codable, Hashable { +public struct TraktWatchedSeason: TraktObject { // Extended: Min public let number: Int // Season number diff --git a/Common/Models/Shows/TraktWatchedShow.swift b/Common/Models/Shows/TraktWatchedShow.swift index 9cd71b7..4d75afb 100644 --- a/Common/Models/Shows/TraktWatchedShow.swift +++ b/Common/Models/Shows/TraktWatchedShow.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktWatchedShow: Codable, Hashable { +public struct TraktWatchedShow: TraktObject { // Extended: Min public let plays: Int // Total number of plays diff --git a/Common/Models/Structures.swift b/Common/Models/Structures.swift index 6a680f5..b6dda4d 100644 --- a/Common/Models/Structures.swift +++ b/Common/Models/Structures.swift @@ -12,7 +12,7 @@ public typealias RawJSON = [String: Any] // Dictionary // MARK: - TV & Movies -public struct ID: Codable, Hashable { +public struct ID: TraktObject { public let trakt: Int public let slug: String @@ -40,8 +40,8 @@ public struct ID: Codable, Hashable { } } -public struct SeasonId: Codable, Hashable { - +public struct SeasonId: TraktObject { + public let trakt: Int public let tvdb: Int? public let tmdb: Int? @@ -62,7 +62,7 @@ public struct SeasonId: Codable, Hashable { } } -public struct EpisodeId: Codable, Hashable { +public struct EpisodeId: TraktObject { public let trakt: Int public let tvdb: Int? public let imdb: String? @@ -86,7 +86,7 @@ public struct EpisodeId: Codable, Hashable { } } -public struct ListId: Codable, Hashable { +public struct ListId: TraktObject { public let trakt: Int public let slug: String @@ -103,7 +103,7 @@ public struct ListId: Codable, Hashable { // MARK: - Stats -public struct TraktStats: Codable, Hashable { +public struct TraktStats: TraktObject { public let watchers: Int public let plays: Int public let collectors: Int @@ -125,7 +125,7 @@ public struct TraktStats: Codable, Hashable { // MARK: - Last Activities -public struct TraktLastActivities: Codable, Hashable { +public struct TraktLastActivities: TraktObject { public let all: Date public let movies: TraktLastActivityMovies public let episodes: TraktLastActivityEpisodes @@ -135,7 +135,7 @@ public struct TraktLastActivities: Codable, Hashable { public let lists: TraktLastActivityLists } -public struct TraktLastActivityMovies: Codable, Hashable { +public struct TraktLastActivityMovies: TraktObject { public let watchedAt: Date public let collectedAt: Date public let ratedAt: Date @@ -155,7 +155,7 @@ public struct TraktLastActivityMovies: Codable, Hashable { } } -public struct TraktLastActivityEpisodes: Codable, Hashable { +public struct TraktLastActivityEpisodes: TraktObject { public let watchedAt: Date public let collectedAt: Date public let ratedAt: Date @@ -173,7 +173,7 @@ public struct TraktLastActivityEpisodes: Codable, Hashable { } } -public struct TraktLastActivityShows: Codable, Hashable { +public struct TraktLastActivityShows: TraktObject { public let ratedAt: Date public let watchlistedAt: Date public let commentedAt: Date @@ -187,7 +187,7 @@ public struct TraktLastActivityShows: Codable, Hashable { } } -public struct TraktLastActivitySeasons: Codable, Hashable { +public struct TraktLastActivitySeasons: TraktObject { public let ratedAt: Date public let watchlistedAt: Date public let commentedAt: Date @@ -201,7 +201,7 @@ public struct TraktLastActivitySeasons: Codable, Hashable { } } -public struct TraktLastActivityComments: Codable, Hashable { +public struct TraktLastActivityComments: TraktObject { public let likedAt: Date enum CodingKeys: String, CodingKey { @@ -209,7 +209,7 @@ public struct TraktLastActivityComments: Codable, Hashable { } } -public struct TraktLastActivityLists: Codable, Hashable { +public struct TraktLastActivityLists: TraktObject { public let likedAt: Date public let updatedAt: Date public let commentedAt: Date diff --git a/Common/Models/Sync/AddRatingsResult.swift b/Common/Models/Sync/AddRatingsResult.swift index e8f4fac..7f3de69 100644 --- a/Common/Models/Sync/AddRatingsResult.swift +++ b/Common/Models/Sync/AddRatingsResult.swift @@ -8,18 +8,18 @@ import Foundation -public struct AddRatingsResult: Codable, Hashable { +public struct AddRatingsResult: TraktObject { public let added: Added public let notFound: NotFound - public struct Added: Codable, Hashable { + public struct Added: TraktObject { public let movies: Int public let shows: Int public let seasons: Int public let episodes: Int } - public struct NotFound: Codable, Hashable { + public struct NotFound: TraktObject { public let movies: [NotFoundIds] public let shows: [NotFoundIds] public let seasons: [NotFoundIds] diff --git a/Common/Models/Sync/AddToCollectionResult.swift b/Common/Models/Sync/AddToCollectionResult.swift index 88eb773..a94b378 100644 --- a/Common/Models/Sync/AddToCollectionResult.swift +++ b/Common/Models/Sync/AddToCollectionResult.swift @@ -8,18 +8,18 @@ import Foundation -public struct AddToCollectionResult: Codable, Hashable { +public struct AddToCollectionResult: TraktObject { public let added: Added public let updated: Added public let existing: Added public let notFound: NotFound - public struct Added: Codable, Hashable { + public struct Added: TraktObject { public let movies: Int public let episodes: Int } - public struct NotFound: Codable, Hashable { + public struct NotFound: TraktObject { public let movies: [NotFoundIds] public let shows: [NotFoundIds] public let seasons: [NotFoundIds] diff --git a/Common/Models/Sync/AddToHistoryResult.swift b/Common/Models/Sync/AddToHistoryResult.swift index c7cd62d..95013e4 100644 --- a/Common/Models/Sync/AddToHistoryResult.swift +++ b/Common/Models/Sync/AddToHistoryResult.swift @@ -8,7 +8,7 @@ import Foundation -public struct NotFoundIds: Codable, Hashable { +public struct NotFoundIds: TraktObject { public let trakt: Int? public let slug: String? public let tvdb: Int? @@ -52,16 +52,16 @@ public struct NotFoundIds: Codable, Hashable { } } -public struct AddToHistoryResult: Codable, Hashable { +public struct AddToHistoryResult: TraktObject { public let added: Added public let notFound: NotFound - public struct Added: Codable, Hashable { + public struct Added: TraktObject { public let movies: Int public let episodes: Int } - public struct NotFound: Codable, Hashable { + public struct NotFound: TraktObject { public let movies: [NotFoundIds] public let shows: [NotFoundIds] public let seasons: [NotFoundIds] @@ -74,16 +74,16 @@ public struct AddToHistoryResult: Codable, Hashable { } } -public struct RemoveFromHistoryResult: Codable, Hashable { +public struct RemoveFromHistoryResult: TraktObject { public let deleted: Deleted public let notFound: NotFound - public struct Deleted: Codable, Hashable { + public struct Deleted: TraktObject { public let movies: Int public let episodes: Int } - public struct NotFound: Codable, Hashable { + public struct NotFound: TraktObject { public let movies: [NotFoundIds] public let shows: [NotFoundIds] public let seasons: [NotFoundIds] diff --git a/Common/Models/Sync/PlaybackProgress.swift b/Common/Models/Sync/PlaybackProgress.swift index b0de8fe..2a25adf 100644 --- a/Common/Models/Sync/PlaybackProgress.swift +++ b/Common/Models/Sync/PlaybackProgress.swift @@ -8,7 +8,7 @@ import Foundation -public struct PlaybackProgress: Codable, Hashable { +public struct PlaybackProgress: TraktObject { public let progress: Float public let pausedAt: Date public let id: Int diff --git a/Common/Models/Sync/RemoveFromCollectionResult.swift b/Common/Models/Sync/RemoveFromCollectionResult.swift index 94227f9..acf7caf 100644 --- a/Common/Models/Sync/RemoveFromCollectionResult.swift +++ b/Common/Models/Sync/RemoveFromCollectionResult.swift @@ -8,16 +8,16 @@ import Foundation -public struct RemoveFromCollectionResult: Codable, Hashable { +public struct RemoveFromCollectionResult: TraktObject { public let deleted: deleted public let notFound: NotFound - public struct deleted: Codable, Hashable { + public struct deleted: TraktObject { public let movies: Int public let episodes: Int } - public struct NotFound: Codable, Hashable { + public struct NotFound: TraktObject { public let movies: [NotFoundIds] public let shows: [NotFoundIds] public let seasons: [NotFoundIds] diff --git a/Common/Models/Sync/RemoveFromWatchlistResult.swift b/Common/Models/Sync/RemoveFromWatchlistResult.swift index 53b27d4..44bea76 100644 --- a/Common/Models/Sync/RemoveFromWatchlistResult.swift +++ b/Common/Models/Sync/RemoveFromWatchlistResult.swift @@ -8,18 +8,18 @@ import Foundation -public struct RemoveFromWatchlistResult: Codable, Hashable { +public struct RemoveFromWatchlistResult: TraktObject { public let deleted: Deleted public let notFound: NotFound - public struct Deleted: Codable, Hashable { + public struct Deleted: TraktObject { public let movies: Int public let shows: Int public let seasons: Int public let episodes: Int } - public struct NotFound: Codable, Hashable { + public struct NotFound: TraktObject { public let movies: [NotFoundIds] public let shows: [NotFoundIds] public let seasons: [NotFoundIds] diff --git a/Common/Models/Sync/RemoveRatingsResult.swift b/Common/Models/Sync/RemoveRatingsResult.swift index e127728..4ad0e80 100644 --- a/Common/Models/Sync/RemoveRatingsResult.swift +++ b/Common/Models/Sync/RemoveRatingsResult.swift @@ -8,18 +8,18 @@ import Foundation -public struct RemoveRatingsResult: Codable, Hashable { +public struct RemoveRatingsResult: TraktObject { public let deleted: Deleted public let notFound: NotFound - public struct Deleted: Codable, Hashable { + public struct Deleted: TraktObject { public let movies: Int public let shows: Int public let seasons: Int public let episodes: Int } - public struct NotFound: Codable, Hashable { + public struct NotFound: TraktObject { public let movies: [NotFoundIds] public let shows: [NotFoundIds] public let seasons: [NotFoundIds] diff --git a/Common/Models/Sync/TraktCollectedItem.swift b/Common/Models/Sync/TraktCollectedItem.swift index 8772954..ae8f257 100644 --- a/Common/Models/Sync/TraktCollectedItem.swift +++ b/Common/Models/Sync/TraktCollectedItem.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktCollectedItem: Codable, Hashable { +public struct TraktCollectedItem: TraktObject { public var lastCollectedAt: Date public let lastUpdatedAt: Date @@ -64,7 +64,7 @@ public struct TraktCollectedItem: Codable, Hashable { } } - public struct Metadata: Codable, Hashable { + public struct Metadata: TraktObject { public let mediaType: MediaType? public let resolution: Resolution? public let hdr: HDR? @@ -93,7 +93,7 @@ public struct TraktCollectedItem: Codable, Hashable { } } - public enum MediaType: String, Codable { + public enum MediaType: String, TraktObject { case digital case bluray case hdDVD = "hddvd" @@ -104,7 +104,7 @@ public struct TraktCollectedItem: Codable, Hashable { case videoCD = "vcd" } - public enum Resolution: String, Codable { + public enum Resolution: String, TraktObject { case udh4k = "uhd_4k" case hd1080p = "hd_1080p" case hd1080i = "hd_1080i" @@ -115,14 +115,14 @@ public struct TraktCollectedItem: Codable, Hashable { case sd576i = "sd_576i" } - public enum HDR: String, Codable { + public enum HDR: String, TraktObject { case dolbyVision = "dolby_vision" case hdr10 = "hdr10" case hdr10Plus = "hdr10_plus" case hlg } - public enum Audio: String, Codable { + public enum Audio: String, TraktObject { case dolbyDigital = "dolby_digital" case dolbyDigitalPlus = "dolby_digital_plus" case dolbyDigitalPlusAtmos = "dolby_digital_plus_atmos" @@ -144,7 +144,7 @@ public struct TraktCollectedItem: Codable, Hashable { case flac } - public enum AudioChannels: String, Codable { + public enum AudioChannels: String, TraktObject { case tenOne = "10.1" case nineOne = "9.1" case sevenOneFour = "7.1.4" @@ -165,14 +165,14 @@ public struct TraktCollectedItem: Codable, Hashable { } } -public struct TraktCollectedSeason: Codable, Hashable { +public struct TraktCollectedSeason: TraktObject { /// Season number public var number: Int public var episodes: [TraktCollectedEpisode] } -public struct TraktCollectedEpisode: Codable, Hashable { +public struct TraktCollectedEpisode: TraktObject { public var number: Int public var collectedAt: Date diff --git a/Common/Models/Sync/TraktHistoryItem.swift b/Common/Models/Sync/TraktHistoryItem.swift index d6b9276..a7a2958 100644 --- a/Common/Models/Sync/TraktHistoryItem.swift +++ b/Common/Models/Sync/TraktHistoryItem.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktHistoryItem: Codable, Hashable { +public struct TraktHistoryItem: TraktObject { public var id: Int public var watchedAt: Date diff --git a/Common/Models/Sync/TraktRating.swift b/Common/Models/Sync/TraktRating.swift index 0fe3674..210e6e5 100644 --- a/Common/Models/Sync/TraktRating.swift +++ b/Common/Models/Sync/TraktRating.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktRating: Codable, Hashable { +public struct TraktRating: TraktObject { public var ratedAt: Date public var rating: Int diff --git a/Common/Models/TraktSearchResult.swift b/Common/Models/TraktSearchResult.swift index ef88ce6..5fc230d 100644 --- a/Common/Models/TraktSearchResult.swift +++ b/Common/Models/TraktSearchResult.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktSearchResult: Codable, Hashable { +public struct TraktSearchResult: TraktObject { public let type: String // Can be movie, show, episode, person, list public let score: Double? diff --git a/Common/Models/TraktVideo.swift b/Common/Models/TraktVideo.swift new file mode 100644 index 0000000..208f156 --- /dev/null +++ b/Common/Models/TraktVideo.swift @@ -0,0 +1,32 @@ +// +// TraktVideo.swift +// TraktKit +// +// Created by Maximilian Litteral on 2/9/25. +// + +import Foundation + +public struct TraktVideo: TraktObject { + public let title: String + public let url: String + public let site: String + public let type: String + public let size: Int + public let official: Bool + public let publishedAt: Date + public let country: String + public let language: String + + enum CodingKeys: String, CodingKey { + case title + case url + case site + case type + case size + case official + case publishedAt = "published_at" + case country + case language + } +} diff --git a/Common/Models/Users/AccountSettings.swift b/Common/Models/Users/AccountSettings.swift index 59428ea..896b218 100644 --- a/Common/Models/Users/AccountSettings.swift +++ b/Common/Models/Users/AccountSettings.swift @@ -8,16 +8,101 @@ import Foundation -public struct AccountSettings: Codable, Hashable { +public struct AccountSettings: TraktObject { public let user: User public let connections: Connections - - public struct Connections: Codable, Hashable { + public let sharingText: SharingText + public let limits: Limits + + enum CodingKeys: String, CodingKey { + case user + case connections + case sharingText = "sharing_text" + case limits + } + + public struct Connections: TraktObject { public let facebook: Bool public let twitter: Bool + public let mastodon: Bool public let google: Bool public let tumblr: Bool public let medium: Bool public let slack: Bool + public let apple: Bool + public let dropbox: Bool + public let microsoft: Bool + } + + public struct SharingText: TraktObject { + public let watching: String + public let watched: String + public let rated: String + } + + public struct Limits: TraktObject { + public let list: List + public let watchlist: Watchlist + public let favorites: Favorites + public let search: Search + public let collection: Collection + public let notes: Notes + + public struct List: TraktObject { + /// Total lists + public let count: Int + /// Item per list + public let itemCount: Int + + enum CodingKeys: String, CodingKey { + case count + case itemCount = "item_count" + } + } + + public struct Watchlist: TraktObject { + /// Number of items that can be added to the watchlist + public let itemCount: Int + + enum CodingKeys: String, CodingKey { + case itemCount = "item_count" + } + } + + public struct Favorites: TraktObject { + /// Number of items that can be favorited. + public let itemCount: Int + + enum CodingKeys: String, CodingKey { + case itemCount = "item_count" + } + } + + public struct Search: TraktObject { + /// Number of saved recent searches + public let recentCount: Int + + enum CodingKeys: String, CodingKey { + case recentCount = "recent_count" + } + } + + public struct Collection: TraktObject { + /// Number of items that can be collected. + public let itemCount: Int + + enum CodingKeys: String, CodingKey { + case itemCount = "item_count" + } + } + + public struct Notes: TraktObject { + /// Number of items that can have a personal note. + public let itemCount: Int + + enum CodingKeys: String, CodingKey { + case itemCount = "item_count" + } + } } } diff --git a/Common/Models/Users/FollowRequest.swift b/Common/Models/Users/FollowRequest.swift index 5f414db..665b5a3 100644 --- a/Common/Models/Users/FollowRequest.swift +++ b/Common/Models/Users/FollowRequest.swift @@ -8,7 +8,7 @@ import Foundation -public struct FollowRequest: Codable, Hashable { +public struct FollowRequest: TraktObject { public let id: Int public let requestedAt: Date public let user: User @@ -20,7 +20,7 @@ public struct FollowRequest: Codable, Hashable { } } -public struct FollowResult: Codable, Hashable { +public struct FollowResult: TraktObject { public let followedAt: Date public let user: User diff --git a/Common/Models/Users/FollowUserResult.swift b/Common/Models/Users/FollowUserResult.swift index bb662ad..6598c3b 100644 --- a/Common/Models/Users/FollowUserResult.swift +++ b/Common/Models/Users/FollowUserResult.swift @@ -8,7 +8,7 @@ import Foundation -public struct FollowUserResult: Codable, Hashable { +public struct FollowUserResult: TraktObject { public let approvedAt: Date public let user: User diff --git a/Common/Models/Users/Friend.swift b/Common/Models/Users/Friend.swift index b80ce24..34add95 100644 --- a/Common/Models/Users/Friend.swift +++ b/Common/Models/Users/Friend.swift @@ -8,7 +8,7 @@ import Foundation -public struct Friend: Codable, Hashable { +public struct Friend: TraktObject { public let friendsAt: Date public let user: User diff --git a/Common/Models/Users/HiddenItem.swift b/Common/Models/Users/HiddenItem.swift index 0d2f47c..6e9c821 100644 --- a/Common/Models/Users/HiddenItem.swift +++ b/Common/Models/Users/HiddenItem.swift @@ -8,7 +8,7 @@ import Foundation -public struct HiddenItem: Codable, Hashable { +public struct HiddenItem: TraktObject { public let hiddenAt: Date public let type: String diff --git a/Common/Models/Users/HideItemResult.swift b/Common/Models/Users/HideItemResult.swift index 7906eb9..f20bcb9 100644 --- a/Common/Models/Users/HideItemResult.swift +++ b/Common/Models/Users/HideItemResult.swift @@ -8,17 +8,17 @@ import Foundation -public struct HideItemResult: Codable, Hashable { +public struct HideItemResult: TraktObject { public let added: Added public let notFound: NotFound - public struct Added: Codable, Hashable { + public struct Added: TraktObject { public let movies: Int public let shows: Int public let seasons: Int } - public struct NotFound: Codable, Hashable { + public struct NotFound: TraktObject { public let movies: [NotFoundIds] public let shows: [NotFoundIds] public let seasons: [NotFoundIds] diff --git a/Common/Models/Users/Likes.swift b/Common/Models/Users/Likes.swift index 8a4b3e5..0411def 100644 --- a/Common/Models/Users/Likes.swift +++ b/Common/Models/Users/Likes.swift @@ -8,13 +8,13 @@ import Foundation -public struct Like: Codable, Hashable { +public struct Like: TraktObject { public let likedAt: Date public let type: LikeType public let list: TraktList? public let comment: Comment? - public enum LikeType: String, Codable { + public enum LikeType: String, TraktObject { case comment case list } diff --git a/Common/Models/Users/ListItemPostResult.swift b/Common/Models/Users/ListItemPostResult.swift index 4fb46c3..eea305d 100644 --- a/Common/Models/Users/ListItemPostResult.swift +++ b/Common/Models/Users/ListItemPostResult.swift @@ -8,12 +8,12 @@ import Foundation -public struct ListItemPostResult: Codable, Hashable { +public struct ListItemPostResult: TraktObject { public let added: ObjectCount public let existing: ObjectCount public let notFound: NotFound - public struct ObjectCount: Codable, Hashable { + public struct ObjectCount: TraktObject { public let movies: Int public let shows: Int public let seasons: Int @@ -21,7 +21,7 @@ public struct ListItemPostResult: Codable, Hashable { public let people: Int } - public struct NotFound: Codable, Hashable { + public struct NotFound: TraktObject { public let movies: [NotFoundIds] public let shows: [NotFoundIds] public let seasons: [NotFoundIds] @@ -36,19 +36,19 @@ public struct ListItemPostResult: Codable, Hashable { } } -public struct WatchlistItemPostResult: Codable, Hashable { +public struct WatchlistItemPostResult: TraktObject { public let added: ObjectCount public let existing: ObjectCount public let notFound: NotFound - public struct ObjectCount: Codable, Hashable { + public struct ObjectCount: TraktObject { public let movies: Int public let shows: Int public let seasons: Int public let episodes: Int } - public struct NotFound: Codable, Hashable { + public struct NotFound: TraktObject { public let movies: [NotFoundIds] public let shows: [NotFoundIds] public let seasons: [NotFoundIds] diff --git a/Common/Models/Users/RemoveListItemResult.swift b/Common/Models/Users/RemoveListItemResult.swift index 822cc36..4dcf3e4 100644 --- a/Common/Models/Users/RemoveListItemResult.swift +++ b/Common/Models/Users/RemoveListItemResult.swift @@ -8,11 +8,11 @@ import Foundation -public struct RemoveListItemResult: Codable, Hashable { +public struct RemoveListItemResult: TraktObject { public let deleted: Deleted public let notFound: NotFound - public struct Deleted: Codable, Hashable { + public struct Deleted: TraktObject { public let movies: Int public let shows: Int public let seasons: Int @@ -20,7 +20,7 @@ public struct RemoveListItemResult: Codable, Hashable { public let people: Int } - public struct NotFound: Codable, Hashable { + public struct NotFound: TraktObject { public let movies: [NotFoundIds] public let shows: [NotFoundIds] public let seasons: [NotFoundIds] diff --git a/Common/Models/Users/TraktList.swift b/Common/Models/Users/TraktList.swift index ef5876f..35817d0 100644 --- a/Common/Models/Users/TraktList.swift +++ b/Common/Models/Users/TraktList.swift @@ -8,13 +8,13 @@ import Foundation -public enum ListPrivacy: String, Codable { +public enum ListPrivacy: String, TraktObject { case `private` case friends case `public` } -public struct TraktList: Codable, Hashable { +public struct TraktList: TraktObject { public let allowComments: Bool public let commentCount: Int public let createdAt: Date? @@ -42,7 +42,7 @@ public struct TraktList: Codable, Hashable { } } -public struct TraktTrendingList: Codable, Hashable { +public struct TraktTrendingList: TraktObject { public let likeCount: Int public let commentCount: Int public let list: TraktList diff --git a/Common/Models/Users/TraktListItem.swift b/Common/Models/Users/TraktListItem.swift index 1c3f7c6..8682185 100644 --- a/Common/Models/Users/TraktListItem.swift +++ b/Common/Models/Users/TraktListItem.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktListItem: Codable, Hashable { +public struct TraktListItem: TraktObject { public let rank: Int public let listedAt: Date public let type: String diff --git a/Common/Models/Users/TraktUser.swift b/Common/Models/Users/TraktUser.swift index 0567e5c..555f5fe 100644 --- a/Common/Models/Users/TraktUser.swift +++ b/Common/Models/Users/TraktUser.swift @@ -8,7 +8,7 @@ import Foundation -public struct User: Codable, Hashable { +public struct User: TraktObject { // Min public let username: String? @@ -47,15 +47,15 @@ public struct User: Codable, Hashable { case vipYears = "vip_years" } - public struct IDs: Codable, Hashable { + public struct IDs: TraktObject { public let slug: String } - public struct Images: Codable, Hashable { + public struct Images: TraktObject { public let avatar: Image } - public struct Image: Codable, Hashable { + public struct Image: TraktObject { public let full: String } } diff --git a/Common/Models/Users/TraktWatchedItem.swift b/Common/Models/Users/TraktWatchedItem.swift index 3824f69..c5caea8 100644 --- a/Common/Models/Users/TraktWatchedItem.swift +++ b/Common/Models/Users/TraktWatchedItem.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktWatchedItem: Codable, Hashable { +public struct TraktWatchedItem: TraktObject { public let plays: Int public let lastWatchedAt: Date public var show: TraktShow? = nil diff --git a/Common/Models/Users/TraktWatching.swift b/Common/Models/Users/TraktWatching.swift index 3f9e1b6..6859239 100644 --- a/Common/Models/Users/TraktWatching.swift +++ b/Common/Models/Users/TraktWatching.swift @@ -8,7 +8,7 @@ import Foundation -public struct TraktWatching: Codable, Hashable { +public struct TraktWatching: TraktObject { public let expiresAt: Date public let startedAt: Date public let action: String diff --git a/Common/Models/Users/UnhideItemResult.swift b/Common/Models/Users/UnhideItemResult.swift index 2398db8..745a783 100644 --- a/Common/Models/Users/UnhideItemResult.swift +++ b/Common/Models/Users/UnhideItemResult.swift @@ -8,17 +8,17 @@ import Foundation -public struct UnhideItemResult: Codable, Hashable { +public struct UnhideItemResult: TraktObject { public let deleted: Deleted public let notFound: NotFound - public struct Deleted: Codable, Hashable { + public struct Deleted: TraktObject { public let movies: Int public let shows: Int public let seasons: Int } - public struct NotFound: Codable, Hashable { + public struct NotFound: TraktObject { public let movies: [NotFoundIds] public let shows: [NotFoundIds] public let seasons: [NotFoundIds] diff --git a/Common/Models/Users/UserStats.swift b/Common/Models/Users/UserStats.swift index f363a07..0c03081 100644 --- a/Common/Models/Users/UserStats.swift +++ b/Common/Models/Users/UserStats.swift @@ -8,7 +8,7 @@ import Foundation -public struct UserStats: Codable, Hashable { +public struct UserStats: TraktObject { public let movies: Movies public let shows: Shows public let seasons: Seasons @@ -16,7 +16,7 @@ public struct UserStats: Codable, Hashable { public let network: Network public let ratings: UserStatsRatingsDistribution - public struct Movies: Codable, Hashable { + public struct Movies: TraktObject { public let plays: Int public let watched: Int public let minutes: Int @@ -25,19 +25,19 @@ public struct UserStats: Codable, Hashable { public let comments: Int } - public struct Shows: Codable, Hashable { + public struct Shows: TraktObject { public let watched: Int public let collected: Int public let ratings: Int public let comments: Int } - public struct Seasons: Codable, Hashable { + public struct Seasons: TraktObject { public let ratings: Int public let comments: Int } - public struct Episodes: Codable, Hashable { + public struct Episodes: TraktObject { public let plays: Int public let watched: Int public let minutes: Int @@ -46,17 +46,17 @@ public struct UserStats: Codable, Hashable { public let comments: Int } - public struct Network: Codable, Hashable { + public struct Network: TraktObject { public let friends: Int public let followers: Int public let following: Int } - public struct UserStatsRatingsDistribution: Codable, Hashable { + public struct UserStatsRatingsDistribution: TraktObject { public let total: Int public let distribution: Distribution - public struct Distribution: Codable, Hashable { + public struct Distribution: TraktObject { public let one: Int public let two: Int public let three: Int diff --git a/Common/Models/Users/UsersComments.swift b/Common/Models/Users/UsersComments.swift index 9aba047..281c91d 100644 --- a/Common/Models/Users/UsersComments.swift +++ b/Common/Models/Users/UsersComments.swift @@ -8,7 +8,7 @@ import Foundation -public struct UsersComments: Codable, Hashable { +public struct UsersComments: TraktObject { public let type: String public let comment: Comment public let movie: TraktMovie? diff --git a/Common/Wrapper/CompletionHandlers.swift b/Common/Wrapper/CompletionHandlers.swift index 7ec0fcb..0ac1e1d 100644 --- a/Common/Wrapper/CompletionHandlers.swift +++ b/Common/Wrapper/CompletionHandlers.swift @@ -9,19 +9,19 @@ import Foundation /// Generic result type -public enum ObjectResultType { +public enum ObjectResultType: Sendable { case success(object: T) case error(error: Error?) } /// Generic results type -public enum ObjectsResultType { +public enum ObjectsResultType: Sendable { case success(objects: [T]) case error(error: Error?) } /// Generic results type + Pagination -public enum ObjectsResultTypePagination { +public enum ObjectsResultTypePagination: Sendable { case success(objects: [T], currentPage: Int, limit: Int) case error(error: Error?) } @@ -30,34 +30,37 @@ extension TraktManager { // MARK: - Result Types - public enum DataResultType { + public enum DataResultType: Sendable { case success(data: Data) case error(error: Error?) } - public enum SuccessResultType { + public enum SuccessResultType: Sendable { case success case fail } - public enum ProgressResultType { + public enum ProgressResultType: Sendable { case success case fail(Int) } - public enum WatchingResultType { + public enum WatchingResultType: Sendable { case checkedIn(watching: TraktWatching) case notCheckedIn case error(error: Error?) } - public enum CheckinResultType { + public enum CheckinResultType: Sendable { case success(checkin: TraktCheckinResponse) case checkedIn(expiration: Date) case error(error: Error?) } - public enum TraktError: Error { + public enum TraktError: Error, Equatable { + /// 204. Some methods will succeed but not return any content. The network manager doesn't handle this well at the moment as it wants to decode the data when it is empty. Instead I'll throw this error so that it can be ignored for now. + case noContent + /// Bad Request (400) - request couldn't be parsed case badRequest /// Oauth must be provided (401) @@ -97,16 +100,15 @@ extension TraktManager { // MARK: - Completion handlers // MARK: Common - public typealias ObjectCompletionHandler = (_ result: ObjectResultType) -> Void - public typealias ObjectsCompletionHandler = (_ result: ObjectsResultType) -> Void - public typealias paginatedCompletionHandler = (_ result: ObjectsResultTypePagination) -> Void - - public typealias DataResultCompletionHandler = (_ result: DataResultType) -> Void - public typealias SuccessCompletionHandler = (_ result: SuccessResultType) -> Void - public typealias ProgressCompletionHandler = (_ result: ProgressResultType) -> Void + public typealias ObjectCompletionHandler = @Sendable (_ result: ObjectResultType) -> Void + public typealias ObjectsCompletionHandler = @Sendable(_ result: ObjectsResultType) -> Void + public typealias paginatedCompletionHandler = @Sendable (_ result: ObjectsResultTypePagination) -> Void + + public typealias DataResultCompletionHandler = @Sendable (_ result: DataResultType) -> Void + public typealias SuccessCompletionHandler = @Sendable (_ result: SuccessResultType) -> Void + public typealias ProgressCompletionHandler = @Sendable (_ result: ProgressResultType) -> Void public typealias CommentsCompletionHandler = paginatedCompletionHandler -// public typealias CastCrewCompletionHandler = ObjectCompletionHandler - + public typealias SearchCompletionHandler = ObjectsCompletionHandler public typealias statsCompletionHandler = ObjectCompletionHandler @@ -119,8 +121,8 @@ extension TraktManager { public typealias dvdReleaseCompletionHandler = ObjectsCompletionHandler // MARK: Checkin - public typealias checkinCompletionHandler = (_ result: CheckinResultType) -> Void - + public typealias checkinCompletionHandler = @Sendable (_ result: CheckinResultType) -> Void + // MARK: Shows public typealias TrendingShowsCompletionHandler = paginatedCompletionHandler public typealias MostShowsCompletionHandler = paginatedCompletionHandler @@ -163,7 +165,7 @@ extension TraktManager { public typealias FollowUserCompletion = ObjectCompletionHandler public typealias FollowersCompletion = ObjectsCompletionHandler public typealias FriendsCompletion = ObjectsCompletionHandler - public typealias WatchingCompletion = (_ result: WatchingResultType) -> Void + public typealias WatchingCompletion = @Sendable (_ result: WatchingResultType) -> Void public typealias UserStatsCompletion = ObjectCompletionHandler public typealias UserWatchedCompletion = ObjectsCompletionHandler @@ -225,19 +227,10 @@ extension TraktManager { } // MARK: - Perform Requests - - func perform(request: URLRequest) async throws -> T { - // TODO: Call `handleResponse` for error handling and retries. - let (data, response) = try await session.data(for: request) - try await handle(response: response) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom(customDateDecodingStrategy) - return try decoder.decode(T.self, from: data) - } /// Data - func performRequest(request: URLRequest, completion: @escaping DataResultCompletionHandler) -> URLSessionDataTaskProtocol? { - let datatask = session._dataTask(with: request) { [weak self] data, response, error in + func performRequest(request: URLRequest, completion: @escaping DataResultCompletionHandler) -> URLSessionDataTask? { + let datatask = session.dataTask(with: request) { [weak self] data, response, error in guard let self else { return } if let error { completion(.error(error: error)) @@ -271,8 +264,8 @@ extension TraktManager { } /// Success / Failure - func performRequest(request: URLRequest, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { - let datatask = session._dataTask(with: request) { [weak self] data, response, error in + func performRequest(request: URLRequest, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { + let datatask = session.dataTask(with: request) { [weak self] data, response, error in guard let self else { return } guard error == nil else { completion(.fail) @@ -301,8 +294,8 @@ extension TraktManager { } /// Checkin - func performRequest(request: URLRequest, completion: @escaping checkinCompletionHandler) -> URLSessionDataTaskProtocol? { - let datatask = session._dataTask(with: request) { [weak self] data, response, error in + func performRequest(request: URLRequest, completion: @escaping checkinCompletionHandler) -> URLSessionDataTask? { + let datatask = session.dataTask(with: request) { [weak self] data, response, error in guard let self else { return } if let error { completion(.error(error: error)) @@ -351,10 +344,14 @@ extension TraktManager { } // Generic array of Trakt objects - func performRequest(request: URLRequest, completion: @escaping ((_ result: ObjectResultType) -> Void)) -> URLSessionDataTaskProtocol? { + func performRequest(request: URLRequest, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { let aCompletion: DataResultCompletionHandler = { (result) -> Void in switch result { case .success(let data): + guard !data.isEmpty else { + completion(.error(error: TraktError.noContent)) + return + } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom(customDateDecodingStrategy) do { @@ -373,8 +370,8 @@ extension TraktManager { } /// Array of TraktProtocol objects - func performRequest(request: URLRequest, completion: @escaping ((_ result: ObjectsResultType) -> Void)) -> URLSessionDataTaskProtocol? { - let dataTask = session._dataTask(with: request) { [weak self] data, response, error in + func performRequest(request: URLRequest, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + let dataTask = session.dataTask(with: request) { [weak self] data, response, error in guard let self else { return } if let error { completion(.error(error: error)) @@ -417,8 +414,8 @@ extension TraktManager { } /// Array of ObjectsResultTypePagination objects - func performRequest(request: URLRequest, completion: @escaping ((_ result: ObjectsResultTypePagination) -> Void)) -> URLSessionDataTaskProtocol? { - let dataTask = session._dataTask(with: request) { [weak self] data, response, error in + func performRequest(request: URLRequest, completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { + let dataTask = session.dataTask(with: request) { [weak self] data, response, error in guard let self else { return } if let error { completion(.error(error: error)) @@ -475,8 +472,8 @@ extension TraktManager { } // Watching - func performRequest(request: URLRequest, completion: @escaping WatchingCompletion) -> URLSessionDataTaskProtocol? { - let dataTask = session._dataTask(with: request) { [weak self] data, response, error in + func performRequest(request: URLRequest, completion: @escaping WatchingCompletion) -> URLSessionDataTask? { + let dataTask = session.dataTask(with: request) { [weak self] data, response, error in guard let self else { return } if let error { completion(.error(error: error)) @@ -523,4 +520,32 @@ extension TraktManager { dataTask.resume() return dataTask } + + // MARK: - Async await + + func fetchData(request: URLRequest) async throws -> (Data, URLResponse) { + let (data, response) = try await session.data(for: request) + try await handle(response: response) + return (data, response) + } + + func perform(request: URLRequest) async throws -> T { + let (data, response) = try await session.data(for: request) + try await handle(response: response) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom(customDateDecodingStrategy) + + if let pagedType = T.self as? PagedObjectProtocol.Type { + let decodedItems = try decoder.decode(pagedType.objectType, from: data) + var currentPage = 0 + var pageCount = 0 + if let r = response as? HTTPURLResponse { + currentPage = Int(r.value(forHTTPHeaderField: "x-pagination-page") ?? "0") ?? 0 + pageCount = Int(r.value(forHTTPHeaderField: "x-pagination-page-count") ?? "0") ?? 0 + } + return pagedType.createPagedObject(with: decodedItems, currentPage: currentPage, pageCount: pageCount) as! T + } + + return try decoder.decode(T.self, from: data) + } } From 534eaf65a1beed41235f67e5d6cbfa9f7b153463 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Tue, 18 Feb 2025 22:11:46 -0500 Subject: [PATCH 03/38] Replace URLSession mocking with URLProtocol --- Common/Wrapper/Calendars.swift | 20 +- Common/Wrapper/Certifications.swift | 2 +- Common/Wrapper/Checkin.swift | 6 +- Common/Wrapper/Comments.swift | 145 ++++----- Common/Wrapper/Enums.swift | 43 ++- Common/Wrapper/Episodes.swift | 16 +- Common/Wrapper/Genres.swift | 2 +- Common/Wrapper/HTTPHeader.swift | 44 +++ Common/Wrapper/Languages.swift | 2 +- Common/Wrapper/Lists.swift | 4 +- Common/Wrapper/Movies.swift | 38 +-- Common/Wrapper/People.swift | 10 +- Common/Wrapper/Recommendations.swift | 12 +- Common/Wrapper/Route.swift | 163 ++++++---- Common/Wrapper/Scrobble.swift | 8 +- Common/Wrapper/Search.swift | 4 +- Common/Wrapper/Seasons.swift | 16 +- Common/Wrapper/SharedFunctions.swift | 34 +- Common/Wrapper/Shows.swift | 44 +-- Common/Wrapper/Sync.swift | 36 +-- Common/Wrapper/TraktManager.swift | 160 ++++------ Common/Wrapper/URLSessionProtocol.swift | 81 ----- Common/Wrapper/Users.swift | 66 ++-- .../AuthenticationInfoTests.swift | 3 +- Tests/TraktKitTests/CalendarTests.swift | 82 ++--- Tests/TraktKitTests/CertificationsTests.swift | 20 +- Tests/TraktKitTests/CheckinTests.swift | 47 +-- Tests/TraktKitTests/CommentTests.swift | 122 +++----- Tests/TraktKitTests/EpisodeTests.swift | 101 +++--- Tests/TraktKitTests/GenreTests.swift | 20 +- Tests/TraktKitTests/ListsTests.swift | 28 +- .../Models/Episodes/Episode_Full.json | 2 +- .../Models/Users/test_get_settings.json | 103 ++++--- Tests/TraktKitTests/MovieTests.swift | 166 ++++------ .../NetworkMocking/MockedResponse.swift | 36 +++ .../NetworkMocking/RequestMocking.swift | 122 ++++++++ Tests/TraktKitTests/PeopleTests.swift | 44 +-- .../TraktKitTests/RecommendationsTests.swift | 37 +-- Tests/TraktKitTests/ScrobbleTests.swift | 43 +-- Tests/TraktKitTests/SearchTests.swift | 25 +- Tests/TraktKitTests/SeasonTests.swift | 83 ++--- Tests/TraktKitTests/ShowTests.swift | 115 ------- Tests/TraktKitTests/ShowsTests.swift | 266 +++++++--------- Tests/TraktKitTests/SyncTests.swift | 163 ++++------ Tests/TraktKitTests/TestTraktManager.swift | 16 - Tests/TraktKitTests/TraktManagerTests.swift | 11 +- Tests/TraktKitTests/TraktTestCase.swift | 27 ++ Tests/TraktKitTests/UserTests.swift | 291 ++++++------------ Tests/TraktKitTests/update_tests.py | 63 ++++ 49 files changed, 1334 insertions(+), 1658 deletions(-) create mode 100644 Common/Wrapper/HTTPHeader.swift delete mode 100644 Common/Wrapper/URLSessionProtocol.swift create mode 100644 Tests/TraktKitTests/NetworkMocking/MockedResponse.swift create mode 100644 Tests/TraktKitTests/NetworkMocking/RequestMocking.swift delete mode 100644 Tests/TraktKitTests/ShowTests.swift delete mode 100644 Tests/TraktKitTests/TestTraktManager.swift create mode 100644 Tests/TraktKitTests/TraktTestCase.swift create mode 100644 Tests/TraktKitTests/update_tests.py diff --git a/Common/Wrapper/Calendars.swift b/Common/Wrapper/Calendars.swift index 16d7324..8c973e4 100644 --- a/Common/Wrapper/Calendars.swift +++ b/Common/Wrapper/Calendars.swift @@ -19,7 +19,7 @@ extension TraktManager { - parameter days: Number of days to display. Example: `7`. */ @discardableResult - public func myShows(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func myShows(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/my/shows/\(dateString)/\(days)", withQuery: [:], isAuthorized: true, @@ -37,7 +37,7 @@ extension TraktManager { - parameter days: Number of days to display. Example: `7`. */ @discardableResult - public func myNewShows(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func myNewShows(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/my/shows/new/\(dateString)/\(days)", withQuery: [:], isAuthorized: true, @@ -55,7 +55,7 @@ extension TraktManager { - parameter days: Number of days to display. Example: `7`. */ @discardableResult - public func mySeasonPremieres(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func mySeasonPremieres(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/my/shows/premieres/\(dateString)/\(days)", withQuery: [:], isAuthorized: true, @@ -73,7 +73,7 @@ extension TraktManager { - parameter days: Number of days to display. Example: `7`. */ @discardableResult - public func myMovies(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func myMovies(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/my/movies/\(dateString)/\(days)", withQuery: [:], isAuthorized: true, @@ -90,7 +90,7 @@ extension TraktManager { 🎚 Filters */ @discardableResult - public func myDVDReleases(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func myDVDReleases(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/my/dvd/\(dateString)/\(days)", withQuery: [:], isAuthorized: true, @@ -106,7 +106,7 @@ extension TraktManager { - parameter days: Number of days to display. Example: `7`. */ @discardableResult - public func allShows(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func allShows(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/all/shows/\(dateString)/\(days)", withQuery: [:], isAuthorized: false, @@ -122,7 +122,7 @@ extension TraktManager { - parameter days: Number of days to display. Example: `7`. */ @discardableResult - public func allNewShows(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func allNewShows(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/all/shows/new/\(dateString)/\(days)", withQuery: [:], isAuthorized: false, @@ -138,7 +138,7 @@ extension TraktManager { - parameter days: Number of days to display. Example: `7`. */ @discardableResult - public func allSeasonPremieres(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func allSeasonPremieres(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/all/shows/premieres/\(dateString)/\(days)", withQuery: [:], isAuthorized: false, @@ -154,7 +154,7 @@ extension TraktManager { - parameter days: Number of days to display. Example: `7`. */ @discardableResult - public func allMovies(startDateString dateString: String, days: Int, extended: [ExtendedType] = [.Min], filters: [Filter]? = nil, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func allMovies(startDateString dateString: String, days: Int, extended: [ExtendedType] = [.Min], filters: [Filter]? = nil, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString()] @@ -176,7 +176,7 @@ extension TraktManager { /** */ @discardableResult - public func allDVD(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func allDVD(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/all/dvd/\(dateString)/\(days)", withQuery: [:], isAuthorized: false, diff --git a/Common/Wrapper/Certifications.swift b/Common/Wrapper/Certifications.swift index 9138606..7d94e69 100644 --- a/Common/Wrapper/Certifications.swift +++ b/Common/Wrapper/Certifications.swift @@ -16,7 +16,7 @@ extension TraktManager { Note: Only `us` certifications are currently returned. */ @discardableResult - public func getCertifications(completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getCertifications(completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "certifications", withQuery: [:], isAuthorized: true, diff --git a/Common/Wrapper/Checkin.swift b/Common/Wrapper/Checkin.swift index c984aae..f440f94 100644 --- a/Common/Wrapper/Checkin.swift +++ b/Common/Wrapper/Checkin.swift @@ -15,7 +15,7 @@ extension TraktManager { **Note**: If a checkin is already in progress, a `409` HTTP status code will returned. The response will contain an `expires_at` timestamp which is when the user can check in again. */ @discardableResult - public func checkIn(_ body: TraktCheckinBody, completionHandler: @escaping checkinCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func checkIn(_ body: TraktCheckinBody, completionHandler: @escaping checkinCompletionHandler) -> URLSessionDataTask? { guard let request = post("checkin", body: body) else { return nil } return performRequest(request: request, completion: completionHandler) } @@ -24,13 +24,13 @@ extension TraktManager { Removes any active checkins, no need to provide a specific item. */ @discardableResult - public func deleteActiveCheckins(completionHandler: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { + public func deleteActiveCheckins(completionHandler: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "checkin", withQuery: [:], isAuthorized: true, withHTTPMethod: .DELETE) else { return nil } - let dataTask = session._dataTask(with: request) { data, response, error in + let dataTask = session.dataTask(with: request) { data, response, error in guard error == nil else { completionHandler(.fail) return diff --git a/Common/Wrapper/Comments.swift b/Common/Wrapper/Comments.swift index f6085aa..8ec4081 100644 --- a/Common/Wrapper/Comments.swift +++ b/Common/Wrapper/Comments.swift @@ -18,7 +18,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func postComment(movie: SyncId? = nil, show: SyncId? = nil, season: SyncId? = nil, episode: SyncId? = nil, list: SyncId? = nil, comment: String, isSpoiler spoiler: Bool? = nil, completion: @escaping SuccessCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func postComment(movie: SyncId? = nil, show: SyncId? = nil, season: SyncId? = nil, episode: SyncId? = nil, list: SyncId? = nil, comment: String, isSpoiler spoiler: Bool? = nil, completion: @escaping SuccessCompletionHandler) throws -> URLSessionDataTask? { let body = TraktCommentBody(movie: movie, show: show, season: season, episode: episode, list: list, comment: comment, spoiler: spoiler) guard let request = post("comments", body: body) else { return nil } return performRequest(request: request, completion: completion) @@ -28,13 +28,12 @@ extension TraktManager { Returns a single comment and indicates how many replies it has. Use **GET** `/comments/:id/replies` to get the actual replies. */ @discardableResult - public func getComment(commentID id: T, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = try? mutableRequest(forPath: "comments/\(id)", - withQuery: [:], - isAuthorized: false, - withHTTPMethod: .GET) else { return nil } - return performRequest(request: request, - completion: completion) + public func getComment(commentID id: T, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { + let request = try mutableRequest(forPath: "comments/\(id)", + withQuery: [:], + isAuthorized: false, + withHTTPMethod: .GET) + return performRequest(request: request, completion: completion) } /** @@ -43,15 +42,14 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func updateComment(commentID id: T, newComment comment: String, isSpoiler spoiler: Bool? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func updateComment(commentID id: T, newComment comment: String, isSpoiler spoiler: Bool? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktCommentBody(comment: comment, spoiler: spoiler) - guard var request = try? mutableRequest(forPath: "comments/\(id)", - withQuery: [:], - isAuthorized: true, - withHTTPMethod: .PUT) else { return nil } + var request = try mutableRequest(forPath: "comments/\(id)", + withQuery: [:], + isAuthorized: true, + withHTTPMethod: .PUT) request.httpBody = try jsonEncoder.encode(body) - return performRequest(request: request, - completion: completion) + return performRequest(request: request, completion: completion) } /** @@ -60,12 +58,11 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func deleteComment(commentID id: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { - guard - let request = try? mutableRequest(forPath: "comments/\(id)", - withQuery: [:], - isAuthorized: true, - withHTTPMethod: .DELETE) else { return nil } + public func deleteComment(commentID id: T, completion: @escaping SuccessCompletionHandler) throws -> URLSessionDataTask? { + let request = try mutableRequest(forPath: "comments/\(id)", + withQuery: [:], + isAuthorized: true, + withHTTPMethod: .DELETE) return performRequest(request: request, completion: completion) } @@ -77,13 +74,12 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getReplies(commentID id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = try? mutableRequest(forPath: "comments/\(id)/replies", - withQuery: [:], - isAuthorized: false, - withHTTPMethod: .GET) else { return nil } - return performRequest(request: request, - completion: completion) + public func getReplies(commentID id: T, completion: @escaping ObjectsCompletionHandler) throws -> URLSessionDataTask? { + let request = try mutableRequest(forPath: "comments/\(id)/replies", + withQuery: [:], + isAuthorized: false, + withHTTPMethod: .GET) + return performRequest(request: request, completion: completion) } /** @@ -92,7 +88,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func postReply(commentID id: T, comment: String, isSpoiler spoiler: Bool? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func postReply(commentID id: T, comment: String, isSpoiler spoiler: Bool? = nil, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { let body = TraktCommentBody(comment: comment, spoiler: spoiler) guard let request = post("comments/\(id)/replies", body: body) else { return nil } return performRequest(request: request, completion: completion) @@ -106,13 +102,12 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getAttachedMediaItem(commentID id: T, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = try? mutableRequest(forPath: "comments/\(id)/item", - withQuery: [:], - isAuthorized: true, - withHTTPMethod: .POST) else { return nil } - return performRequest(request: request, - completion: completion) + public func getAttachedMediaItem(commentID id: T, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { + let request = try mutableRequest(forPath: "comments/\(id)/item", + withQuery: [:], + isAuthorized: true, + withHTTPMethod: .POST) + return performRequest(request: request, completion: completion) } // MARK: - Likes @@ -123,13 +118,12 @@ extension TraktManager { ✨ Extended Info */ @discardableResult - public func getUsersWhoLikedComment(commentID id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = try? mutableRequest(forPath: "comments/\(id)/likes", - withQuery: [:], - isAuthorized: true, - withHTTPMethod: .GET) else { return nil } - return performRequest(request: request, - completion: completion) + public func getUsersWhoLikedComment(commentID id: T, completion: @escaping ObjectsCompletionHandler) throws -> URLSessionDataTask? { + let request = try mutableRequest(forPath: "comments/\(id)/likes", + withQuery: [:], + isAuthorized: true, + withHTTPMethod: .GET) + return performRequest(request: request, completion: completion) } // MARK: - Like @@ -140,13 +134,12 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func likeComment(commentID id: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = try? mutableRequest(forPath: "comments/\(id)/like", - withQuery: [:], - isAuthorized: false, - withHTTPMethod: .POST) else { return nil } - return performRequest(request: request, - completion: completion) + public func likeComment(commentID id: T, completion: @escaping SuccessCompletionHandler) throws -> URLSessionDataTask? { + let request = try mutableRequest(forPath: "comments/\(id)/like", + withQuery: [:], + isAuthorized: false, + withHTTPMethod: .POST) + return performRequest(request: request, completion: completion) } /** @@ -155,13 +148,12 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func removeLikeOnComment(commentID id: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = try? mutableRequest(forPath: "comments/\(id)/like", - withQuery: [:], - isAuthorized: false, - withHTTPMethod: .DELETE) else { return nil } - return performRequest(request: request, - completion: completion) + public func removeLikeOnComment(commentID id: T, completion: @escaping SuccessCompletionHandler) throws -> URLSessionDataTask? { + let request = try mutableRequest(forPath: "comments/\(id)/like", + withQuery: [:], + isAuthorized: false, + withHTTPMethod: .DELETE) + return performRequest(request: request, completion: completion) } // MARK: - Trending @@ -173,13 +165,12 @@ extension TraktManager { ✨ Extended */ @discardableResult - public func getTrendingComments(commentType: CommentType, mediaType: Type2, includeReplies: Bool, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = try? mutableRequest(forPath: "comments/trending/\(commentType.rawValue)/\(mediaType.rawValue)", - withQuery: ["include_replies": "\(includeReplies)"], - isAuthorized: false, - withHTTPMethod: .GET) else { return nil } - return performRequest(request: request, - completion: completion) + public func getTrendingComments(commentType: CommentType, mediaType: Type2, includeReplies: Bool, completion: @escaping ObjectsCompletionHandler) throws -> URLSessionDataTask? { + let request = try mutableRequest(forPath: "comments/trending/\(commentType.rawValue)/\(mediaType.rawValue)", + withQuery: ["include_replies": "\(includeReplies)"], + isAuthorized: false, + withHTTPMethod: .GET) + return performRequest(request: request, completion: completion) } // MARK: - Recent @@ -191,13 +182,12 @@ extension TraktManager { ✨ Extended */ @discardableResult - public func getRecentComments(commentType: CommentType, mediaType: Type2, includeReplies: Bool, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = try? mutableRequest(forPath: "comments/recent/\(commentType.rawValue)/\(mediaType.rawValue)", - withQuery: ["include_replies": "\(includeReplies)"], - isAuthorized: false, - withHTTPMethod: .GET) else { return nil } - return performRequest(request: request, - completion: completion) + public func getRecentComments(commentType: CommentType, mediaType: Type2, includeReplies: Bool, completion: @escaping ObjectsCompletionHandler) throws -> URLSessionDataTask? { + let request = try mutableRequest(forPath: "comments/recent/\(commentType.rawValue)/\(mediaType.rawValue)", + withQuery: ["include_replies": "\(includeReplies)"], + isAuthorized: false, + withHTTPMethod: .GET) + return performRequest(request: request, completion: completion) } // MARK: - Updates @@ -209,12 +199,11 @@ extension TraktManager { ✨ Extended */ @discardableResult - public func getRecentlyUpdatedComments(commentType: CommentType, mediaType: Type2, includeReplies: Bool, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { - guard let request = try? mutableRequest(forPath: "comments/updates/\(commentType.rawValue)/\(mediaType.rawValue)", - withQuery: ["include_replies": "\(includeReplies)"], - isAuthorized: false, - withHTTPMethod: .GET) else { return nil } - return performRequest(request: request, - completion: completion) + public func getRecentlyUpdatedComments(commentType: CommentType, mediaType: Type2, includeReplies: Bool, completion: @escaping ObjectsCompletionHandler) throws -> URLSessionDataTask? { + let request = try mutableRequest(forPath: "comments/updates/\(commentType.rawValue)/\(mediaType.rawValue)", + withQuery: ["include_replies": "\(includeReplies)"], + isAuthorized: false, + withHTTPMethod: .GET) + return performRequest(request: request, completion: completion) } } diff --git a/Common/Wrapper/Enums.swift b/Common/Wrapper/Enums.swift index 5850a59..e13fb3e 100644 --- a/Common/Wrapper/Enums.swift +++ b/Common/Wrapper/Enums.swift @@ -8,7 +8,7 @@ import Foundation -public enum Method: String { +public enum Method: String, Sendable { /// Select one or more items. Success returns 200 status code. case GET /// Create a new item. Success returns 201 status code. @@ -17,9 +17,22 @@ public enum Method: String { case PUT /// Delete an item. Success returns 200 or 204 status code. case DELETE + + public var expectedResult: Int { + switch self { + case .GET: + 200 + case .POST: + 201 + case .PUT: + 200 + case .DELETE: + 204 + } + } } -public struct StatusCodes { +public struct StatusCodes: Sendable { /// Success public static let Success = 200 /// Success - new resource created (POST) @@ -132,7 +145,7 @@ public enum SearchType: String, Sendable { } /// Type of ID used for look up -public enum LookupType { +public enum LookupType: Sendable { case Trakt(id: NSNumber) case IMDB(id: String) case TMDB(id: NSNumber) @@ -170,7 +183,7 @@ public enum LookupType { } } -public enum MediaType: String, CustomStringConvertible { +public enum MediaType: String, CustomStringConvertible, Sendable { case movies, shows public var description: String { @@ -178,7 +191,7 @@ public enum MediaType: String, CustomStringConvertible { } } -public enum WatchedType: String, CustomStringConvertible { +public enum WatchedType: String, CustomStringConvertible, Sendable { case Movies = "movies" case Shows = "shows" case Seasons = "seasons" @@ -189,7 +202,7 @@ public enum WatchedType: String, CustomStringConvertible { } } -public enum Type2: String, CustomStringConvertible { +public enum Type2: String, CustomStringConvertible, Sendable { case All = "all" case Movies = "movies" case Shows = "shows" @@ -202,7 +215,7 @@ public enum Type2: String, CustomStringConvertible { } } -public enum ListType: String, CustomStringConvertible { +public enum ListType: String, CustomStringConvertible, Sendable { case all case personal case official @@ -213,7 +226,7 @@ public enum ListType: String, CustomStringConvertible { } } -public enum ListSortType: String, CustomStringConvertible { +public enum ListSortType: String, CustomStringConvertible, Sendable { case popular case likes case comments @@ -227,14 +240,14 @@ public enum ListSortType: String, CustomStringConvertible { } /// Type of comments -public enum CommentType: String { +public enum CommentType: String, Sendable { case all = "all" case reviews = "reviews" case shouts = "shouts" } /// Extended information -public enum ExtendedType: String, CustomStringConvertible { +public enum ExtendedType: String, CustomStringConvertible, Sendable { /// Least amount of info case Min = "min" /// All information, excluding images @@ -260,7 +273,7 @@ extension Sequence where Iterator.Element: CustomStringConvertible { } /// Possible values for items in Lists -public enum ListItemType: String { +public enum ListItemType: String, Sendable { case movies = "movie" case shows = "show" case seasons = "season" @@ -268,13 +281,13 @@ public enum ListItemType: String { case people = "person" } -public enum Period: String { +public enum Period: String, Sendable { case Weekly = "weekly" case Monthly = "monthly" case All = "all" } -public enum SectionType: String { +public enum SectionType: String, Sendable { /// Can hide movie, show objects case Calendar = "calendar" /// Can hide show, season objects @@ -285,13 +298,13 @@ public enum SectionType: String { case Recommendations = "recommendations" } -public enum HiddenItemsType: String { +public enum HiddenItemsType: String, Sendable { case Movie = "movie" case Show = "show" case Season = "Season" } -public enum LikeType: String { +public enum LikeType: String, Sendable { case Comments = "comments" case Lists = "lists" } diff --git a/Common/Wrapper/Episodes.swift b/Common/Wrapper/Episodes.swift index a01f986..a9157c8 100644 --- a/Common/Wrapper/Episodes.swift +++ b/Common/Wrapper/Episodes.swift @@ -18,7 +18,7 @@ extension TraktManager { **Note**: If the `first_aired` is unknown, it will be set to `null`. */ @discardableResult - public func getEpisodeSummary(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getEpisodeSummary(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)", withQuery: ["extended": extended.queryString()], isAuthorized: false, @@ -38,7 +38,7 @@ extension TraktManager { - parameter language: 2 character language code */ @discardableResult - public func getEpisodeTranslations(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, language: String? = nil, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getEpisodeTranslations(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, language: String? = nil, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { var path = "shows/\(id)/seasons/\(season)/episodes/\(episode)/translations" if let language = language { path += "/\(language)" @@ -60,7 +60,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getEpisodeComments(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, pagination: Pagination? = nil, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getEpisodeComments(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, pagination: Pagination? = nil, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = [:] @@ -87,7 +87,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getListsContainingEpisode(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, listType: ListType? = nil, sortBy: ListSortType? = nil, pagination: Pagination? = nil, completion: @escaping paginatedCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getListsContainingEpisode(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, listType: ListType? = nil, sortBy: ListSortType? = nil, pagination: Pagination? = nil, completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { var path = "shows/\(id)/seasons/\(season)/episodes/\(episode)/lists" if let listType = listType { path += "/\(listType)" @@ -120,7 +120,7 @@ extension TraktManager { Returns rating (between 0 and 10) and distribution for an episode. */ @discardableResult - public func getEpisodeRatings(showID id: T, seasonNumber: NSNumber, episodeNumber: NSNumber, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getEpisodeRatings(showID id: T, seasonNumber: NSNumber, episodeNumber: NSNumber, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(seasonNumber)/episodes/\(episodeNumber)/ratings", withQuery: [:], isAuthorized: false, @@ -135,7 +135,7 @@ extension TraktManager { Returns lots of episode stats. */ @discardableResult - public func getEpisodeStatistics(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, completion: @escaping statsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getEpisodeStatistics(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, completion: @escaping statsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)/stats", withQuery: [:], isAuthorized: false, @@ -150,7 +150,7 @@ extension TraktManager { Returns all users watching this episode right now. */ @discardableResult - public func getUsersWatchingEpisode(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getUsersWatchingEpisode(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)/watching", withQuery: [:], isAuthorized: false, @@ -173,7 +173,7 @@ extension TraktManager { **Note**: This returns a lot of data, so please only use this extended parameter if you actually need it! */ @discardableResult - public func getPeopleInEpisode(showID id: T, season: NSNumber, episode: NSNumber, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTaskProtocol? { + public func getPeopleInEpisode(showID id: T, season: NSNumber, episode: NSNumber, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)/people", withQuery: ["extended": extended.queryString()], isAuthorized: false, diff --git a/Common/Wrapper/Genres.swift b/Common/Wrapper/Genres.swift index 4d2b6ba..051e340 100644 --- a/Common/Wrapper/Genres.swift +++ b/Common/Wrapper/Genres.swift @@ -14,7 +14,7 @@ extension TraktManager { Get a list of all genres, including names and slugs. */ @discardableResult - public func listGenres(type: WatchedType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func listGenres(type: WatchedType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "genres/\(type)", withQuery: [:], isAuthorized: false, diff --git a/Common/Wrapper/HTTPHeader.swift b/Common/Wrapper/HTTPHeader.swift new file mode 100644 index 0000000..87d8abe --- /dev/null +++ b/Common/Wrapper/HTTPHeader.swift @@ -0,0 +1,44 @@ +// +// HTTPHeader.swift +// TraktKit +// +// Created by Maximilian Litteral on 2/16/25. +// + +public enum HTTPHeader { + case contentType + case apiVersion + case apiKey(String) + case page(Int) + case pageCount(Int) + + public var key: String { + switch self { + case .contentType: + "Content-type" + case .apiVersion: + "trakt-api-version" + case .apiKey: + "trakt-api-key" + case .page: + "X-Pagination-Page" + case .pageCount: + "X-Pagination-Page-Count" + } + } + + public var value: String { + switch self { + case .contentType: + "application/json" + case .apiVersion: + "2" + case .apiKey(let apiKey): + apiKey + case .page(let page): + page.description + case .pageCount(let pageCount): + pageCount.description + } + } +} diff --git a/Common/Wrapper/Languages.swift b/Common/Wrapper/Languages.swift index 03b84d1..a034dc7 100644 --- a/Common/Wrapper/Languages.swift +++ b/Common/Wrapper/Languages.swift @@ -14,7 +14,7 @@ extension TraktManager { Get a list of all genres, including names and slugs. */ @discardableResult - public func listLanguages(type: WatchedType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func listLanguages(type: WatchedType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "languages/\(type)", withQuery: [:], isAuthorized: false, diff --git a/Common/Wrapper/Lists.swift b/Common/Wrapper/Lists.swift index e3d88d5..73a5dab 100644 --- a/Common/Wrapper/Lists.swift +++ b/Common/Wrapper/Lists.swift @@ -16,7 +16,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getTrendingLists(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getTrendingLists(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "lists/trending", withQuery: [:], @@ -35,7 +35,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getPopularLists(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getPopularLists(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "lists/popular", withQuery: [:], diff --git a/Common/Wrapper/Movies.swift b/Common/Wrapper/Movies.swift index 28b4b82..7097705 100644 --- a/Common/Wrapper/Movies.swift +++ b/Common/Wrapper/Movies.swift @@ -18,7 +18,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getTrendingMovies(pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], filters: [Filter]? = nil, completion: @escaping TrendingMoviesCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getTrendingMovies(pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], filters: [Filter]? = nil, completion: @escaping TrendingMoviesCompletionHandler) -> URLSessionDataTask? { return getTrending(.Movies, pagination: pagination, extended: extended, filters: filters, completion: completion) } @@ -30,7 +30,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getPopularMovies(pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], filters: [Filter]? = nil, completion: @escaping paginatedCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getPopularMovies(pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], filters: [Filter]? = nil, completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { return getPopular(.Movies, pagination: pagination, extended: extended, filters: filters, completion: completion) } @@ -42,7 +42,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getPlayedMovies(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostMoviesCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getPlayedMovies(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostMoviesCompletionHandler) -> URLSessionDataTask? { return getPlayed(.Movies, period: period, pagination: pagination, completion: completion) } @@ -54,7 +54,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getWatchedMovies(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostMoviesCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getWatchedMovies(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostMoviesCompletionHandler) -> URLSessionDataTask? { return getWatched(.Movies, period: period, pagination: pagination, completion: completion) } @@ -66,7 +66,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getCollectedMovies(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostMoviesCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getCollectedMovies(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostMoviesCompletionHandler) -> URLSessionDataTask? { return getCollected(.Movies, period: period, pagination: pagination, completion: completion) } @@ -78,7 +78,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getAnticipatedMovies(pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], completion: @escaping AnticipatedMovieCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getAnticipatedMovies(pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], completion: @escaping AnticipatedMovieCompletionHandler) -> URLSessionDataTask? { return getAnticipated(.Movies, pagination: pagination, extended: extended, completion: completion) } @@ -88,7 +88,7 @@ extension TraktManager { Returns the top 10 grossing movies in the U.S. box office last weekend. Updated every Monday morning. */ @discardableResult - public func getWeekendBoxOffice(extended: [ExtendedType] = [.Min], completion: @escaping BoxOfficeMoviesCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getWeekendBoxOffice(extended: [ExtendedType] = [.Min], completion: @escaping BoxOfficeMoviesCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "movies/boxoffice", withQuery: ["extended": extended.queryString()], isAuthorized: false, @@ -105,7 +105,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getUpdatedMovies(startDate: Date?, pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getUpdatedMovies(startDate: Date?, pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { return getUpdated(.Movies, startDate: startDate, pagination: pagination, extended: extended, completion: completion) } @@ -115,7 +115,7 @@ extension TraktManager { Returns a single movie's details. */ @discardableResult - public func getMovieSummary(movieID id: T, extended: [ExtendedType] = [.Min], completion: @escaping MovieCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getMovieSummary(movieID id: T, extended: [ExtendedType] = [.Min], completion: @escaping MovieCompletionHandler) -> URLSessionDataTask? { return getSummary(.Movies, id: id, extended: extended, completion: completion) } @@ -125,7 +125,7 @@ extension TraktManager { Returns all title aliases for a movie. Includes country where name is different. */ @discardableResult - public func getMovieAliases(movieID id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getMovieAliases(movieID id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { return getAliases(.Movies, id: id, completion: completion) } @@ -138,7 +138,7 @@ extension TraktManager { - parameter country: 2 character country code. Example: `us`. */ @discardableResult - public func getMovieReleases(movieID id: T, country: String?, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getMovieReleases(movieID id: T, country: String?, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { var path = "movies/\(id)/releases" @@ -160,7 +160,7 @@ extension TraktManager { Returns all translations for a movie, including language and translated values for title, tagline and overview. */ @discardableResult - public func getMovieTranslations(movieID id: T, language: String?, completion: @escaping MovieTranslationsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getMovieTranslations(movieID id: T, language: String?, completion: @escaping MovieTranslationsCompletionHandler) -> URLSessionDataTask? { return getTranslations(.Movies, id: id, language: language, completion: completion) } @@ -172,7 +172,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getMovieComments(movieID id: T, pagination: Pagination? = nil, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getMovieComments(movieID id: T, pagination: Pagination? = nil, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTask? { return getComments(.Movies, id: id, pagination: pagination, completion: completion) } @@ -188,7 +188,7 @@ extension TraktManager { listType: ListType? = nil, sortBy: ListSortType? = nil, pagination: Pagination? = nil, - completion: @escaping paginatedCompletionHandler) -> URLSessionDataTaskProtocol? { + completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { var path = "movies/\(id)/lists" if let listType = listType { path += "/\(listType)" @@ -223,7 +223,7 @@ extension TraktManager { The `crew` object will be broken up into `production`, `art`, `crew`, `costume & make-up`, `directing`, `writing`, `sound`, and `camera` (if there are people for those crew positions). Each of those members will have a `job` and a standard `person` object. */ @discardableResult - public func getPeopleInMovie(movieID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTaskProtocol? { + public func getPeopleInMovie(movieID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTask? { return getPeople(.Movies, id: id, extended: extended, completion: completion) } @@ -233,7 +233,7 @@ extension TraktManager { Returns rating (between 0 and 10) and distribution for a movie. */ @discardableResult - public func getMovieRatings(movieID id: T, completion: @escaping RatingDistributionCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getMovieRatings(movieID id: T, completion: @escaping RatingDistributionCompletionHandler) -> URLSessionDataTask? { return getRatings(.Movies, id: id, completion: completion) } @@ -245,7 +245,7 @@ extension TraktManager { **Note**: We are continuing to improve this algorithm. */ @discardableResult - public func getRelatedMovies(movieID id: T, extended: [ExtendedType] = [.Min], completion: @escaping MoviesCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getRelatedMovies(movieID id: T, extended: [ExtendedType] = [.Min], completion: @escaping MoviesCompletionHandler) -> URLSessionDataTask? { return getRelated(.Movies, id: id, extended: extended, completion: completion) } @@ -255,7 +255,7 @@ extension TraktManager { Returns lots of movie stats. */ @discardableResult - public func getMovieStatistics(movieID id: T, completion: @escaping statsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getMovieStatistics(movieID id: T, completion: @escaping statsCompletionHandler) -> URLSessionDataTask? { return getStatistics(.Movies, id: id, completion: completion) } @@ -265,7 +265,7 @@ extension TraktManager { Returns all users watching this movie right now. */ @discardableResult - public func getUsersWatchingMovie(movieID id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getUsersWatchingMovie(movieID id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { return getUsersWatching(.Movies, id: id, completion: completion) } } diff --git a/Common/Wrapper/People.swift b/Common/Wrapper/People.swift index 89144f9..c991bbf 100644 --- a/Common/Wrapper/People.swift +++ b/Common/Wrapper/People.swift @@ -18,7 +18,7 @@ extension TraktManager { ✨ Extended Info */ @discardableResult - public func getPersonDetails(personID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getPersonDetails(personID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "people/\(id)", withQuery: ["extended": extended.queryString()], isAuthorized: false, @@ -40,7 +40,7 @@ extension TraktManager { ✨ Extended Info */ @discardableResult - public func getMovieCredits(personID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTaskProtocol? { + public func getMovieCredits(personID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTask? { return getCredits(type: WatchedType.Movies, id: id, extended: extended, completion: completion) } @@ -54,7 +54,7 @@ extension TraktManager { ✨ Extended Info */ @discardableResult - public func getShowCredits(personID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTaskProtocol? { + public func getShowCredits(personID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTask? { return getCredits(type: WatchedType.Shows, id: id, extended: extended, completion: completion) } @@ -66,7 +66,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getListsContainingPerson(personId id: T, listType: ListType? = nil, sortBy: ListSortType? = nil, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getListsContainingPerson(personId id: T, listType: ListType? = nil, sortBy: ListSortType? = nil, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { var path = "people/\(id)/lists" if let listType = listType { path += "/\(listType)" @@ -87,7 +87,7 @@ extension TraktManager { // MARK: - Private @discardableResult - private func getCredits(type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTaskProtocol? { + private func getCredits(type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "people/\(id)/\(type)", withQuery: ["extended": extended.queryString()], isAuthorized: false, diff --git a/Common/Wrapper/Recommendations.swift b/Common/Wrapper/Recommendations.swift index 9c80147..c4f4796 100644 --- a/Common/Wrapper/Recommendations.swift +++ b/Common/Wrapper/Recommendations.swift @@ -19,7 +19,7 @@ extension TraktManager { ✨ Extended Info */ @discardableResult - public func getRecommendedMovies(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getRecommendedMovies(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { return getRecommendations(.Movies, completion: completion) } @@ -29,7 +29,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func hideRecommendedMovie(movieID id: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { + public func hideRecommendedMovie(movieID id: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { return hideRecommendation(type: .Movies, id: id, completion: completion) } @@ -39,7 +39,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func getRecommendedShows(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getRecommendedShows(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { return getRecommendations(.Shows, completion: completion) } @@ -49,14 +49,14 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func hideRecommendedShow(showID id: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { + public func hideRecommendedShow(showID id: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { return hideRecommendation(type: .Shows, id: id, completion: completion) } // MARK: - Private @discardableResult - private func getRecommendations(_ type: WatchedType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + private func getRecommendations(_ type: WatchedType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "recommendations/\(type)", withQuery: [:], isAuthorized: true, @@ -70,7 +70,7 @@ extension TraktManager { } @discardableResult - private func hideRecommendation(type: WatchedType, id: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { + private func hideRecommendation(type: WatchedType, id: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "recommendations/\(type)/\(id)", withQuery: [:], isAuthorized: true, diff --git a/Common/Wrapper/Route.swift b/Common/Wrapper/Route.swift index 11970ef..88e9414 100644 --- a/Common/Wrapper/Route.swift +++ b/Common/Wrapper/Route.swift @@ -8,22 +8,86 @@ import Foundation -public class Route { - public let path: String - public let method: Method +public struct Route: Sendable { + + // MARK: - Properties + private let resultType: T.Type - public let traktManager: TraktManager + + public var path: String + public let method: Method public let requiresAuthentication: Bool - private var extended: [ExtendedType] = [] - private var _page: Int? - private var _limit: Int? - + private var extended = [ExtendedType]() + private var page: Int? + private var limit: Int? + private var filters = [FilterType]() private var searchType: SearchType? private var searchQuery: String? - private var request: URLRequest { + // MARK: - Lifecycle + + public init(path: String, method: Method, requiresAuthentication: Bool = false, resultType: T.Type = T.self) { + self.path = path + self.method = method + self.requiresAuthentication = requiresAuthentication + self.resultType = resultType + } + + // MARK: - Actions + + public func extend(_ extended: ExtendedType...) -> Self { + var copy = self + copy.extended = extended + return copy + } + + // MARK: - Pagination + + public func page(_ page: Int?) -> Self { + var copy = self + copy.page = page + return copy + } + + public func limit(_ limit: Int?) -> Self { + var copy = self + copy.limit = limit + return copy + } + + // MARK: - Filters + + public func filter(_ filter: TraktManager.Filter) -> Self { + var copy = self + copy.filters.append(filter) + return copy + } + + public func type(_ type: SearchType?) -> Self { + var copy = self + copy.searchType = type + return copy + } + + // MARK: - Search + + public func query(_ query: String?) -> Self { + var copy = self + copy.searchQuery = query + return copy + } + + // MARK: - Perform + + public func perform() async throws -> T { + @InjectedClient var traktManager + let request = try makeRequest(traktManager: traktManager) + return try await traktManager.perform(request: request) + } + + private func makeRequest(traktManager: TraktManager) throws -> URLRequest { var query: [String: String] = [:] if !extended.isEmpty { @@ -31,82 +95,55 @@ public class Route { } // pagination - if let page = _page { + if let page { query["page"] = page.description } - if let limit = _limit { + if let limit { query["limit"] = limit.description } - + + // Search + if let searchType { query["type"] = searchType.rawValue } - + if let searchQuery { query["query"] = searchQuery } - + // Filters - if filters.isEmpty == false { + if !filters.isEmpty { for (key, value) in (filters.map { $0.value() }) { query[key] = value } } - return try! traktManager.mutableRequest(forPath: path, - withQuery: query, - isAuthorized: requiresAuthentication, - withHTTPMethod: method)! - } - - public init(path: String, method: Method, requiresAuthentication: Bool = false, traktManager: TraktManager, resultType: T.Type = T.self) { - self.path = path - self.method = method - self.requiresAuthentication = requiresAuthentication - self.resultType = resultType - self.traktManager = traktManager + return try traktManager.mutableRequest( + forPath: path, + withQuery: query, + isAuthorized: requiresAuthentication, + withHTTPMethod: method + ) } +} - public func extend(_ extended: ExtendedType...) -> Self { - self.extended = extended - return self - } - - // MARK: - Pagination +public protocol PagedObjectProtocol { + static var objectType: Decodable.Type { get } + static func createPagedObject(with object: Decodable, currentPage: Int, pageCount: Int) -> Self +} - public func page(_ page: Int?) -> Self { - self._page = page - return self - } +public struct PagedObject: PagedObjectProtocol, TraktObject { + let object: T + let currentPage: Int + let pageCount: Int - public func limit(_ limit: Int?) -> Self { - self._limit = limit - return self + public static var objectType: any Decodable.Type { + T.self } - - // MARK: - Filters - - public func filter(_ filter: TraktManager.Filter) -> Self { - filters.append(filter) - return self - } - - public func type(_ type: SearchType?) -> Self { - searchType = type - return self - } - - // MARK: - Search - - public func query(_ query: String?) -> Self { - searchQuery = query - return self - } - - // MARK: - Perform - public func perform() async throws -> T { - try await traktManager.perform(request: request) + public static func createPagedObject(with object: Decodable, currentPage: Int, pageCount: Int) -> Self { + return PagedObject(object: object as! T, currentPage: currentPage, pageCount: pageCount) } } diff --git a/Common/Wrapper/Scrobble.swift b/Common/Wrapper/Scrobble.swift index 1ce1619..d52205c 100644 --- a/Common/Wrapper/Scrobble.swift +++ b/Common/Wrapper/Scrobble.swift @@ -20,7 +20,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func scrobbleStart(_ scrobble: TraktScrobble, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func scrobbleStart(_ scrobble: TraktScrobble, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { return try perform("start", scrobble: scrobble, completion: completion) } @@ -32,7 +32,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func scrobblePause(_ scrobble: TraktScrobble, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func scrobblePause(_ scrobble: TraktScrobble, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { return try perform("pause", scrobble: scrobble, completion: completion) } @@ -48,14 +48,14 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func scrobbleStop(_ scrobble: TraktScrobble, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func scrobbleStop(_ scrobble: TraktScrobble, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { return try perform("stop", scrobble: scrobble, completion: completion) } // MARK: - Private @discardableResult - func perform(_ scrobbleAction: String, scrobble: TraktScrobble, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { + func perform(_ scrobbleAction: String, scrobble: TraktScrobble, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { // Request guard let request = post("scrobble/\(scrobbleAction)", body: scrobble) else { return nil } return performRequest(request: request, completion: completion) diff --git a/Common/Wrapper/Search.swift b/Common/Wrapper/Search.swift index 3bb7fdd..c5005f4 100644 --- a/Common/Wrapper/Search.swift +++ b/Common/Wrapper/Search.swift @@ -25,7 +25,7 @@ extension TraktManager { pagination: Pagination? = nil, filters: [Filter]? = nil, fields: [SearchType.Field]? = nil, - completion: @escaping SearchCompletionHandler) -> URLSessionDataTaskProtocol? { + completion: @escaping SearchCompletionHandler) -> URLSessionDataTask? { let typesString = types.map { $0.rawValue }.joined(separator: ",") // Search with multiple types var query: [String: String] = ["query": query, @@ -72,7 +72,7 @@ extension TraktManager { extended: [ExtendedType] = [.Min], type: SearchType, pagination: Pagination? = nil, - completion: @escaping SearchCompletionHandler) -> URLSessionDataTaskProtocol? { + completion: @escaping SearchCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString(), "type": type.rawValue] diff --git a/Common/Wrapper/Seasons.swift b/Common/Wrapper/Seasons.swift index 4668f83..3d44c74 100644 --- a/Common/Wrapper/Seasons.swift +++ b/Common/Wrapper/Seasons.swift @@ -21,7 +21,7 @@ extension TraktManager { */ @discardableResult - public func getSeasons(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping SeasonsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getSeasons(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping SeasonsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons", withQuery: ["extended": extended.queryString()], isAuthorized: false, @@ -40,7 +40,7 @@ extension TraktManager { ✨ Extended */ @discardableResult - public func getEpisodesForSeason(showID id: T, season: NSNumber, translatedInto language: String? = nil, extended: [ExtendedType] = [.Min], completion: @escaping EpisodesCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getEpisodesForSeason(showID id: T, season: NSNumber, translatedInto language: String? = nil, extended: [ExtendedType] = [.Min], completion: @escaping EpisodesCompletionHandler) -> URLSessionDataTask? { var query = ["extended": extended.queryString()] query["translations"] = language @@ -61,7 +61,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getAllSeasonComments(showID id: T, season: NSNumber, pagination: Pagination? = nil, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getAllSeasonComments(showID id: T, season: NSNumber, pagination: Pagination? = nil, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = [:] // pagination @@ -87,7 +87,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getListsContainingSeason(showID id: T, season: NSNumber, listType: ListType? = nil, sortBy: ListSortType? = nil, pagination: Pagination? = nil, completion: @escaping paginatedCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getListsContainingSeason(showID id: T, season: NSNumber, listType: ListType? = nil, sortBy: ListSortType? = nil, pagination: Pagination? = nil, completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { var path = "shows/\(id)/seasons/\(season)/lists" if let listType = listType { path += "/\(listType)" @@ -120,7 +120,7 @@ extension TraktManager { Returns rating (between 0 and 10) and distribution for a season. */ @discardableResult - public func getSeasonRatings(showID id: T, season: NSNumber, completion: @escaping RatingDistributionCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getSeasonRatings(showID id: T, season: NSNumber, completion: @escaping RatingDistributionCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/ratings", withQuery: [:], isAuthorized: false, @@ -135,7 +135,7 @@ extension TraktManager { Returns lots of season stats. */ @discardableResult - public func getSeasonStatistics(showID id: T, season: NSNumber, completion: @escaping statsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getSeasonStatistics(showID id: T, season: NSNumber, completion: @escaping statsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/stats", withQuery: [:], isAuthorized: false, @@ -150,7 +150,7 @@ extension TraktManager { Returns all users watching this season right now. */ @discardableResult - public func getUsersWatchingSeasons(showID id: T, season: NSNumber, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getUsersWatchingSeasons(showID id: T, season: NSNumber, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/watching", withQuery: [:], isAuthorized: false, @@ -172,7 +172,7 @@ extension TraktManager { **Note**: This returns a lot of data, so please only use this extended parameter if you actually need it! */ @discardableResult - public func getPeopleInSeason(showID id: T, season: NSNumber, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTaskProtocol? { + public func getPeopleInSeason(showID id: T, season: NSNumber, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/people", withQuery: ["extended": extended.queryString()], isAuthorized: false, diff --git a/Common/Wrapper/SharedFunctions.swift b/Common/Wrapper/SharedFunctions.swift index 874715d..41c3849 100644 --- a/Common/Wrapper/SharedFunctions.swift +++ b/Common/Wrapper/SharedFunctions.swift @@ -12,7 +12,7 @@ internal extension TraktManager { // MARK: - Trending - func getTrending(_ type: WatchedType, pagination: Pagination?, extended: [ExtendedType] = [.Min], filters: [Filter]? = nil, completion: @escaping ((_ result: ObjectsResultTypePagination) -> Void)) -> URLSessionDataTaskProtocol? { + func getTrending(_ type: WatchedType, pagination: Pagination?, extended: [ExtendedType] = [.Min], filters: [Filter]? = nil, completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString()] @@ -39,7 +39,7 @@ internal extension TraktManager { // MARK: - Popular - func getPopular(_ type: WatchedType, pagination: Pagination?, extended: [ExtendedType] = [.Min], filters: [Filter]? = nil, completion: @escaping ((_ result: ObjectsResultTypePagination) -> Void)) -> URLSessionDataTaskProtocol? { + func getPopular(_ type: WatchedType, pagination: Pagination?, extended: [ExtendedType] = [.Min], filters: [Filter]? = nil, completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString()] @@ -66,7 +66,7 @@ internal extension TraktManager { // MARK: - Played - func getPlayed(_ type: WatchedType, period: Period = .Weekly, pagination: Pagination?, extended: [ExtendedType] = [.Min], completion: @escaping ((_ result: ObjectsResultTypePagination) -> Void)) -> URLSessionDataTaskProtocol? { + func getPlayed(_ type: WatchedType, period: Period = .Weekly, pagination: Pagination?, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString()] @@ -87,7 +87,7 @@ internal extension TraktManager { // MARK: - Watched - func getWatched(_ type: WatchedType, period: Period = .Weekly, pagination: Pagination?, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTaskProtocol? { + func getWatched(_ type: WatchedType, period: Period = .Weekly, pagination: Pagination?, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString()] @@ -108,7 +108,7 @@ internal extension TraktManager { // MARK: - Collected - func getCollected(_ type: WatchedType, period: Period = .Weekly, pagination: Pagination?, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTaskProtocol? { + func getCollected(_ type: WatchedType, period: Period = .Weekly, pagination: Pagination?, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString()] @@ -129,7 +129,7 @@ internal extension TraktManager { // MARK: - Anticipated - func getAnticipated(_ type: WatchedType, pagination: Pagination?, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTaskProtocol? { + func getAnticipated(_ type: WatchedType, pagination: Pagination?, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString()] @@ -150,7 +150,7 @@ internal extension TraktManager { // MARK: - Updates - func getUpdated(_ type: WatchedType, startDate: Date?, pagination: Pagination?, extended: [ExtendedType], completion: @escaping UpdateCompletionHandler) -> URLSessionDataTaskProtocol? { + func getUpdated(_ type: WatchedType, startDate: Date?, pagination: Pagination?, extended: [ExtendedType], completion: @escaping UpdateCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString()] @@ -176,7 +176,7 @@ internal extension TraktManager { // MARK: - Summary - func getSummary(_ type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { + func getSummary(_ type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "\(type)/\(id)", withQuery: ["extended": extended.queryString()], isAuthorized: false, @@ -187,7 +187,7 @@ internal extension TraktManager { // MARK: - Aliases - func getAliases(_ type: WatchedType, id: T, completion: @escaping AliasCompletionHandler) -> URLSessionDataTaskProtocol? { + func getAliases(_ type: WatchedType, id: T, completion: @escaping AliasCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "\(type)/\(id)/aliases", withQuery: [:], isAuthorized: false, @@ -197,8 +197,8 @@ internal extension TraktManager { // MARK: - Translations - func getTranslations(_ type: WatchedType, id: T, language: String?, completion: @escaping ((_ result: ObjectsResultType) -> Void)) -> URLSessionDataTaskProtocol? { - + func getTranslations(_ type: WatchedType, id: T, language: String?, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + var path = "\(type)/\(id)/translations" if let language = language { path += "/\(language)" @@ -213,7 +213,7 @@ internal extension TraktManager { // MARK: - Comments - func getComments(_ type: WatchedType, id: T, pagination: Pagination?, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTaskProtocol? { + func getComments(_ type: WatchedType, id: T, pagination: Pagination?, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = [:] @@ -234,7 +234,7 @@ internal extension TraktManager { // MARK: - People - func getPeople(_ type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTaskProtocol? { + func getPeople(_ type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "\(type)/\(id)/people", withQuery: ["extended": extended.queryString()], isAuthorized: false, @@ -245,7 +245,7 @@ internal extension TraktManager { // MARK: - Ratings - func getRatings(_ type: WatchedType, id: T, completion: @escaping RatingDistributionCompletionHandler) -> URLSessionDataTaskProtocol? { + func getRatings(_ type: WatchedType, id: T, completion: @escaping RatingDistributionCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "\(type)/\(id)/ratings", withQuery: [:], isAuthorized: false, @@ -256,7 +256,7 @@ internal extension TraktManager { // MARK: - Related - func getRelated(_ type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ((_ result: ObjectsResultType) -> Void)) -> URLSessionDataTaskProtocol? { + func getRelated(_ type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "\(type)/\(id)/related", withQuery: ["extended": extended.queryString()], isAuthorized: false, @@ -267,7 +267,7 @@ internal extension TraktManager { // MARK: - Stats - func getStatistics(_ type: WatchedType, id: T, completion: @escaping statsCompletionHandler) -> URLSessionDataTaskProtocol? { + func getStatistics(_ type: WatchedType, id: T, completion: @escaping statsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "\(type)/\(id)/stats", withQuery: [:], isAuthorized: false, @@ -278,7 +278,7 @@ internal extension TraktManager { // MARK: - Watching - func getUsersWatching(_ type: WatchedType, id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + func getUsersWatching(_ type: WatchedType, id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "\(type)/\(id)/watching", withQuery: [:], isAuthorized: false, diff --git a/Common/Wrapper/Shows.swift b/Common/Wrapper/Shows.swift index 53c70d3..ba290ee 100644 --- a/Common/Wrapper/Shows.swift +++ b/Common/Wrapper/Shows.swift @@ -18,7 +18,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getTrendingShows(pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], filters: [Filter]? = nil, completion: @escaping TrendingShowsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getTrendingShows(pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], filters: [Filter]? = nil, completion: @escaping TrendingShowsCompletionHandler) -> URLSessionDataTask? { return getTrending(.Shows, pagination: pagination, extended: extended, filters: filters, completion: completion) } @@ -30,7 +30,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getPopularShows(pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], filters: [Filter]? = nil, completion: @escaping paginatedCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getPopularShows(pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], filters: [Filter]? = nil, completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { return getPopular(.Shows, pagination: pagination, extended: extended, filters: filters, completion: completion) } @@ -42,7 +42,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getPlayedShows(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostShowsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getPlayedShows(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostShowsCompletionHandler) -> URLSessionDataTask? { return getPlayed(.Shows, period: period, pagination: pagination, completion: completion) } @@ -54,7 +54,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getWatchedShows(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostShowsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getWatchedShows(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostShowsCompletionHandler) -> URLSessionDataTask? { return getWatched(.Shows, period: period, pagination: pagination, completion: completion) } @@ -66,7 +66,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getCollectedShows(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostShowsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getCollectedShows(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostShowsCompletionHandler) -> URLSessionDataTask? { return getCollected(.Shows, pagination: pagination, completion: completion) } @@ -78,7 +78,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getAnticipatedShows(period: Period = .Weekly, pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], completion: @escaping AnticipatedShowCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getAnticipatedShows(period: Period = .Weekly, pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], completion: @escaping AnticipatedShowCompletionHandler) -> URLSessionDataTask? { return getAnticipated(.Shows, pagination: pagination, extended: extended, completion: completion) } @@ -90,7 +90,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getUpdatedShows(startDate: Date?, pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getUpdatedShows(startDate: Date?, pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { return getUpdated(.Shows, startDate: startDate, pagination: pagination, extended: extended, completion: completion) } @@ -101,7 +101,7 @@ extension TraktManager { */ @discardableResult - public func getUpdatedShowTraktIds(from startDate: Date, pagination: Pagination? = nil, completion: @escaping paginatedCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getUpdatedShowTraktIds(from startDate: Date, pagination: Pagination? = nil, completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { var query = [String: String]() // pagination @@ -127,7 +127,7 @@ extension TraktManager { **Note**: When getting `full` extended info, the `status` field can have a value of `returning series` (airing right now), `in production` (airing soon), `planned` (in development), `canceled`, or `ended`. */ @discardableResult - public func getShowSummary(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getShowSummary(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { return getSummary(.Shows, id: id, extended: extended, completion: completion) } @@ -139,7 +139,7 @@ extension TraktManager { - parameter id: Trakt.tv ID, Trakt.tv slug, or IMDB ID */ @discardableResult - public func getShowAliases(showID id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getShowAliases(showID id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { return getAliases(.Shows, id: id, completion: completion) } @@ -152,7 +152,7 @@ extension TraktManager { - parameter language: 2 character language code. Example: `es` */ @discardableResult - public func getShowTranslations(showID id: T, language: String?, completion: @escaping ShowTranslationsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getShowTranslations(showID id: T, language: String?, completion: @escaping ShowTranslationsCompletionHandler) -> URLSessionDataTask? { return getTranslations(.Shows, id: id, language: language, completion: completion) } @@ -164,7 +164,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getShowComments(showID id: T, pagination: Pagination? = nil, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getShowComments(showID id: T, pagination: Pagination? = nil, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTask? { return getComments(.Shows, id: id, pagination: pagination, completion: completion) } @@ -176,7 +176,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getListsContainingShow(showID id: T, listType: ListType? = nil, sortBy: ListSortType? = nil, pagination: Pagination? = nil, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getListsContainingShow(showID id: T, listType: ListType? = nil, sortBy: ListSortType? = nil, pagination: Pagination? = nil, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { var path = "shows/\(id)/lists" if let listType = listType { path += "/\(listType)" @@ -211,7 +211,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func getShowCollectionProgress(showID id: T, hidden: Bool = false, specials: Bool = false, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getShowCollectionProgress(showID id: T, hidden: Bool = false, specials: Bool = false, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/progress/collection", withQuery: ["hidden": "\(hidden)", @@ -230,7 +230,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func getShowWatchedProgress(showID id: T, hidden: Bool = false, specials: Bool = false, completion: @escaping ShowWatchedProgressCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getShowWatchedProgress(showID id: T, hidden: Bool = false, specials: Bool = false, completion: @escaping ShowWatchedProgressCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/progress/watched", withQuery: ["hidden": "\(hidden)", @@ -255,7 +255,7 @@ extension TraktManager { **Note**: This returns a lot of data, so please only use this extended parameter if you actually need it! */ @discardableResult - public func getPeopleInShow(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTaskProtocol? { + public func getPeopleInShow(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTask? { return getPeople(.Shows, id: id, extended: extended, completion: completion) } @@ -265,7 +265,7 @@ extension TraktManager { Returns rating (between 0 and 10) and distribution for a show. */ @discardableResult - public func getShowRatings(showID id: T, completion: @escaping RatingDistributionCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getShowRatings(showID id: T, completion: @escaping RatingDistributionCompletionHandler) -> URLSessionDataTask? { return getRatings(.Shows, id: id, completion: completion) } @@ -277,7 +277,7 @@ extension TraktManager { **Note**: We are continuing to improve this algorithm. */ @discardableResult - public func getRelatedShows(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getRelatedShows(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { return getRelated(.Shows, id: id, extended: extended, completion: completion) } @@ -287,7 +287,7 @@ extension TraktManager { Returns lots of show stats. */ @discardableResult - public func getShowStatistics(showID id: T, completion: @escaping statsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getShowStatistics(showID id: T, completion: @escaping statsCompletionHandler) -> URLSessionDataTask? { return getStatistics(.Shows, id: id, completion: completion) } @@ -297,7 +297,7 @@ extension TraktManager { Returns all users watching this show right now. */ @discardableResult - public func getUsersWatchingShow(showID id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getUsersWatchingShow(showID id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { return getUsersWatching(.Shows, id: id, completion: completion) } @@ -309,7 +309,7 @@ extension TraktManager { **Note**: If no episode is found, a 204 HTTP status code will be returned. */ @discardableResult - public func getNextEpisode(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getNextEpisode(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/next_episode", withQuery: ["extended": extended.queryString()], isAuthorized: false, @@ -326,7 +326,7 @@ extension TraktManager { **Note**: If no episode is found, a 204 HTTP status code will be returned. */ @discardableResult - public func getLastEpisode(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getLastEpisode(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/last_episode", withQuery: ["extended": extended.queryString()], isAuthorized: false, diff --git a/Common/Wrapper/Sync.swift b/Common/Wrapper/Sync.swift index b15679e..53954f7 100644 --- a/Common/Wrapper/Sync.swift +++ b/Common/Wrapper/Sync.swift @@ -19,10 +19,10 @@ extension TraktManager { - parameter completion: completion block - - returns: URLSessionDataTaskProtocol? + - returns: URLSessionDataTask? */ @discardableResult - public func lastActivities(completion: @escaping LastActivitiesCompletionHandler) -> URLSessionDataTaskProtocol? { + public func lastActivities(completion: @escaping LastActivitiesCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "sync/last_activities", withQuery: [:], isAuthorized: true, @@ -46,7 +46,7 @@ extension TraktManager { - parameter type: Possible Values: .Movies, .Episodes */ @discardableResult - public func getPlaybackProgress(type: WatchedType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getPlaybackProgress(type: WatchedType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "sync/playback/\(type)", withQuery: [:], isAuthorized: true, @@ -62,7 +62,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func removePlaybackItem(id: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { + public func removePlaybackItem(id: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "sync/playback/\(id)", withQuery: [:], isAuthorized: true, @@ -87,7 +87,7 @@ extension TraktManager { ✨ Extended Info */ @discardableResult - public func getCollection(type: WatchedType, extended: [ExtendedType] = [.Min], completion: @escaping CollectionCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getCollection(type: WatchedType, extended: [ExtendedType] = [.Min], completion: @escaping CollectionCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "sync/collection/\(type)", withQuery: ["extended": extended.queryString()], isAuthorized: true, @@ -112,7 +112,7 @@ extension TraktManager { - parameter episodes: Array of episode Trakt ids */ @discardableResult - public func addToCollection(movies: [CollectionId]? = nil, shows: [CollectionId]? = nil, seasons: [CollectionId]? = nil, episodes: [CollectionId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func addToCollection(movies: [CollectionId]? = nil, shows: [CollectionId]? = nil, seasons: [CollectionId]? = nil, episodes: [CollectionId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes) guard let request = post("sync/collection", body: body) else { return nil } return performRequest(request: request, completion: completion) @@ -131,7 +131,7 @@ extension TraktManager { - parameter episodes: Array of episode Trakt ids */ @discardableResult - public func removeFromCollection(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func removeFromCollection(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes) guard let request = post("sync/collection/remove", body: body) else { return nil } return performRequest(request: request, completion: completion) @@ -155,7 +155,7 @@ extension TraktManager { - parameter completion: completion handler */ @discardableResult - public func getWatchedShows(extended: [ExtendedType] = [.Min], completion: @escaping WatchedShowsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getWatchedShows(extended: [ExtendedType] = [.Min], completion: @escaping WatchedShowsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "sync/watched/shows", @@ -181,7 +181,7 @@ extension TraktManager { - parameter completion: completion handler */ @discardableResult - public func getWatchedMovies(extended: [ExtendedType] = [.Min], completion: @escaping WatchedMoviesCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getWatchedMovies(extended: [ExtendedType] = [.Min], completion: @escaping WatchedMoviesCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "sync/watched/movies", withQuery: ["extended": extended.queryString()], @@ -202,7 +202,7 @@ extension TraktManager { ✨ Extended Info */ @discardableResult - public func getHistory(type: WatchedType? = nil, traktID: Int? = nil, startAt: Date? = nil, endAt: Date? = nil, extended: [ExtendedType] = [.Min], pagination: Pagination? = nil, completion: @escaping HistoryCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getHistory(type: WatchedType? = nil, traktID: Int? = nil, startAt: Date? = nil, endAt: Date? = nil, extended: [ExtendedType] = [.Min], pagination: Pagination? = nil, completion: @escaping HistoryCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString()] @@ -254,7 +254,7 @@ extension TraktManager { - parameter completion: completion handler */ @discardableResult - public func addToHistory(movies: [AddToHistoryId]? = nil, shows: [AddToHistoryId]? = nil, seasons: [AddToHistoryId]? = nil, episodes: [AddToHistoryId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func addToHistory(movies: [AddToHistoryId]? = nil, shows: [AddToHistoryId]? = nil, seasons: [AddToHistoryId]? = nil, episodes: [AddToHistoryId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes) guard let request = post("sync/history", body: body) else { return nil } return performRequest(request: request, completion: completion) @@ -276,7 +276,7 @@ extension TraktManager { - parameter completion: completion handler */ @discardableResult - public func removeFromHistory(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, historyIDs: [Int]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func removeFromHistory(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, historyIDs: [Int]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes, ids: historyIDs) guard let request = post("sync/history/remove", body: body) else { return nil } return performRequest(request: request, completion: completion) @@ -293,7 +293,7 @@ extension TraktManager { - parameter rating: Filter for a specific rating */ @discardableResult - public func getRatings(type: WatchedType, rating: NSInteger?, completion: @escaping RatingsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getRatings(type: WatchedType, rating: NSInteger?, completion: @escaping RatingsCompletionHandler) -> URLSessionDataTask? { var path = "sync/ratings/\(type)" if let rating = rating { path += "/\(rating)" @@ -320,7 +320,7 @@ extension TraktManager { - parameter episodes: Array of episode Trakt ids */ @discardableResult - public func addRatings(movies: [RatingId]? = nil, shows: [RatingId]? = nil, seasons: [RatingId]? = nil, episodes: [RatingId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func addRatings(movies: [RatingId]? = nil, shows: [RatingId]? = nil, seasons: [RatingId]? = nil, episodes: [RatingId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes) guard let request = post("sync/ratings", body: body) else { return nil } return performRequest(request: request, completion: completion) @@ -337,7 +337,7 @@ extension TraktManager { - parameter episodes: Array of episode Trakt ids */ @discardableResult - public func removeRatings(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func removeRatings(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes) guard let request = post("sync/ratings/remove", body: body) else { return nil } return performRequest(request: request, completion: completion) @@ -358,7 +358,7 @@ extension TraktManager { ✨ Extended Info */ @discardableResult - public func getWatchlist(watchType: WatchedType, pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], completion: @escaping WatchlistCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getWatchlist(watchType: WatchedType, pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], completion: @escaping WatchlistCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString()] @@ -389,7 +389,7 @@ extension TraktManager { - parameter episodes: Array of episode Trakt ids */ @discardableResult - public func addToWatchlist(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func addToWatchlist(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes) guard let request = post("sync/watchlist", body: body) else { completion(.error(error: nil)); return nil } return performRequest(request: request, completion: completion) @@ -408,7 +408,7 @@ extension TraktManager { - parameter episodes: Array of episode Trakt ids */ @discardableResult - public func removeFromWatchlist(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func removeFromWatchlist(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes) guard let request = post("sync/watchlist/remove", body: body) else { completion(.error(error: nil)); return nil } return performRequest(request: request, completion: completion) diff --git a/Common/Wrapper/TraktManager.swift b/Common/Wrapper/TraktManager.swift index 35f63cf..efe3994 100644 --- a/Common/Wrapper/TraktManager.swift +++ b/Common/Wrapper/TraktManager.swift @@ -13,8 +13,8 @@ public extension Notification.Name { static let TraktAccountStatusDidChange = Notification.Name(rawValue: "signedInToTrakt") } -@preconcurrency -public class TraktManager { +public final class TraktManager: Sendable { +//public final class TraktManager: @unchecked Sendable { // TODO List: // 1. Create a limit object, double check every paginated API call is marked as paginated @@ -73,51 +73,61 @@ public class TraktManager { private enum Constants { static let tokenExpirationDefaultsKey = "accessTokenExpirationDate" static let oneMonth: TimeInterval = 2629800 + static let accessTokenKey = "accessToken" + static let refreshTokenKey = "refreshToken" } static let logger = Logger(subsystem: "TraktKit", category: "TraktManager") // MARK: Internal - private var staging: Bool? - private var clientID: String? - private var clientSecret: String? - private var redirectURI: String? - private var baseURL: String? - private var APIBaseURL: String? - private var isWaitingToToken: Bool = false - let jsonEncoder: JSONEncoder = { + private let staging: Bool + private let clientId: String + private let clientSecret: String + private let redirectURI: String + private let apiHost: String + + internal let jsonEncoder: JSONEncoder = { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 return encoder }() - // Keys - let accessTokenKey = "accessToken" - let refreshTokenKey = "refreshToken" - - let session: URLSessionProtocol + let session: URLSession public lazy var explore: ExploreResource = ExploreResource(traktManager: self) // MARK: Public - @preconcurrency - public static let sharedManager = TraktManager() + public static let sharedManager = TraktManager(clientId: "", clientSecret: "", redirectURI: "") public var isSignedIn: Bool { get { return accessToken != nil } } - public var oauthURL: URL? + public var oauthURL: URL? { + var urlComponents = URLComponents() + urlComponents.scheme = "https" + urlComponents.host = staging ? "staging.trakt.tv" : "trakt.tv" + urlComponents.path = "/oauth/authorize" + urlComponents.queryItems = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientId), + URLQueryItem(name: "redirect_uri", value: redirectURI), + ] + return urlComponents.url + } + + /// Cached access token so that we don't have to fetch from the keychain repeatedly. + nonisolated(unsafe) private var _accessToken: String? public var accessToken: String? { get { if _accessToken != nil { return _accessToken } - if let accessTokenData = MLKeychain.loadData(forKey: accessTokenKey) { + if let accessTokenData = MLKeychain.loadData(forKey: Constants.accessTokenKey) { if let accessTokenString = String(data: accessTokenData, encoding: .utf8) { _accessToken = accessTokenString return accessTokenString @@ -131,22 +141,24 @@ public class TraktManager { _accessToken = newValue if newValue == nil { // Remove from keychain - MLKeychain.deleteItem(forKey: accessTokenKey) + MLKeychain.deleteItem(forKey: Constants.accessTokenKey) } else { // Save to keychain - let succeeded = MLKeychain.setString(value: newValue!, forKey: accessTokenKey) + let succeeded = MLKeychain.setString(value: newValue!, forKey: Constants.accessTokenKey) Self.logger.debug("Saved access token \(succeeded ? "successfully" : "failed")") } } } + /// Cached refresh token so that we don't have to fetch from the keychain repeatedly. + nonisolated(unsafe) private var _refreshToken: String? public var refreshToken: String? { get { if _refreshToken != nil { return _refreshToken } - if let refreshTokenData = MLKeychain.loadData(forKey: refreshTokenKey) { + if let refreshTokenData = MLKeychain.loadData(forKey: Constants.refreshTokenKey) { if let refreshTokenString = String.init(data: refreshTokenData, encoding: .utf8) { _refreshToken = refreshTokenString return refreshTokenString @@ -160,10 +172,10 @@ public class TraktManager { _refreshToken = newValue if newValue == nil { // Remove from keychain - MLKeychain.deleteItem(forKey: refreshTokenKey) + MLKeychain.deleteItem(forKey: Constants.refreshTokenKey) } else { // Save to keychain - let succeeded = MLKeychain.setString(value: newValue!, forKey: refreshTokenKey) + let succeeded = MLKeychain.setString(value: newValue!, forKey: Constants.refreshTokenKey) Self.logger.debug("Saved refresh token \(succeeded ? "successfully" : "failed")") } } @@ -171,23 +183,23 @@ public class TraktManager { // MARK: - Lifecycle - public init(session: URLSessionProtocol = URLSession(configuration: .default)) { + public init( + session: URLSession = URLSession(configuration: .default), + staging: Bool = false, + clientId: String, + clientSecret: String, + redirectURI: String + ) { self.session = session + self.staging = staging + self.clientId = clientId + self.clientSecret = clientSecret + self.redirectURI = redirectURI + self.apiHost = staging ? "api-staging.trakt.tv" : "api.trakt.tv" } // MARK: - Setup - public func set(clientID: String, clientSecret secret: String, redirectURI: String, staging: Bool = false) { - self.clientID = clientID - self.clientSecret = secret - self.redirectURI = redirectURI - self.staging = staging - - self.baseURL = !staging ? "trakt.tv" : "staging.trakt.tv" - self.APIBaseURL = !staging ? "api.trakt.tv" : "api-staging.trakt.tv" - self.oauthURL = URL(string: "https://\(baseURL!)/oauth/authorize?response_type=code&client_id=\(clientID)&redirect_uri=\(redirectURI)") - } - internal func createErrorWithStatusCode(_ statusCode: Int) -> NSError { let message = if let traktMessage = StatusCodes.message(for: statusCode) { traktMessage @@ -218,9 +230,7 @@ public class TraktManager { request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.addValue("2", forHTTPHeaderField: "trakt-api-version") - if let clientID { - request.addValue(clientID, forHTTPHeaderField: "trakt-api-key") - } + request.addValue(clientId, forHTTPHeaderField: "trakt-api-key") if authorization { if let accessToken { @@ -233,10 +243,9 @@ public class TraktManager { return request } - internal func mutableRequest(forPath path: String, withQuery query: [String: String], isAuthorized authorized: Bool, withHTTPMethod httpMethod: Method) throws -> URLRequest? { - guard let apiBaseURL = APIBaseURL else { throw TraktKitError.missingClientInfo } - let urlString = "https://\(apiBaseURL)/" + path - guard var components = URLComponents(string: urlString) else { return nil } + internal func mutableRequest(forPath path: String, withQuery query: [String: String], isAuthorized authorized: Bool, withHTTPMethod httpMethod: Method) throws -> URLRequest { + let urlString = "https://\(apiHost)/" + path + guard var components = URLComponents(string: urlString) else { throw TraktKitError.malformedURL } if query.isEmpty == false { var queryItems: [URLQueryItem] = [] @@ -246,19 +255,17 @@ public class TraktManager { components.queryItems = queryItems } - guard let url = components.url else { return nil } + guard let url = components.url else { throw TraktKitError.malformedURL } var request = URLRequest(url: url) request.httpMethod = httpMethod.rawValue request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.addValue("2", forHTTPHeaderField: "trakt-api-version") - if let clientID = clientID { - request.addValue(clientID, forHTTPHeaderField: "trakt-api-key") - } + request.addValue(clientId, forHTTPHeaderField: "trakt-api-key") if authorized { - if let accessToken = accessToken { + if let accessToken { request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } } @@ -267,8 +274,7 @@ public class TraktManager { } func post(_ path: String, query: [String: String] = [:], body: Body) -> URLRequest? { - guard let apiBaseURL = APIBaseURL else { preconditionFailure("Call `set(clientID:clientSecret:redirectURI:staging:)` before making any API requests") } - let urlString = "https://\(apiBaseURL)/" + path + let urlString = "https://\(apiHost)/" + path guard var components = URLComponents(string: urlString) else { return nil } if query.isEmpty == false { var queryItems: [URLQueryItem] = [] @@ -284,11 +290,9 @@ public class TraktManager { request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.addValue("2", forHTTPHeaderField: "trakt-api-version") - if let clientID = clientID { - request.addValue(clientID, forHTTPHeaderField: "trakt-api-key") - } + request.addValue(clientId, forHTTPHeaderField: "trakt-api-key") - if let accessToken = accessToken { + if let accessToken { request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } @@ -303,16 +307,7 @@ public class TraktManager { // MARK: - Authentication public func getToken(authorizationCode code: String) async throws -> AuthenticationInfo { - guard - let baseURL, - let clientID, - let clientSecret, - let redirectURI = redirectURI - else { - throw TraktKitError.missingClientInfo - } - - let urlString = "https://\(baseURL)/oauth/token" + let urlString = "https://\(apiHost)/oauth/token" guard let url = URL(string: urlString) else { throw TraktKitError.malformedURL } @@ -320,7 +315,7 @@ public class TraktManager { let json = [ "code": code, - "client_id": clientID, + "client_id": clientId, "client_secret": clientSecret, "redirect_uri": redirectURI, "grant_type": "authorization_code", @@ -335,18 +330,12 @@ public class TraktManager { // MARK: - Authentication - Devices public func getAppCode() async throws -> DeviceCode { - guard - let APIBaseURL, - let clientID - else { - throw TraktKitError.missingClientInfo - } - let urlString = "https://\(APIBaseURL)/oauth/device/code/" + let urlString = "https://\(apiHost)/oauth/device/code/" guard let url = URL(string: urlString) else { throw TraktKitError.malformedURL } - let json = ["client_id": clientID] + let json = ["client_id": clientId] var request = try mutableRequestForURL(url, authorization: false, HTTPMethod: .POST) request.httpBody = try JSONSerialization.data(withJSONObject: json, options: []) @@ -394,15 +383,7 @@ public class TraktManager { private func requestAccessToken(code: String) async throws -> (AuthenticationInfo?, Int) { // Build request - guard - let APIBaseURL, - let clientID, - let clientSecret - else { - throw TraktKitError.missingClientInfo - } - - let urlString = "https://\(APIBaseURL)/oauth/device/token" + let urlString = "https://\(apiHost)/oauth/device/token" guard let url = URL(string: urlString) else { throw TraktKitError.malformedURL } @@ -410,7 +391,7 @@ public class TraktManager { let json = [ "code": code, - "client_id": clientID, + "client_id": clientId, "client_secret": clientSecret, ] request.httpBody = try JSONSerialization.data(withJSONObject: json, options: []) @@ -462,26 +443,17 @@ public class TraktManager { Use the `refresh_token` to get a new `access_token` without asking the user to re-authenticate. The `access_token` is valid for 24 hours before it needs to be refreshed again. */ public func getAccessTokenFromRefreshToken() async throws { - guard - let baseURL, - let clientID, - let clientSecret, - let redirectURI - else { - throw TraktKitError.missingClientInfo - } - guard let refreshToken else { throw TraktKitError.invalidRefreshToken } // Create request - guard let url = URL(string: "https://\(baseURL)/oauth/token") else { + guard let url = URL(string: "https://\(apiHost)/oauth/token") else { throw TraktKitError.malformedURL } var request = try mutableRequestForURL(url, authorization: false, HTTPMethod: .POST) let json = [ "refresh_token": refreshToken, - "client_id": clientID, + "client_id": clientId, "client_secret": clientSecret, "redirect_uri": redirectURI, "grant_type": "refresh_token", diff --git a/Common/Wrapper/URLSessionProtocol.swift b/Common/Wrapper/URLSessionProtocol.swift deleted file mode 100644 index 3d29d6d..0000000 --- a/Common/Wrapper/URLSessionProtocol.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// URLSessionProtocol.swift -// TraktKit -// -// Created by Maximilian Litteral on 3/11/18. -// Copyright © 2018 Maximilian Litteral. All rights reserved. -// - -import Foundation - -public protocol URLSessionProtocol { - typealias DataTaskResult = (Data?, URLResponse?, Error?) -> Void - - func _dataTask(with request: URLRequest, completion: @escaping DataTaskResult) -> URLSessionDataTaskProtocol - func data(for request: URLRequest) async throws -> (Data, URLResponse) -} - -public protocol URLSessionDataTaskProtocol { - func resume() - func cancel() -} - -// MARK: Conform to protocols - -extension URLSession: URLSessionProtocol { - public func _dataTask(with request: URLRequest, completion: @escaping DataTaskResult) -> URLSessionDataTaskProtocol { - dataTask(with: request, completionHandler: completion) as URLSessionDataTaskProtocol - } - - public func data(for request: URLRequest) async throws -> (Data, URLResponse) { - try await data(for: request, delegate: nil) - } -} - -extension URLSessionDataTask: URLSessionDataTaskProtocol {} - -// MARK: MOCK - -class MockURLSession: URLSessionProtocol { - var nextDataTask = MockURLSessionDataTask() - var nextData: Data? - var nextStatusCode: Int = StatusCodes.Success - var nextError: Error? - - private(set) var lastURL: URL? - - func successHttpURLResponse(request: URLRequest) -> URLResponse { - return HTTPURLResponse(url: request.url!, statusCode: nextStatusCode, httpVersion: "HTTP/1.1", headerFields: nil)! - } - - public func _dataTask(with request: URLRequest, completion: @escaping DataTaskResult) -> URLSessionDataTaskProtocol { - lastURL = request.url - completion(nextData, successHttpURLResponse(request: request), nextError) - return nextDataTask - } - - public func data(for request: URLRequest) async throws -> (Data, URLResponse) { - lastURL = request.url - - if let nextData = nextData { - return (nextData, successHttpURLResponse(request: request)) - } else if let nextError = nextError { - throw nextError - } else { - fatalError("No error or data") - } - } -} - -class MockURLSessionDataTask: URLSessionDataTaskProtocol { - private(set) var resumeWasCalled = false - private(set) var cancelWasCalled = false - - func resume() { - resumeWasCalled = true - } - - func cancel() { - cancelWasCalled = true - } -} diff --git a/Common/Wrapper/Users.swift b/Common/Wrapper/Users.swift index 28309e6..7a85435 100644 --- a/Common/Wrapper/Users.swift +++ b/Common/Wrapper/Users.swift @@ -29,7 +29,7 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func getSettings(completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getSettings(completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "users/settings", withQuery: [:], isAuthorized: true, @@ -45,7 +45,7 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func getFollowRequests(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getFollowRequests(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "users/requests", withQuery: [:], isAuthorized: true, @@ -63,7 +63,7 @@ extension TraktManager { - parameter id: ID of the follower request. Example: `123`. */ @discardableResult - public func approveFollowRequest(requestID id: NSNumber, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { + public func approveFollowRequest(requestID id: NSNumber, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "users/requests/\(id)", withQuery: [:], isAuthorized: true, @@ -79,7 +79,7 @@ extension TraktManager { - parameter id: ID of the follower request. Example: `123`. */ @discardableResult - public func denyFollowRequest(requestID id: NSNumber, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { + public func denyFollowRequest(requestID id: NSNumber, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "users/requests/\(id)", withQuery: [:], isAuthorized: true, @@ -97,7 +97,7 @@ extension TraktManager { ✨ Extended Info */ @discardableResult - public func hiddenItems(section: SectionType, type: HiddenItemsType? = nil, extended: [ExtendedType] = [.Min], pagination: Pagination? = nil, completion: @escaping HiddenItemsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func hiddenItems(section: SectionType, type: HiddenItemsType? = nil, extended: [ExtendedType] = [.Min], pagination: Pagination? = nil, completion: @escaping HiddenItemsCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString()] if let type = type { query["type"] = type.rawValue @@ -123,7 +123,7 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func hide(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, from section: SectionType, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func hide(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, from section: SectionType, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons) guard let request = post("users/hidden/\(section.rawValue)", body: body) else { return nil } return performRequest(request: request, completion: completion) @@ -135,7 +135,7 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func unhide(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, from section: SectionType, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func unhide(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, from section: SectionType, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons) guard let request = post("users/hidden/\(section.rawValue)/remove", body: body) else { return nil } return performRequest(request: request, completion: completion) @@ -152,7 +152,7 @@ extension TraktManager { - Parameter type: Possible values: comments, lists. */ @discardableResult - public func getLikes(type: LikeType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getLikes(type: LikeType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "users/likes/\(type.rawValue)", withQuery: [:], isAuthorized: true, @@ -168,7 +168,7 @@ extension TraktManager { 🔓 OAuth Optional */ @discardableResult - public func getUserProfile(username: String = "me", extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getUserProfile(username: String = "me", extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { let authorization = username == "me" ? true : false guard let request = try? mutableRequest(forPath: "users/\(username)", withQuery: ["extended": extended.queryString()], @@ -187,7 +187,7 @@ extension TraktManager { 🔓 OAuth Optional */ @discardableResult - public func getUserCollection(username: String = "me", type: MediaType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getUserCollection(username: String = "me", type: MediaType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { let authorization = username == "me" ? true : false guard let request = try? mutableRequest(forPath: "users/\(username)/collection/\(type.rawValue)", withQuery: [:], @@ -205,7 +205,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getUserComments(username: String = "me", commentType: CommentType? = nil, type: Type2? = nil, pagination: Pagination? = nil, completion: @escaping UserCommentsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getUserComments(username: String = "me", commentType: CommentType? = nil, type: Type2? = nil, pagination: Pagination? = nil, completion: @escaping UserCommentsCompletionHandler) -> URLSessionDataTask? { let authorization = username == "me" ? true : false var path = "users/\(username)/comments" if let commentType = commentType { @@ -241,7 +241,7 @@ extension TraktManager { 🔓 OAuth Optional */ @discardableResult - public func getCustomLists(username: String = "me", completion: @escaping ListsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getCustomLists(username: String = "me", completion: @escaping ListsCompletionHandler) -> URLSessionDataTask? { let authorization = username == "me" ? true : false guard let request = try? mutableRequest(forPath: "users/\(username)/lists", @@ -263,7 +263,7 @@ extension TraktManager { - parameter allowComments: Are comments allowed? */ @discardableResult - public func createCustomList(listName: String, listDescription: String, privacy: String = "private", displayNumbers: Bool = false, allowComments: Bool = true, completion: @escaping ListCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func createCustomList(listName: String, listDescription: String, privacy: String = "private", displayNumbers: Bool = false, allowComments: Bool = true, completion: @escaping ListCompletionHandler) throws -> URLSessionDataTask? { // JSON let json: [String: Any] = [ @@ -289,7 +289,7 @@ extension TraktManager { 🔓 OAuth Optional */ @discardableResult - public func getCustomList(username: String = "me", listID: T, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getCustomList(username: String = "me", listID: T, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { let authorization = username == "me" ? true : false guard let request = try? mutableRequest(forPath: "users/\(username)/lists/\(listID)", @@ -305,7 +305,7 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func updateCustomList(listID: T, listName: String? = nil, listDescription: String? = nil, privacy: String? = nil, displayNumbers: Bool? = nil, allowComments: Bool? = nil, completion: @escaping ListCompletionHandler) throws -> URLSessionDataTaskProtocol? { + public func updateCustomList(listID: T, listName: String? = nil, listDescription: String? = nil, privacy: String? = nil, displayNumbers: Bool? = nil, allowComments: Bool? = nil, completion: @escaping ListCompletionHandler) throws -> URLSessionDataTask? { // JSON var json = [String: Any]() @@ -328,7 +328,7 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func deleteCustomList(username: String = "me", listID: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { + public func deleteCustomList(username: String = "me", listID: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "users/\(username)/lists/\(listID)", withQuery: [:], @@ -345,7 +345,7 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func likeList(username: String = "me", listID: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { + public func likeList(username: String = "me", listID: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "users/\(username)/lists/\(listID)/like", withQuery: [:], @@ -360,7 +360,7 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func removeListLike(username: String = "me", listID: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { + public func removeListLike(username: String = "me", listID: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "users/\(username)/lists/\(listID)/like", withQuery: [:], @@ -377,7 +377,7 @@ extension TraktManager { 🔓 OAuth Optional */ @discardableResult - public func getItemsForCustomList(username: String = "me", listID: T, type: [ListItemType]? = nil, extended: [ExtendedType] = [.Min], completion: @escaping ListItemCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getItemsForCustomList(username: String = "me", listID: T, type: [ListItemType]? = nil, extended: [ExtendedType] = [.Min], completion: @escaping ListItemCompletionHandler) -> URLSessionDataTask? { let authorization = username == "me" ? true : false var path = "users/\(username)/lists/\(listID)/items" @@ -405,7 +405,7 @@ extension TraktManager { - parameter people: Array of people Trakt ids */ @discardableResult - public func addItemToCustomList(username: String = "me", listID: T, movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, people: [SyncId]? = nil, completion: @escaping AddListItemCompletion) throws -> URLSessionDataTaskProtocol? { + public func addItemToCustomList(username: String = "me", listID: T, movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, people: [SyncId]? = nil, completion: @escaping AddListItemCompletion) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes, people: people) guard let request = post("users/\(username)/lists/\(listID)/items", body: body) else { return nil } return performRequest(request: request, completion: completion) @@ -425,7 +425,7 @@ extension TraktManager { - parameter people: Array of people Trakt ids */ @discardableResult - public func removeItemFromCustomList(username: String = "me", listID: T, movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, people: [SyncId]? = nil, completion: @escaping RemoveListItemCompletion) throws -> URLSessionDataTaskProtocol? { + public func removeItemFromCustomList(username: String = "me", listID: T, movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, people: [SyncId]? = nil, completion: @escaping RemoveListItemCompletion) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes, people: people) guard let request = post("users/\(username)/lists/\(listID)/items/remove", body: body) else { return nil } return performRequest(request: request, completion: completion) @@ -439,7 +439,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getUserAllListComments(username: String = "me", listID: String, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getUserAllListComments(username: String = "me", listID: String, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "users/\(username)/lists/\(listID)/comments", withQuery: [:], @@ -458,7 +458,7 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func followUser(username: String, completion: @escaping FollowUserCompletion) -> URLSessionDataTaskProtocol? { + public func followUser(username: String, completion: @escaping FollowUserCompletion) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "users/\(username)/follow", withQuery: [:], isAuthorized: true, @@ -472,7 +472,7 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func unfollowUser(username: String, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTaskProtocol? { + public func unfollowUser(username: String, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "users/\(username)/follow", withQuery: [:], isAuthorized: true, @@ -488,7 +488,7 @@ extension TraktManager { 🔓 OAuth Optional */ @discardableResult - public func getUserFollowers(username: String = "me", completion: @escaping FollowersCompletion) -> URLSessionDataTaskProtocol? { + public func getUserFollowers(username: String = "me", completion: @escaping FollowersCompletion) -> URLSessionDataTask? { let authorization = username == "me" ? true : false guard let request = try? mutableRequest(forPath: "users/\(username)/followers", withQuery: [:], @@ -505,7 +505,7 @@ extension TraktManager { 🔓 OAuth Optional */ @discardableResult - public func getUserFollowing(username: String = "me", completion: @escaping FollowersCompletion) -> URLSessionDataTaskProtocol? { + public func getUserFollowing(username: String = "me", completion: @escaping FollowersCompletion) -> URLSessionDataTask? { let authorization = username == "me" ? true : false guard let request = try? mutableRequest(forPath: "users/\(username)/following", @@ -523,7 +523,7 @@ extension TraktManager { 🔓 OAuth Optional */ @discardableResult - public func getUserFriends(username: String = "me", completion: @escaping FriendsCompletion) -> URLSessionDataTaskProtocol? { + public func getUserFriends(username: String = "me", completion: @escaping FriendsCompletion) -> URLSessionDataTask? { let authorization = username == "me" ? true : false guard let request = try? mutableRequest(forPath: "users/\(username)/friends", withQuery: [:], @@ -544,7 +544,7 @@ extension TraktManager { ✨ Extended Info */ @discardableResult - public func getUserWatchedHistory(username: String = "me", type: WatchedType? = nil, traktId: Int? = nil, startAt: Date? = nil, endAt: Date? = nil, extended: [ExtendedType] = [.Min], pagination: Pagination? = nil, completion: @escaping HistoryCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getUserWatchedHistory(username: String = "me", type: WatchedType? = nil, traktId: Int? = nil, startAt: Date? = nil, endAt: Date? = nil, extended: [ExtendedType] = [.Min], pagination: Pagination? = nil, completion: @escaping HistoryCompletionHandler) -> URLSessionDataTask? { var path = "users/\(username)/history" if let type = type { @@ -588,7 +588,7 @@ extension TraktManager { 🔓 OAuth Optional */ @discardableResult - public func getUserRatings(username: String = "me", type: MediaType? = nil, rating: NSNumber? = nil, completion: @escaping RatingsCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getUserRatings(username: String = "me", type: MediaType? = nil, rating: NSNumber? = nil, completion: @escaping RatingsCompletionHandler) -> URLSessionDataTask? { var path = "users/\(username)/ratings" @@ -617,7 +617,7 @@ extension TraktManager { 🔓 OAuth Optional */ @discardableResult - public func getUserWatchlist(username: String = "me", type: WatchedType, extended: [ExtendedType] = [.Min], completion: @escaping ListItemCompletionHandler) -> URLSessionDataTaskProtocol? { + public func getUserWatchlist(username: String = "me", type: WatchedType, extended: [ExtendedType] = [.Min], completion: @escaping ListItemCompletionHandler) -> URLSessionDataTask? { let authorization = username == "me" ? true : false guard let request = try? mutableRequest(forPath: "users/\(username)/watchlist/\(type.rawValue)", withQuery: ["extended": extended.queryString()], @@ -634,7 +634,7 @@ extension TraktManager { 🔓 OAuth Optional */ @discardableResult - public func getUserWatching(username: String = "me", completion: @escaping WatchingCompletion) -> URLSessionDataTaskProtocol? { + public func getUserWatching(username: String = "me", completion: @escaping WatchingCompletion) -> URLSessionDataTask? { // Should this function have a special completion handler? If it returns no data it is obvious that the user // is not watching anything, but checking a boolean in the completion block is also nice let authorization = username == "me" ? true : false @@ -653,7 +653,7 @@ extension TraktManager { 🔓 OAuth Optional */ @discardableResult - public func getUserWatched(username: String = "me", type: MediaType, extended: [ExtendedType] = [.Min], completion: @escaping UserWatchedCompletion) -> URLSessionDataTaskProtocol? { + public func getUserWatched(username: String = "me", type: MediaType, extended: [ExtendedType] = [.Min], completion: @escaping UserWatchedCompletion) -> URLSessionDataTask? { let authorization = username == "me" ? true : false guard var request = try? mutableRequest(forPath: "users/\(username)/watched/\(type.rawValue)", withQuery: ["extended": extended.queryString()], @@ -671,7 +671,7 @@ extension TraktManager { 🔓 OAuth Optional */ @discardableResult - public func getUserStats(username: String = "me", completion: @escaping UserStatsCompletion) -> URLSessionDataTaskProtocol? { + public func getUserStats(username: String = "me", completion: @escaping UserStatsCompletion) -> URLSessionDataTask? { let authorization = username == "me" ? true : false guard let request = try? mutableRequest(forPath: "users/\(username)/stats", withQuery: [:], diff --git a/Tests/TraktKitTests/AuthenticationInfoTests.swift b/Tests/TraktKitTests/AuthenticationInfoTests.swift index 8accbd1..3dc8f38 100644 --- a/Tests/TraktKitTests/AuthenticationInfoTests.swift +++ b/Tests/TraktKitTests/AuthenticationInfoTests.swift @@ -9,8 +9,7 @@ import XCTest @testable import TraktKit -class AuthenticationInfoTests: XCTestCase { - +final class AuthenticationInfoTests: XCTestCase { func testParsingAuthenticationInfo() { let authenticationInfo = decode("AuthenticationInfo", to: AuthenticationInfo.self)! XCTAssertEqual(authenticationInfo.accessToken, "dbaf9757982a9e738f05d249b7b5b4a266b3a139049317c4909f2f263572c781") diff --git a/Tests/TraktKitTests/CalendarTests.swift b/Tests/TraktKitTests/CalendarTests.swift index 96d9560..9bd126e 100644 --- a/Tests/TraktKitTests/CalendarTests.swift +++ b/Tests/TraktKitTests/CalendarTests.swift @@ -10,22 +10,12 @@ import XCTest import Foundation @testable import TraktKit -class CalendarTests: XCTestCase { - - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) - - override func tearDown() { - super.tearDown() - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil - } +final class CalendarTests: TraktTestCase { // MARK: - My Shows - func test_get_my_shows() { - session.nextData = jsonData(named: "test_get_my_shows") + func test_get_my_shows() throws { + try mock(.GET, "https://api.trakt.tv/calendars/my/shows/2014-09-01/7", result: .success(jsonData(named: "test_get_my_shows"))) let expectation = XCTestExpectation(description: "My Shows") traktManager.myShows(startDateString: "2014-09-01", days: 7) { result in @@ -37,8 +27,7 @@ class CalendarTests: XCTestCase { let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/calendars/my/shows/2014-09-01/7") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -49,8 +38,8 @@ class CalendarTests: XCTestCase { // MARK: - My New Shows - func test_get_my_new_shows() { - session.nextData = jsonData(named: "test_get_my_new_shows") + func test_get_my_new_shows() throws { + try mock(.GET, "https://api.trakt.tv/calendars/my/shows/new/2014-09-01/7", result: .success(jsonData(named: "test_get_my_new_shows"))) let expectation = XCTestExpectation(description: "My New Shows") traktManager.myNewShows(startDateString: "2014-09-01", days: 7) { result in @@ -62,8 +51,7 @@ class CalendarTests: XCTestCase { let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/calendars/my/shows/new/2014-09-01/7") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -74,8 +62,8 @@ class CalendarTests: XCTestCase { // MARK: - My Season Premiers - func test_get_my_season_premieres() { - session.nextData = jsonData(named: "test_get_my_season_premieres") + func test_get_my_season_premieres() throws { + try mock(.GET, "https://api.trakt.tv/calendars/my/shows/premieres/2014-09-01/7", result: .success(jsonData(named: "test_get_my_season_premieres"))) let expectation = XCTestExpectation(description: "My New Seasons") traktManager.mySeasonPremieres(startDateString: "2014-09-01", days: 7) { result in @@ -87,8 +75,7 @@ class CalendarTests: XCTestCase { let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/calendars/my/shows/premieres/2014-09-01/7") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -99,8 +86,8 @@ class CalendarTests: XCTestCase { // MARK: - My Movies - func test_get_my_movies() { - session.nextData = jsonData(named: "test_get_my_movies") + func test_get_my_movies() throws { + try mock(.GET, "https://api.trakt.tv/calendars/my/movies/2014-09-01/7", result: .success(jsonData(named: "test_get_my_movies"))) let expectation = XCTestExpectation(description: "My movies") traktManager.myMovies(startDateString: "2014-09-01", days: 7) { result in @@ -110,8 +97,7 @@ class CalendarTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/calendars/my/movies/2014-09-01/7") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -122,8 +108,8 @@ class CalendarTests: XCTestCase { // MARK: - My DVD - func test_get_my_dvd() { - session.nextData = jsonData(named: "test_get_my_dvd") + func test_get_my_dvd() throws { + try mock(.GET, "https://api.trakt.tv/calendars/my/dvd/2014-09-01/7", result: .success(jsonData(named: "test_get_my_dvd"))) let expectation = XCTestExpectation(description: "My dvds") traktManager.myDVDReleases(startDateString: "2014-09-01", days: 7) { result in @@ -133,8 +119,7 @@ class CalendarTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/calendars/my/dvd/2014-09-01/7") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -145,8 +130,8 @@ class CalendarTests: XCTestCase { // MARK: - All Shows - func test_get_all_shows() { - session.nextData = jsonData(named: "test_get_all_shows") + func test_get_all_shows() throws { + try mock(.GET, "https://api.trakt.tv/calendars/all/shows/2014-09-01/7", result: .success(jsonData(named: "test_get_all_shows"))) let expectation = XCTestExpectation(description: "All Shows") traktManager.allShows(startDateString: "2014-09-01", days: 7) { result in @@ -156,8 +141,7 @@ class CalendarTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/calendars/all/shows/2014-09-01/7") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -168,8 +152,8 @@ class CalendarTests: XCTestCase { // MARK: - All New Shows - func test_get_all_new_shows() { - session.nextData = jsonData(named: "test_get_all_new_shows") + func test_get_all_new_shows() throws { + try mock(.GET, "https://api.trakt.tv/calendars/all/shows/new/2014-09-01/7", result: .success(jsonData(named: "test_get_all_new_shows"))) let expectation = XCTestExpectation(description: "All New Shows") traktManager.allNewShows(startDateString: "2014-09-01", days: 7) { result in @@ -179,8 +163,7 @@ class CalendarTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/calendars/all/shows/new/2014-09-01/7") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -191,8 +174,8 @@ class CalendarTests: XCTestCase { // MARK: - All Season Premiers - func test_get_season_premieres() { - session.nextData = jsonData(named: "test_get_season_premieres") + func test_get_season_premieres() throws { + try mock(.GET, "https://api.trakt.tv/calendars/all/shows/premieres/2014-09-01/7", result: .success(jsonData(named: "test_get_season_premieres"))) let expectation = XCTestExpectation(description: "All Season Premieres") traktManager.allSeasonPremieres(startDateString: "2014-09-01", days: 7) { result in @@ -202,8 +185,7 @@ class CalendarTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/calendars/all/shows/premieres/2014-09-01/7") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -214,8 +196,8 @@ class CalendarTests: XCTestCase { // MARK: - All Movies - func test_get_all_movies() { - session.nextData = jsonData(named: "test_get_all_movies") + func test_get_all_movies() throws { + try mock(.GET, "https://api.trakt.tv/calendars/all/movies/2014-09-01/7?extended=min", result: .success(jsonData(named: "test_get_all_movies"))) let expectation = XCTestExpectation(description: "All Movies") traktManager.allMovies(startDateString: "2014-09-01", days: 7) { result in @@ -225,8 +207,7 @@ class CalendarTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/calendars/all/movies/2014-09-01/7?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -237,8 +218,8 @@ class CalendarTests: XCTestCase { // MARK: - All DVD - func test_get_all_dvd() { - session.nextData = jsonData(named: "test_get_all_dvd") + func test_get_all_dvd() throws { + try mock(.GET, "https://api.trakt.tv/calendars/all/dvd/2014-09-01/7", result: .success(jsonData(named: "test_get_all_dvd"))) let expectation = XCTestExpectation(description: "All DVDs") traktManager.allDVD(startDateString: "2014-09-01", days: 7) { result in @@ -248,8 +229,7 @@ class CalendarTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/calendars/all/dvd/2014-09-01/7") - + switch result { case .timedOut: XCTFail("Something isn't working") diff --git a/Tests/TraktKitTests/CertificationsTests.swift b/Tests/TraktKitTests/CertificationsTests.swift index 16109c1..1d23fce 100644 --- a/Tests/TraktKitTests/CertificationsTests.swift +++ b/Tests/TraktKitTests/CertificationsTests.swift @@ -10,20 +10,9 @@ import XCTest import Foundation @testable import TraktKit -class CertificationsTests: XCTestCase { - - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) - - override func tearDown() { - super.tearDown() - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil - } - - func test_get_certifications() { - session.nextData = jsonData(named: "test_get_certifications") +final class CertificationsTests: TraktTestCase { + func test_get_certifications() throws { + try mock(.GET, "https://api.trakt.tv/certifications", result: .success(jsonData(named: "test_get_certifications"))) let expectation = XCTestExpectation(description: "Get Certifications") traktManager.getCertifications { result in @@ -33,8 +22,7 @@ class CertificationsTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/certifications") - + switch result { case .timedOut: XCTFail("Something isn't working") diff --git a/Tests/TraktKitTests/CheckinTests.swift b/Tests/TraktKitTests/CheckinTests.swift index 6573ab1..d0e6ef7 100644 --- a/Tests/TraktKitTests/CheckinTests.swift +++ b/Tests/TraktKitTests/CheckinTests.swift @@ -10,25 +10,14 @@ import XCTest import Foundation @testable import TraktKit -class CheckinTests: XCTestCase { - - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) +final class CheckinTests: TraktTestCase { - override func tearDown() { - super.tearDown() - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil - } - - func test_checkin_movie() { - session.nextData = jsonData(named: "test_checkin_movie") - session.nextStatusCode = StatusCodes.SuccessNewResourceCreated + func test_checkin_movie() throws { + try mock(.POST, "https://api.trakt.tv/checkin", result: .success(jsonData(named: "test_checkin_movie"))) let expectation = XCTestExpectation(description: "Checkin a movie") let checkin = TraktCheckinBody(movie: SyncId(trakt: 12345)) - try! traktManager.checkIn(checkin) { result in + traktManager.checkIn(checkin) { result in if case .success(let checkin) = result { XCTAssertEqual(checkin.id, 3373536619) XCTAssertNotNil(checkin.movie) @@ -36,8 +25,7 @@ class CheckinTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/checkin") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -46,13 +34,12 @@ class CheckinTests: XCTestCase { } } - func test_checkin_episode() { - session.nextData = jsonData(named: "test_checkin_episode") - session.nextStatusCode = StatusCodes.SuccessNewResourceCreated + func test_checkin_episode() throws { + try mock(.POST, "https://api.trakt.tv/checkin", result: .success(jsonData(named: "test_checkin_episode"))) let expectation = XCTestExpectation(description: "Checkin a episode") let checkin = TraktCheckinBody(episode: SyncId(trakt: 12345)) - try! traktManager.checkIn(checkin) { result in + traktManager.checkIn(checkin) { result in if case .success(let checkin) = result { XCTAssertEqual(checkin.id, 3373536620) XCTAssertNotNil(checkin.episode) @@ -61,8 +48,7 @@ class CheckinTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/checkin") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -71,21 +57,19 @@ class CheckinTests: XCTestCase { } } - func test_already_checked_in() { - session.nextData = jsonData(named: "test_already_checked_in") - session.nextStatusCode = StatusCodes.Conflict + func test_already_checked_in() throws { + try mock(.POST, "https://api.trakt.tv/checkin", result: .success(jsonData(named: "test_already_checked_in")), httpCode: StatusCodes.Conflict) let expectation = XCTestExpectation(description: "Checkin an existing item") let checkin = TraktCheckinBody(episode: SyncId(trakt: 12345)) - try! traktManager.checkIn(checkin) { result in + traktManager.checkIn(checkin) { result in if case .checkedIn(let expiration) = result { XCTAssertEqual(expiration.dateString(withFormat: "YYYY-MM-dd"), "2014-10-15") expectation.fulfill() } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/checkin") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -94,8 +78,8 @@ class CheckinTests: XCTestCase { } } - func test_delete_active_checkins() { - session.nextStatusCode = StatusCodes.SuccessNoContentToReturn + func test_delete_active_checkins() throws { + try mock(.POST, "https://api.trakt.tv/checkin", result: .success(jsonData(named: "test_already_checked_in")), httpCode: StatusCodes.SuccessNoContentToReturn) let expectation = XCTestExpectation(description: "Delete active checkins") traktManager.deleteActiveCheckins { result in @@ -104,7 +88,6 @@ class CheckinTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/checkin") switch result { case .timedOut: diff --git a/Tests/TraktKitTests/CommentTests.swift b/Tests/TraktKitTests/CommentTests.swift index 043b66a..8908d83 100644 --- a/Tests/TraktKitTests/CommentTests.swift +++ b/Tests/TraktKitTests/CommentTests.swift @@ -10,33 +10,21 @@ import XCTest import Foundation @testable import TraktKit -class CommentTests: XCTestCase { - - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) - - override func tearDown() { - super.tearDown() - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil - } +final class CommentTests: TraktTestCase { // MARK: - Comments - func test_post_a_comment() { - session.nextData = jsonData(named: "test_post_a_comment") - session.nextStatusCode = StatusCodes.SuccessNewResourceCreated + func test_post_a_comment() throws { + try mock(.POST, "https://api.trakt.tv/comments", result: .success(jsonData(named: "test_post_a_comment"))) let expectation = XCTestExpectation(description: "Post a comment") - try! traktManager.postComment(movie: SyncId(trakt: 12345), comment: "Oh, I wasn't really listening.", isSpoiler: false) { result in + try traktManager.postComment(movie: SyncId(trakt: 12345), comment: "Oh, I wasn't really listening.", isSpoiler: false) { result in if case .success = result { expectation.fulfill() } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/comments") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -47,11 +35,11 @@ class CommentTests: XCTestCase { // MARK: - Comment - func test_get_a_comment() { - session.nextData = jsonData(named: "test_get_a_comment") + func test_get_a_comment() throws { + try mock(.GET, "https://api.trakt.tv/comments/417", result: .success(jsonData(named: "test_get_a_comment"))) let expectation = XCTestExpectation(description: "Get a comment") - traktManager.getComment(commentID: "417") { result in + try traktManager.getComment(commentID: "417") { result in if case .success(let comment) = result { XCTAssertEqual(comment.likes, 0) XCTAssertEqual(comment.userRating, 8) @@ -61,8 +49,7 @@ class CommentTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/comments/417") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -71,8 +58,8 @@ class CommentTests: XCTestCase { } } - func test_update_a_comment() { - session.nextData = jsonData(named: "test_update_a_comment") + func test_update_a_comment() throws { + try mock(.PUT, "https://api.trakt.tv/comments/417", result: .success(jsonData(named: "test_update_a_comment"))) let newComment = "Agreed, this show is awesome. AMC in general has awesome shows and I can't wait to see what they come up with next." @@ -84,8 +71,7 @@ class CommentTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/comments/417") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -94,17 +80,16 @@ class CommentTests: XCTestCase { } } - func test_delete_a_comment() { - session.nextStatusCode = StatusCodes.SuccessNoContentToReturn + func test_delete_a_comment() throws { + try mock(.DELETE, "https://api.trakt.tv/comments/417", result: .success(jsonData(named: "test_update_a_comment"))) let expectation = XCTestExpectation(description: "Delete a comment") - traktManager.deleteComment(commentID: "417") { result in + try traktManager.deleteComment(commentID: "417") { result in if case .success = result { expectation.fulfill() } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/comments/417") switch result { case .timedOut: @@ -116,19 +101,18 @@ class CommentTests: XCTestCase { // MARK: - Replies - func test_get_replies_for_comment() { - session.nextData = jsonData(named: "test_get_replies_for_comment") + func test_get_replies_for_comment() throws { + try mock(.GET, "https://api.trakt.tv/comments/417/replies", result: .success(jsonData(named: "test_get_replies_for_comment"))) let expectation = XCTestExpectation(description: "Get replies for comment") - traktManager.getReplies(commentID: "417") { result in + try traktManager.getReplies(commentID: "417") { result in if case .success(let replies) = result { XCTAssertEqual(replies.count, 1) expectation.fulfill() } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/comments/417/replies") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -137,21 +121,20 @@ class CommentTests: XCTestCase { } } - func test_post_reply_for_comment() { - session.nextData = jsonData(named: "test_post_reply_for_comment") + func test_post_reply_for_comment() throws { + try mock(.POST, "https://api.trakt.tv/comments/417/replies", result: .success(jsonData(named: "test_post_reply_for_comment"))) let reply = "Couldn't agree more with your review!" let expectation = XCTestExpectation(description: "Get replies for comment") - try! traktManager.postReply(commentID: "417", comment: reply) { result in + traktManager.postReply(commentID: "417", comment: reply) { result in if case .success(let postedReply) = result { XCTAssertEqual(postedReply.comment, reply) expectation.fulfill() } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/comments/417/replies") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -162,19 +145,18 @@ class CommentTests: XCTestCase { // MARK: - Item - func test_get_attached_media_item() { - session.nextData = jsonData(named: "test_get_attached_media_item") + func test_get_attached_media_item() throws { + try mock(.GET, "https://api.trakt.tv/comments/417/item", result: .success(jsonData(named: "test_get_attached_media_item"))) let expectation = XCTestExpectation(description: "Get attached media item") - traktManager.getAttachedMediaItem(commentID: "417") { result in + try traktManager.getAttachedMediaItem(commentID: "417") { result in if case .success(let mediaItem) = result { XCTAssertNotNil(mediaItem.show) expectation.fulfill() } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/comments/417/item") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -185,19 +167,18 @@ class CommentTests: XCTestCase { // MARK: - Likes - func test_users_who_liked_comment() { - session.nextData = jsonData(named: "test_users_who_liked_comment") + func test_users_who_liked_comment() throws { + try mock(.GET, "https://api.trakt.tv/comments/417/likes", result: .success(jsonData(named: "test_users_who_liked_comment"))) let expectation = XCTestExpectation(description: "Get users who liked comment") - traktManager.getUsersWhoLikedComment(commentID: "417") { result in + try traktManager.getUsersWhoLikedComment(commentID: "417") { result in if case .success(let users) = result { XCTAssertEqual(users.count, 2) expectation.fulfill() } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/comments/417/likes") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -208,17 +189,16 @@ class CommentTests: XCTestCase { // MARK: - Like - func test_like_a_comment() { - session.nextStatusCode = StatusCodes.SuccessNoContentToReturn + func test_like_a_comment() throws { + try mock(.POST, "https://api.trakt.tv/comments/417/like", result: .success(.init())) let expectation = XCTestExpectation(description: "Like a comment") - traktManager.likeComment(commentID: "417") { result in + try traktManager.likeComment(commentID: "417") { result in if case .success = result { expectation.fulfill() } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/comments/417/like") switch result { case .timedOut: @@ -228,17 +208,16 @@ class CommentTests: XCTestCase { } } - func test_remove_like() { - session.nextStatusCode = StatusCodes.SuccessNoContentToReturn + func test_remove_like() throws { + try mock(.DELETE, "https://api.trakt.tv/comments/417/like", result: .success(.init())) let expectation = XCTestExpectation(description: "Like a comment") - traktManager.removeLikeOnComment(commentID: "417") { result in + try traktManager.removeLikeOnComment(commentID: "417") { result in if case .success = result { expectation.fulfill() } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/comments/417/like") switch result { case .timedOut: @@ -250,19 +229,18 @@ class CommentTests: XCTestCase { // MARK: - Trending - func test_get_trending_comments() { - session.nextData = jsonData(named: "test_get_trending_comments") + func test_get_trending_comments() throws { + try mock(.GET, "https://api.trakt.tv/comments/trending/all/all?include_replies=true", result: .success(jsonData(named: "test_get_trending_comments"))) let expectation = XCTestExpectation(description: "Get trending comments") - traktManager.getTrendingComments(commentType: .all, mediaType: .All, includeReplies: true) { result in + try traktManager.getTrendingComments(commentType: .all, mediaType: .All, includeReplies: true) { result in if case .success(let trendingComments) = result { XCTAssertEqual(trendingComments.count, 5) expectation.fulfill() } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/comments/trending/all/all?include_replies=true") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -273,19 +251,18 @@ class CommentTests: XCTestCase { // MARK: - Recent - func test_get_recently_created_comments() { - session.nextData = jsonData(named: "test_get_recently_created_comments") + func test_get_recently_created_comments() throws { + try mock(.GET, "https://api.trakt.tv/comments/recent/all/all?include_replies=true", result: .success(jsonData(named: "test_get_recently_created_comments"))) let expectation = XCTestExpectation(description: "Get recently created comments") - traktManager.getRecentComments(commentType: .all, mediaType: .All, includeReplies: true) { result in + try traktManager.getRecentComments(commentType: .all, mediaType: .All, includeReplies: true) { result in if case .success(let recentComments) = result { XCTAssertEqual(recentComments.count, 5) expectation.fulfill() } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/comments/recent/all/all?include_replies=true") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -296,19 +273,18 @@ class CommentTests: XCTestCase { // MARK: - Updates - func test_get_recently_updated_comments() { - session.nextData = jsonData(named: "test_get_recently_updated_comments") + func test_get_recently_updated_comments() throws { + try mock(.GET, "https://api.trakt.tv/comments/updates/all/all?include_replies=true", result: .success(jsonData(named: "test_get_recently_updated_comments"))) let expectation = XCTestExpectation(description: "Get recently updated comments") - traktManager.getRecentlyUpdatedComments(commentType: .all, mediaType: .All, includeReplies: true) { result in + try traktManager.getRecentlyUpdatedComments(commentType: .all, mediaType: .All, includeReplies: true) { result in if case .success(let recentComments) = result { XCTAssertEqual(recentComments.count, 5) expectation.fulfill() } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/comments/updates/all/all?include_replies=true") - + switch result { case .timedOut: XCTFail("Something isn't working") diff --git a/Tests/TraktKitTests/EpisodeTests.swift b/Tests/TraktKitTests/EpisodeTests.swift index f5f500b..aab6be4 100644 --- a/Tests/TraktKitTests/EpisodeTests.swift +++ b/Tests/TraktKitTests/EpisodeTests.swift @@ -10,22 +10,12 @@ import XCTest import Foundation @testable import TraktKit -class EpisodeTests: XCTestCase { - - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) - - override func tearDown() { - super.tearDown() - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil - } - +final class EpisodeTests: TraktTestCase { + // MARK: - Summary - func test_get_min_episode() { - session.nextData = jsonData(named: "Episode_Min") + func test_get_min_episode() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1?extended=min", result: .success(jsonData(named: "Episode_Min"))) let expectation = XCTestExpectation(description: "EpisodeSummary") traktManager.getEpisodeSummary(showID: "game-of-thrones", seasonNumber: 1, episodeNumber: 1) { result in @@ -40,8 +30,7 @@ class EpisodeTests: XCTestCase { expectation.fulfill() } let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -50,8 +39,8 @@ class EpisodeTests: XCTestCase { } } - func test_get_full_episode() { - session.nextData = jsonData(named: "Episode_Full") + func test_get_full_episode() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1?extended=full", result: .success(jsonData(named: "Episode_Full"))) let expectation = XCTestExpectation(description: "EpisodeSummary") traktManager.getEpisodeSummary(showID: "game-of-thrones", seasonNumber: 1, episodeNumber: 1, extended: [.Full]) { result in @@ -70,8 +59,7 @@ class EpisodeTests: XCTestCase { expectation.fulfill() } let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1?extended=full") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -81,7 +69,7 @@ class EpisodeTests: XCTestCase { } func test_get_full_episode_async() async throws { - session.nextData = jsonData(named: "Episode_Full") + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1?extended=full", result: .success(jsonData(named: "Episode_Full"))) let episode = try await traktManager .show(id: "game-of-thrones") @@ -97,15 +85,14 @@ class EpisodeTests: XCTestCase { XCTAssertNotNil(episode.overview) XCTAssertNotNil(episode.firstAired) XCTAssertNotNil(episode.updatedAt) - XCTAssertEqual(episode.availableTranslations!, ["en"]) - - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1?extended=full") + XCTAssertEqual(episode.absoluteNumber, 1) + XCTAssertEqual(episode.availableTranslations, ["en"]) } // MARK: - Translations - func test_get_all_episode_translations() { - session.nextData = jsonData(named: "test_get_all_episode_translations") + func test_get_all_episode_translations() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1/translations", result: .success(jsonData(named: "test_get_all_episode_translations"))) let expectation = XCTestExpectation(description: "Get episode translations") traktManager.getEpisodeTranslations(showID: "game-of-thrones", seasonNumber: 1, episodeNumber: 1) { result in @@ -117,8 +104,7 @@ class EpisodeTests: XCTestCase { expectation.fulfill() } let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1/translations") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -129,8 +115,8 @@ class EpisodeTests: XCTestCase { // MARK: - Comments - func test_get_episode_comments() { - session.nextData = jsonData(named: "test_get_episode_comments") + func test_get_episode_comments() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1/comments", result: .success(jsonData(named: "test_get_episode_comments"))) let expectation = XCTestExpectation(description: "Get episode comments") traktManager.getEpisodeComments(showID: "game-of-thrones", seasonNumber: 1, episodeNumber: 1) { result in @@ -142,8 +128,7 @@ class EpisodeTests: XCTestCase { expectation.fulfill() } let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1/comments") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -154,8 +139,8 @@ class EpisodeTests: XCTestCase { // MARK: - Lists - func test_get_lists_containing_episode() { - session.nextData = jsonData(named: "test_get_lists_containing_episode") + func test_get_lists_containing_episode() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1/lists/official/added", result: .success(jsonData(named: "test_get_lists_containing_episode"))) let expectation = XCTestExpectation(description: "Get lists containing episode") traktManager.getListsContainingEpisode(showID: "game-of-thrones", seasonNumber: 1, episodeNumber: 1, listType: .official, sortBy: .added) { result in @@ -165,8 +150,7 @@ class EpisodeTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1/lists/official/added") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -175,10 +159,27 @@ class EpisodeTests: XCTestCase { } } + func test_get_listsContainingEpisode_async() async throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1/lists/official/added?extended=full", result: .success(jsonData(named: "test_get_lists_containing_episode"))) + + let route = traktManager + .show(id: "game-of-thrones") + .season(1).episode(1) + .containingLists() + .listType(.official) + .sort(by: .added) + .extend(.Full) + + XCTAssertEqual(route.path, "shows/game-of-thrones/seasons/1/episodes/1/lists/official/added") + + let lists = try await route.perform() + XCTAssertEqual(lists.count, 1) + } + // MARK: - Ratings - func test_get_episode_ratings() { - session.nextData = jsonData(named: "test_get_episode_ratings") + func test_get_episode_ratings() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1/ratings", result: .success(jsonData(named: "test_get_episode_ratings"))) let expectation = XCTestExpectation(description: "Get episode ratings") traktManager.getEpisodeRatings(showID: "game-of-thrones", seasonNumber: 1, episodeNumber: 1) { result in @@ -191,8 +192,7 @@ class EpisodeTests: XCTestCase { expectation.fulfill() } let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1/ratings") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -203,8 +203,8 @@ class EpisodeTests: XCTestCase { // MARK: - Stats - func test_get_episode_stats() { - session.nextData = jsonData(named: "test_get_episode_stats") + func test_get_episode_stats() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1/stats", result: .success(jsonData(named: "test_get_episode_stats"))) let expectation = XCTestExpectation(description: "Get episode stats") traktManager.getEpisodeStatistics(showID: "game-of-thrones", seasonNumber: 1, episodeNumber: 1) { result in @@ -222,8 +222,7 @@ class EpisodeTests: XCTestCase { expectation.fulfill() } let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1/stats") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -234,8 +233,8 @@ class EpisodeTests: XCTestCase { // MARK: - Watching - func test_get_users_watching_now() { - session.nextData = jsonData(named: "test_get_users_watching_now") + func test_get_users_watching_now() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1/watching", result: .success(jsonData(named: "test_get_users_watching_now"))) let expectation = XCTestExpectation(description: "Get users watching episode") traktManager.getUsersWatchingEpisode(showID: "game-of-thrones", seasonNumber: 1, episodeNumber: 1) { result in @@ -247,8 +246,7 @@ class EpisodeTests: XCTestCase { expectation.fulfill() } let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1/watching") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -259,8 +257,8 @@ class EpisodeTests: XCTestCase { // MARK: - People - func test_get_show_people_min() { - session.nextData = jsonData(named: "test_get_episode_cast") + func test_get_show_people_min() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1/people?extended=min", result: .success(jsonData(named: "test_get_episode_cast"))) let expectation = XCTestExpectation(description: "ShowCastAndCrew") traktManager.getPeopleInEpisode(showID: "game-of-thrones", season: 1, episode: 1) { result in @@ -279,8 +277,7 @@ class EpisodeTests: XCTestCase { expectation.fulfill() } let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/episodes/1/people?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") diff --git a/Tests/TraktKitTests/GenreTests.swift b/Tests/TraktKitTests/GenreTests.swift index 5c381b8..db08669 100644 --- a/Tests/TraktKitTests/GenreTests.swift +++ b/Tests/TraktKitTests/GenreTests.swift @@ -10,20 +10,9 @@ import XCTest import Foundation @testable import TraktKit -class GenreTests: XCTestCase { - - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) - - override func tearDown() { - super.tearDown() - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil - } - - func test_get_genres() { - session.nextData = jsonData(named: "test_get_genres") +final class GenreTests: TraktTestCase { + func test_get_genres() throws { + try mock(.GET, "https://api.trakt.tv/genres/movies", result: .success(jsonData(named: "test_get_genres"))) let expectation = XCTestExpectation(description: "Get movie genres") traktManager.listGenres(type: .Movies) { result in @@ -33,8 +22,7 @@ class GenreTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/genres/movies") - + switch result { case .timedOut: XCTFail("Something isn't working") diff --git a/Tests/TraktKitTests/ListsTests.swift b/Tests/TraktKitTests/ListsTests.swift index 17daa9f..9b8789a 100644 --- a/Tests/TraktKitTests/ListsTests.swift +++ b/Tests/TraktKitTests/ListsTests.swift @@ -9,20 +9,9 @@ import XCTest @testable import TraktKit -class ListsTests: XCTestCase { - - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) - - override func tearDown() { - super.tearDown() - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil - } - - func test_get_trending_lists() { - session.nextData = jsonData(named: "test_get_trending_lists") +final class ListsTests: TraktTestCase { + func test_get_trending_lists() throws { + try mock(.GET, "https://api.trakt.tv/lists/trending", result: .success(jsonData(named: "test_get_trending_lists"))) let expectation = XCTestExpectation(description: "Get trending lists") traktManager.getTrendingLists { result in @@ -32,8 +21,7 @@ class ListsTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/lists/trending") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -42,8 +30,8 @@ class ListsTests: XCTestCase { } } - func test_get_popular_lists() { - session.nextData = jsonData(named: "test_get_popular_lists") + func test_get_popular_lists() throws { + try mock(.GET, "https://api.trakt.tv/lists/popular", result: .success(jsonData(named: "test_get_popular_lists"))) let expectation = XCTestExpectation(description: "Get popular lists") traktManager.getPopularLists { result in @@ -53,8 +41,7 @@ class ListsTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/lists/popular") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -62,5 +49,4 @@ class ListsTests: XCTestCase { break } } - } diff --git a/Tests/TraktKitTests/Models/Episodes/Episode_Full.json b/Tests/TraktKitTests/Models/Episodes/Episode_Full.json index 0f56173..94f6a43 100644 --- a/Tests/TraktKitTests/Models/Episodes/Episode_Full.json +++ b/Tests/TraktKitTests/Models/Episodes/Episode_Full.json @@ -9,7 +9,7 @@ "tmdb": 63056, "tvrage": null }, - "number_abs": null, + "number_abs": 1, "overview": "Ned Stark, Lord of Winterfell learns that his mentor, Jon Arryn, has died and that King Robert is on his way north to offer Ned Arryn’s position as the King’s Hand. Across the Narrow Sea in Pentos, Viserys Targaryen plans to wed his sister Daenerys to the nomadic Dothraki warrior leader, Khal Drogo to forge an alliance to take the throne.", "first_aired": "2011-04-18T01:00:00.000Z", "updated_at": "2014-08-29T23:16:39.000Z", diff --git a/Tests/TraktKitTests/Models/Users/test_get_settings.json b/Tests/TraktKitTests/Models/Users/test_get_settings.json index 45a4330..6d945fe 100644 --- a/Tests/TraktKitTests/Models/Users/test_get_settings.json +++ b/Tests/TraktKitTests/Models/Users/test_get_settings.json @@ -1,42 +1,69 @@ { - "connections" : { - "twitter" : true, - "facebook" : true, - "google" : true, - "tumblr" : false, - "medium" : false, - "slack" : false - }, - "account" : { - "timezone" : "America\/Los_Angeles", - "cover_image" : "https:\/\/walter.trakt.tv\/images\/movies\/000\/001\/545\/fanarts\/original\/0abb604492.jpg", - "time_24hr" : false - }, - "sharing_text" : { - "watched" : "I just watched [item]", - "watching" : "I'm watching [item]" - }, - "user" : { - "private" : false, - "ids" : { - "slug" : "justin" + "user": { + "username": "justin", + "private": false, + "name": "Justin Nemeth", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin", + "uuid": "b6589fc6ab0dc82cf12099d1c2d40ab994e8410c" + }, + "joined_at": "2010-09-25T17:49:25.000Z", + "location": "San Diego, CA", + "about": "Co-founder of trakt.", + "gender": "male", + "age": 32, + "images": { + "avatar": { + "full": "https://secure.gravatar.com/avatar/30c2f0dfbc39e48656f40498aa871e33?r=pg&s=256" + } + }, + "vip_og": true, + "vip_years": 5 }, - "vip" : true, - "joined_at" : "2010-09-25T17:49:25.000Z", - "age" : 32, - "vip_og" : true, - "vip_years" : 5, - "about" : "Co-founder of trakt.", - "vip_ep" : false, - "location" : "San Diego, CA", - "username" : "justin", - "images" : { - "avatar" : { - "full" : "https:\/\/secure.gravatar.com\/avatar\/30c2f0dfbc39e48656f40498aa871e33?r=pg&s=256" - } + "account": { + "timezone": "America/Los_Angeles", + "date_format": "mdy", + "time_24hr": false, + "cover_image": "https://walter-r2.trakt.tv/images/movies/000/001/545/fanarts/original/0abb604492.jpg" }, - "name" : "Justin Nemeth", - "gender" : "male" - } + "connections": { + "facebook": false, + "twitter": true, + "mastodon": true, + "google": true, + "tumblr": false, + "medium": false, + "slack": false, + "apple": false, + "dropbox": false, + "microsoft": false + }, + "sharing_text": { + "watching": "I'm watching [item]", + "watched": "I just watched [item]", + "rated": "[item] [stars]" + }, + "limits": { + "list": { + "count": 2, + "item_count": 100 + }, + "watchlist": { + "item_count": 100 + }, + "favorites": { + "item_count": 100 + }, + "search": { + "recent_count": 5 + }, + "collection": { + "item_count": 100 + }, + "notes": { + "item_count": 100 + } + } } - diff --git a/Tests/TraktKitTests/MovieTests.swift b/Tests/TraktKitTests/MovieTests.swift index 5d29e29..a8292b6 100644 --- a/Tests/TraktKitTests/MovieTests.swift +++ b/Tests/TraktKitTests/MovieTests.swift @@ -10,22 +10,11 @@ import XCTest import Foundation @testable import TraktKit -class MovieTests: XCTestCase { - - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) - - override func tearDown() { - super.tearDown() - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil - } - +final class MovieTests: TraktTestCase { // MARK: - Trending - func test_get_trending_movies() { - session.nextData = jsonData(named: "test_get_trending_movies") + func test_get_trending_movies() throws { + try mock(.GET, "https://api.trakt.tv/movies/trending?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_trending_movies"))) let expectation = XCTestExpectation(description: "Get Trending Movies") traktManager.getTrendingMovies(pagination: Pagination(page: 1, limit: 10)) { result in @@ -35,10 +24,6 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.path, "/movies/trending") - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) switch result { case .timedOut: @@ -50,8 +35,8 @@ class MovieTests: XCTestCase { // MARK: - Popular - func test_get_popular_movies() { - session.nextData = jsonData(named: "test_get_popular_movies") + func test_get_popular_movies() throws { + try mock(.GET, "https://api.trakt.tv/movies/popular?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_popular_movies"))) let expectation = XCTestExpectation(description: "Get Popular Movies") traktManager.getPopularMovies(pagination: Pagination(page: 1, limit: 10)) { result in @@ -61,10 +46,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.path, "/movies/popular") - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) + switch result { case .timedOut: XCTFail("Something isn't working") @@ -75,8 +57,8 @@ class MovieTests: XCTestCase { // MARK: - Played - func test_get_most_played_movies() { - session.nextData = jsonData(named: "test_get_most_played_movies") + func test_get_most_played_movies() throws { + try mock(.GET, "https://api.trakt.tv/movies/played/all?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_most_played_movies"))) let expectation = XCTestExpectation(description: "Get Most Played Movies") traktManager.getPlayedMovies(period: .All, pagination: Pagination(page: 1, limit: 10)) { result in @@ -86,10 +68,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.path, "/movies/played/all") - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) + switch result { case .timedOut: XCTFail("Something isn't working") @@ -100,8 +79,8 @@ class MovieTests: XCTestCase { // MARK: - Watched - func test_get_most_watched_movies() { - session.nextData = jsonData(named: "test_get_most_watched_movies") + func test_get_most_watched_movies() throws { + try mock(.GET, "https://api.trakt.tv/movies/watched/all?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_most_watched_movies"))) let expectation = XCTestExpectation(description: "Get Most Watched Movies") traktManager.getWatchedMovies(period: .All, pagination: Pagination(page: 1, limit: 10)) { result in @@ -111,10 +90,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.path, "/movies/watched/all") - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) + switch result { case .timedOut: XCTFail("Something isn't working") @@ -125,8 +101,8 @@ class MovieTests: XCTestCase { // MARK: - Collected - func test_get_most_collected_movies() { - session.nextData = jsonData(named: "test_get_most_collected_movies") + func test_get_most_collected_movies() throws { + try mock(.GET, "https://api.trakt.tv/movies/collected/all?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_most_collected_movies"))) let expectation = XCTestExpectation(description: "Get Most Collected Movies") traktManager.getCollectedMovies(period: .All, pagination: Pagination(page: 1, limit: 10)) { result in @@ -136,10 +112,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.path, "/movies/collected/all") - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) + switch result { case .timedOut: XCTFail("Something isn't working") @@ -150,8 +123,8 @@ class MovieTests: XCTestCase { // MARK: - Anticipated - func test_get_most_anticipated_movies() { - session.nextData = jsonData(named: "test_get_most_anticipated_movies") + func test_get_most_anticipated_movies() throws { + try mock(.GET, "https://api.trakt.tv/movies/anticipated?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_most_anticipated_movies"))) let expectation = XCTestExpectation(description: "Get Most Anticipated Movies") traktManager.getAnticipatedMovies(pagination: Pagination(page: 1, limit: 10)) { result in @@ -161,10 +134,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.path, "/movies/anticipated") - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) + switch result { case .timedOut: XCTFail("Something isn't working") @@ -175,8 +145,8 @@ class MovieTests: XCTestCase { // MARK: - Box Office - func test_get_weekend_box_office() { - session.nextData = jsonData(named: "test_get_weekend_box_office") + func test_get_weekend_box_office() throws { + try mock(.GET, "https://api.trakt.tv/movies/boxoffice?extended=min", result: .success(jsonData(named: "test_get_weekend_box_office"))) let expectation = XCTestExpectation(description: "Get Weekend Box Office") traktManager.getWeekendBoxOffice { result in @@ -186,8 +156,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/movies/boxoffice?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -198,8 +167,8 @@ class MovieTests: XCTestCase { // MARK: - Updates - func test_get_recently_updated_movies() { - session.nextData = jsonData(named: "test_get_recently_updated_movies") + func test_get_recently_updated_movies() throws { + try mock(.GET, "https://api.trakt.tv/movies/updates/2014-01-10?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_recently_updated_movies"))) let expectation = XCTestExpectation(description: "Get recently updated movies") traktManager.getUpdatedMovies(startDate: try? Date.dateFromString("2014-01-10"), pagination: Pagination(page: 1, limit: 10)) { result in @@ -209,10 +178,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.path, "/movies/updates/2014-01-10") - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) + switch result { case .timedOut: XCTFail("Something isn't working") @@ -223,8 +189,8 @@ class MovieTests: XCTestCase { // MARK: - Summary - func test_get_min_movie() { - session.nextData = jsonData(named: "Movie_Min") + func test_get_min_movie() throws { + try mock(.GET, "https://api.trakt.tv/movies/tron-legacy-2010?extended=min", result: .success(jsonData(named: "Movie_Min"))) let expectation = XCTestExpectation(description: "MovieSummary") traktManager.getMovieSummary(movieID: "tron-legacy-2010") { result in @@ -239,8 +205,7 @@ class MovieTests: XCTestCase { let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/movies/tron-legacy-2010?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -249,8 +214,8 @@ class MovieTests: XCTestCase { } } - func test_get_full_movie() { - session.nextData = jsonData(named: "Movie_Full") + func test_get_full_movie() throws { + try mock(.GET, "https://api.trakt.tv/movies/tron-legacy-2010?extended=full", result: .success(jsonData(named: "Movie_Full"))) let expectation = XCTestExpectation(description: "MovieSummary") traktManager.getMovieSummary(movieID: "tron-legacy-2010", extended: [.Full]) { result in @@ -276,8 +241,7 @@ class MovieTests: XCTestCase { let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/movies/tron-legacy-2010?extended=full") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -288,8 +252,8 @@ class MovieTests: XCTestCase { // MARK: - Aliases - func test_get_movie_aliases() { - session.nextData = jsonData(named: "test_get_movie_aliases") + func test_get_movie_aliases() throws { + try mock(.GET, "https://api.trakt.tv/movies/tron-legacy-2010/aliases", result: .success(jsonData(named: "test_get_movie_aliases"))) let expectation = XCTestExpectation(description: "Get movie aliases") traktManager.getMovieAliases(movieID: "tron-legacy-2010") { result in @@ -299,8 +263,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/movies/tron-legacy-2010/aliases") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -311,8 +274,8 @@ class MovieTests: XCTestCase { // MARK: - Releases - func test_get_movie_releases() { - session.nextData = jsonData(named: "test_get_movie_releases") + func test_get_movie_releases() throws { + try mock(.GET, "https://api.trakt.tv/movies/tron-legacy-2010/releases/us", result: .success(jsonData(named: "test_get_movie_releases"))) let expectation = XCTestExpectation(description: "Get movie releases") traktManager.getMovieReleases(movieID: "tron-legacy-2010", country: "us") { result in @@ -322,8 +285,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/movies/tron-legacy-2010/releases/us") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -334,8 +296,8 @@ class MovieTests: XCTestCase { // MARK: - Translations - func test_get_movie_translations() { - session.nextData = jsonData(named: "test_get_movie_translations") + func test_get_movie_translations() throws { + try mock(.GET, "https://api.trakt.tv/movies/tron-legacy-2010/translations/us", result: .success(jsonData(named: "test_get_movie_translations"))) let expectation = XCTestExpectation(description: "Get movie translations") traktManager.getMovieTranslations(movieID: "tron-legacy-2010", language: "us") { result in @@ -345,8 +307,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/movies/tron-legacy-2010/translations/us") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -357,8 +318,8 @@ class MovieTests: XCTestCase { // MARK: - Comments - func test_get_movie_comments() { - session.nextData = jsonData(named: "test_get_movie_comments") + func test_get_movie_comments() throws { + try mock(.GET, "https://api.trakt.tv/movies/tron-legacy-2010/comments", result: .success(jsonData(named: "test_get_movie_comments"))) let expectation = XCTestExpectation(description: "Get movie comments") traktManager.getMovieComments(movieID: "tron-legacy-2010") { result in @@ -368,8 +329,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/movies/tron-legacy-2010/comments") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -380,8 +340,8 @@ class MovieTests: XCTestCase { // MARK: - Lists - func test_get_lists_containing_movie() { - session.nextData = jsonData(named: "test_get_lists_containing_movie") + func test_get_lists_containing_movie() throws { + try mock(.GET, "https://api.trakt.tv/movies/tron-legacy-2010/lists", result: .success(jsonData(named: "test_get_lists_containing_movie"))) let expectation = XCTestExpectation(description: "Get lists containing movie") traktManager.getListsContainingMovie(movieID: "tron-legacy-2010") { result in @@ -391,8 +351,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/movies/tron-legacy-2010/lists") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -403,8 +362,8 @@ class MovieTests: XCTestCase { // MARK: - People - func test_get_cast_and_crew() { - session.nextData = jsonData(named: "test_get_cast_and_crew") + func test_get_cast_and_crew() throws { + try mock(.GET, "https://api.trakt.tv/movies/tron-legacy-2010/people?extended=min", result: .success(jsonData(named: "test_get_cast_and_crew"))) let expectation = XCTestExpectation(description: "Get movie cast and crew") traktManager.getPeopleInMovie(movieID: "tron-legacy-2010") { result in @@ -416,8 +375,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/movies/tron-legacy-2010/people?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -428,8 +386,8 @@ class MovieTests: XCTestCase { // MARK: - Ratings - func test_get_movie_ratings() { - session.nextData = jsonData(named: "test_get_movie_ratings") + func test_get_movie_ratings() throws { + try mock(.GET, "https://api.trakt.tv/movies/tron-legacy-2010/ratings", result: .success(jsonData(named: "test_get_movie_ratings"))) let expectation = XCTestExpectation(description: "Get movie ratings") traktManager.getMovieRatings(movieID: "tron-legacy-2010") { result in @@ -439,8 +397,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/movies/tron-legacy-2010/ratings") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -451,8 +408,8 @@ class MovieTests: XCTestCase { // MARK: - Related - func test_get_related_movies() { - session.nextData = jsonData(named: "test_get_related_movies") + func test_get_related_movies() throws { + try mock(.GET, "https://api.trakt.tv/movies/tron-legacy-2010/related?extended=min", result: .success(jsonData(named: "test_get_related_movies"))) let expectation = XCTestExpectation(description: "Get related movies") traktManager.getRelatedMovies(movieID: "tron-legacy-2010") { result in @@ -462,8 +419,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/movies/tron-legacy-2010/related?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -474,8 +430,8 @@ class MovieTests: XCTestCase { // MARK: - stats - func test_get_movie_stats() { - session.nextData = jsonData(named: "test_get_movie_stats") + func test_get_movie_stats() throws { + try mock(.GET, "https://api.trakt.tv/movies/tron-legacy-2010/stats", result: .success(jsonData(named: "test_get_movie_stats"))) let expectation = XCTestExpectation(description: "Get movies stats") traktManager.getMovieStatistics(movieID: "tron-legacy-2010") { result in @@ -490,8 +446,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/movies/tron-legacy-2010/stats") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -502,8 +457,8 @@ class MovieTests: XCTestCase { // MARK: - Watching - func test_get_users_watching_movie_now() { - session.nextData = jsonData(named: "test_get_users_watching_movie_now") + func test_get_users_watching_movie_now() throws { + try mock(.GET, "https://api.trakt.tv/movies/tron-legacy-2010/watching", result: .success(jsonData(named: "test_get_users_watching_movie_now"))) let expectation = XCTestExpectation(description: "Get users watching a movie") traktManager.getUsersWatchingMovie(movieID: "tron-legacy-2010") { result in @@ -513,8 +468,7 @@ class MovieTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/movies/tron-legacy-2010/watching") - + switch result { case .timedOut: XCTFail("Something isn't working") diff --git a/Tests/TraktKitTests/NetworkMocking/MockedResponse.swift b/Tests/TraktKitTests/NetworkMocking/MockedResponse.swift new file mode 100644 index 0000000..1ce3d10 --- /dev/null +++ b/Tests/TraktKitTests/NetworkMocking/MockedResponse.swift @@ -0,0 +1,36 @@ +// +// MockedResponse.swift +// TraktKit +// +// Created by Maximilian Litteral on 2/16/25. +// + +import Foundation +@testable import TraktKit + +extension RequestMocking { + struct MockedResponse { + let url: URL + let result: Result + let httpCode: Int + let headers: [String: String] + let loadingTime: TimeInterval + let customResponse: URLResponse? + } +} + +extension RequestMocking.MockedResponse { + enum Error: Swift.Error { + case failedMockCreation + } + + init(urlString: String, result: Result, httpCode: Int = 200, headers: [HTTPHeader] = [.contentType, .apiVersion, .apiKey("")], loadingTime: TimeInterval = .zero) throws { + guard let url = URL(string: urlString) else { throw Error.failedMockCreation } + self.url = url + self.result = result + self.httpCode = httpCode + self.headers = Dictionary(headers.map { ($0.key, $0.value) }) { _, last in last } + self.loadingTime = loadingTime + self.customResponse = nil + } +} diff --git a/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift b/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift new file mode 100644 index 0000000..ef251c9 --- /dev/null +++ b/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift @@ -0,0 +1,122 @@ +// +// RequestMocking.swift +// TraktKit +// +// Created by Maximilian Litteral on 2/16/25. +// +import Foundation +import os +@testable import TraktKit + +extension URLSession { + static var mockedResponsesOnly: URLSession { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [RequestMocking.self, RequestBlocking.self] + configuration.timeoutIntervalForRequest = 1 + configuration.timeoutIntervalForResource = 1 + return URLSession(configuration: configuration) + } +} + + +final class RequestMocking: URLProtocol, @unchecked Sendable { + static nonisolated(unsafe) private var mocks: [MockedResponse] = [] + + override class func canInit(with request: URLRequest) -> Bool { + return mock(for: request) != nil + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override class func requestIsCacheEquivalent(_ a: URLRequest, to b: URLRequest) -> Bool { + return false + } + + override func startLoading() { + guard + let mock = RequestMocking.mock(for: request), + let url = request.url, + let response = mock.customResponse ?? + HTTPURLResponse(url: url, statusCode: mock.httpCode, httpVersion: "HTTP/1.1", headerFields: mock.headers) + else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + mock.loadingTime) { [weak self] in + guard let self else { return } + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + + switch mock.result { + case let .success(data): + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + case let .failure(error): + client?.urlProtocol(self, didFailWithError: error) + } + } + } + + + override func stopLoading() { } +} + +// MARK: - Helpers + +extension RequestMocking { + static func add(mock: MockedResponse) { + mocks.append(mock) + } + + static func removeAllMocks() { + mocks.removeAll() + } + + static private func mock(for request: URLRequest) -> MockedResponse? { + mocks.first { mock in + guard let url = request.url else { return false } + return mock.url.compareComponents(url) + } + } +} + +extension URL { + /// Compares components, which doesn't require query parameters to be in any particular order + public func compareComponents(_ url: URL) -> Bool { + guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false), + let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } + + return components.scheme == urlComponents.scheme && + components.host == urlComponents.host && + components.path == urlComponents.path && + components.queryItems?.enumerated().compactMap { $0.element.name }.sorted() == urlComponents.queryItems?.enumerated().compactMap { $0.element.name }.sorted() && + components.queryItems?.enumerated().compactMap { $0.element.value }.sorted() == urlComponents.queryItems?.enumerated().compactMap { $0.element.value }.sorted() + } +} + +// MARK: - RequestBlocking + +/// Block all outgoing requests not caught by `RequestMocking` protocol +private class RequestBlocking: URLProtocol, @unchecked Sendable { + + static let logger = Logger(subsystem: "TraktKit", category: "RequestBlocking") + + enum Error: Swift.Error { + case requestBlocked + } + + override class func canInit(with request: URLRequest) -> Bool { + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + Self.logger.warning("Blocking request to \(self.request.url?.absoluteString ?? "Unknown URL.")") + self.client?.urlProtocol(self, didFailWithError: Error.requestBlocked) + } + + override func stopLoading() { } +} diff --git a/Tests/TraktKitTests/PeopleTests.swift b/Tests/TraktKitTests/PeopleTests.swift index cc091d1..6d89d09 100644 --- a/Tests/TraktKitTests/PeopleTests.swift +++ b/Tests/TraktKitTests/PeopleTests.swift @@ -9,22 +9,11 @@ import XCTest @testable import TraktKit -class PeopleTests: XCTestCase { - - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) - - override func tearDown() { - super.tearDown() - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil - } - +final class PeopleTests: TraktTestCase { // MARK: - Summary - func testParsePersonMin() { - session.nextData = jsonData(named: "Person_Min") + func test_parse_person_min() throws { + try mock(.GET, "https://api.trakt.tv/people/bryan-cranston?extended=min", result: .success(jsonData(named: "Person_Min"))) let expectation = XCTestExpectation(description: "Get minimal details on a person") traktManager.getPersonDetails(personID: "bryan-cranston", extended: [.Min]) { result in @@ -34,7 +23,6 @@ class PeopleTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/people/bryan-cranston?extended=min") switch result { case .timedOut: @@ -44,8 +32,8 @@ class PeopleTests: XCTestCase { } } - func testParsePersonFull() { - session.nextData = jsonData(named: "Person_Full") + func test_parse_person_full() throws { + try mock(.GET, "https://api.trakt.tv/people/bryan-cranston?extended=full", result: .success(jsonData(named: "Person_Full"))) let expectation = XCTestExpectation(description: "Get full details on a person") traktManager.getPersonDetails(personID: "bryan-cranston", extended: [.Full]) { result in @@ -55,7 +43,6 @@ class PeopleTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/people/bryan-cranston?extended=full") switch result { case .timedOut: @@ -68,8 +55,8 @@ class PeopleTests: XCTestCase { // MARK: - Movies - func test_get_movie_credits() { - session.nextData = jsonData(named: "test_get_movie_credits") + func test_get_movie_credits() throws { + try mock(.GET, "https://api.trakt.tv/people/bryan-cranston/movies?extended=min", result: .success(jsonData(named: "test_get_movie_credits"))) let expectation = XCTestExpectation(description: "Get movie credits for person") traktManager.getMovieCredits(personID: "bryan-cranston", extended: [.Min]) { result in @@ -81,8 +68,7 @@ class PeopleTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/people/bryan-cranston/movies?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -93,8 +79,8 @@ class PeopleTests: XCTestCase { // MARK: - Shows - func test_get_show_credits() { - session.nextData = jsonData(named: "test_get_show_credits") + func test_get_show_credits() throws { + try mock(.GET, "https://api.trakt.tv/people/bryan-cranston/shows?extended=min", result: .success(jsonData(named: "test_get_show_credits"))) let expectation = XCTestExpectation(description: "Get show credits for person") traktManager.getShowCredits(personID: "bryan-cranston", extended: [.Min]) { result in @@ -105,8 +91,7 @@ class PeopleTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/people/bryan-cranston/shows?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -117,8 +102,8 @@ class PeopleTests: XCTestCase { // MARK: - Lists - func test_get_lists_containing_this_person() { - session.nextData = jsonData(named: "test_get_lists_containing_this_person") + func test_get_lists_containing_this_person() throws { + try mock(.GET, "https://api.trakt.tv/people/bryan-cranston/lists", result: .success(jsonData(named: "test_get_lists_containing_this_person"))) let expectation = XCTestExpectation(description: "Get lists containing person") traktManager.getListsContainingPerson(personId: "bryan-cranston") { result in @@ -128,8 +113,7 @@ class PeopleTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/people/bryan-cranston/lists") - + switch result { case .timedOut: XCTFail("Something isn't working") diff --git a/Tests/TraktKitTests/RecommendationsTests.swift b/Tests/TraktKitTests/RecommendationsTests.swift index 65268e2..1d7dc5f 100644 --- a/Tests/TraktKitTests/RecommendationsTests.swift +++ b/Tests/TraktKitTests/RecommendationsTests.swift @@ -9,22 +9,11 @@ import XCTest @testable import TraktKit -class RecommendationsTests: XCTestCase { - - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) - - override func tearDown() { - super.tearDown() - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil - } - +final class RecommendationsTests: TraktTestCase { // MARK: - Movies - func test_get_movie_recommendations() { - session.nextData = jsonData(named: "test_get_movie_recommendations") + func test_get_movie_recommendations() throws { + try mock(.GET, "https://api.trakt.tv/recommendations/movies", result: .success(jsonData(named: "test_get_movie_recommendations"))) let expectation = XCTestExpectation(description: "Get movie recommendations") traktManager.getRecommendedMovies { result in @@ -34,8 +23,7 @@ class RecommendationsTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/recommendations/movies") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -46,8 +34,8 @@ class RecommendationsTests: XCTestCase { // MARK: - Hide Movie - func test_hide_movie_recommendation() { - session.nextStatusCode = StatusCodes.SuccessNoContentToReturn + func test_hide_movie_recommendation() throws { + try mock(.POST, "https://api.trakt.tv/recommendations/movies/922", result: .success(.init())) let expectation = XCTestExpectation(description: "Hide movie recommendation") traktManager.hideRecommendedMovie(movieID: 922) { result in @@ -56,7 +44,6 @@ class RecommendationsTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/recommendations/movies/922") switch result { case .timedOut: @@ -68,8 +55,8 @@ class RecommendationsTests: XCTestCase { // MARK: - Shows - func test_get_show_recommendations() { - session.nextData = jsonData(named: "test_get_show_recommendations") + func test_get_show_recommendations() throws { + try mock(.GET, "https://api.trakt.tv/recommendations/shows", result: .success(jsonData(named: "test_get_show_recommendations"))) let expectation = XCTestExpectation(description: "Get show recommendations") traktManager.getRecommendedShows { result in @@ -79,8 +66,7 @@ class RecommendationsTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/recommendations/shows") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -91,8 +77,8 @@ class RecommendationsTests: XCTestCase { // MARK: - Hide Show - func test_hide_show_recommendation() { - session.nextStatusCode = StatusCodes.SuccessNoContentToReturn + func test_hide_show_recommendation() throws { + try mock(.POST, "https://api.trakt.tv/recommendations/shows/922", result: .success(.init())) let expectation = XCTestExpectation(description: "Hide show recommendation") traktManager.hideRecommendedShow(showID: 922) { result in @@ -101,7 +87,6 @@ class RecommendationsTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/recommendations/shows/922") switch result { case .timedOut: diff --git a/Tests/TraktKitTests/ScrobbleTests.swift b/Tests/TraktKitTests/ScrobbleTests.swift index 769e005..c7fd8bd 100644 --- a/Tests/TraktKitTests/ScrobbleTests.swift +++ b/Tests/TraktKitTests/ScrobbleTests.swift @@ -9,27 +9,15 @@ import XCTest @testable import TraktKit -class ScrobbleTests: XCTestCase { - - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) - - override func tearDown() { - super.tearDown() - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil - } - +final class ScrobbleTests: TraktTestCase { // MARK: - Start - func test_start_watching_in_media_center() { - session.nextData = jsonData(named: "test_start_watching_in_media_center") - session.nextStatusCode = StatusCodes.SuccessNewResourceCreated + func test_start_watching_in_media_center() throws { + try mock(.POST, "https://api.trakt.tv/scrobble/start", result: .success(jsonData(named: "test_start_watching_in_media_center"))) let expectation = XCTestExpectation(description: "Start watching in media center") let scrobble = TraktScrobble(movie: SyncId(trakt: 12345), progress: 1.25) - try! traktManager.scrobbleStart(scrobble) { result in + try traktManager.scrobbleStart(scrobble) { result in if case .success(let response) = result { XCTAssertEqual(response.action, "start") XCTAssertEqual(response.progress, 1.25) @@ -38,8 +26,7 @@ class ScrobbleTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/scrobble/start") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -50,13 +37,12 @@ class ScrobbleTests: XCTestCase { // MARK: - Pause - func test_pause_watching_in_media_center() { - session.nextData = jsonData(named: "test_pause_watching_in_media_center") - session.nextStatusCode = StatusCodes.SuccessNewResourceCreated + func test_pause_watching_in_media_center() throws { + try mock(.POST, "https://api.trakt.tv/scrobble/pause", result: .success(jsonData(named: "test_pause_watching_in_media_center"))) let expectation = XCTestExpectation(description: "Pause watching in media center") let scrobble = TraktScrobble(movie: SyncId(trakt: 12345), progress: 75) - try! traktManager.scrobblePause(scrobble) { result in + try traktManager.scrobblePause(scrobble) { result in if case .success(let response) = result { XCTAssertEqual(response.action, "pause") XCTAssertEqual(response.progress, 75) @@ -65,8 +51,7 @@ class ScrobbleTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/scrobble/pause") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -77,13 +62,12 @@ class ScrobbleTests: XCTestCase { // MARK: - Stop - func test_stop_watching_in_media_center() { - session.nextData = jsonData(named: "test_stop_watching_in_media_center") - session.nextStatusCode = StatusCodes.SuccessNewResourceCreated + func test_stop_watching_in_media_center() throws { + try mock(.POST, "https://api.trakt.tv/scrobble/stop", result: .success(jsonData(named: "test_stop_watching_in_media_center"))) let expectation = XCTestExpectation(description: "Stop watching in media center") let scrobble = TraktScrobble(movie: SyncId(trakt: 12345), progress: 99.9) - try! traktManager.scrobbleStop(scrobble) { result in + try traktManager.scrobbleStop(scrobble) { result in if case .success(let response) = result { XCTAssertEqual(response.action, "scrobble") XCTAssertEqual(response.progress, 99.9) @@ -92,8 +76,7 @@ class ScrobbleTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/scrobble/stop") - + switch result { case .timedOut: XCTFail("Something isn't working") diff --git a/Tests/TraktKitTests/SearchTests.swift b/Tests/TraktKitTests/SearchTests.swift index 82b7633..49b2c29 100644 --- a/Tests/TraktKitTests/SearchTests.swift +++ b/Tests/TraktKitTests/SearchTests.swift @@ -9,22 +9,12 @@ import XCTest @testable import TraktKit -class SearchTests: XCTestCase { - - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) - - override func tearDown() { - super.tearDown() - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil - } +final class SearchTests: TraktTestCase { // MARK: - Text query - func test_search_query() { - session.nextData = jsonData(named: "test_search_query") + func test_search_query() throws { + try mock(.GET, "https://api.trakt.tv/search/movie,show,episode,person,list?query=tron&extended=min", result: .success(jsonData(named: "test_search_query"))) let expectation = XCTestExpectation(description: "Search") traktManager.search(query: "tron", types: [.movie, .show, .episode, .person, .list]) { result in @@ -34,7 +24,6 @@ class SearchTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.path, "/search/movie,show,episode,person,list") switch result { case .timedOut: @@ -46,8 +35,8 @@ class SearchTests: XCTestCase { // MARK: - ID Lookup - func test_id_lookup() { - session.nextData = jsonData(named: "test_id_lookup") + func test_id_lookup() throws { + try mock(.GET, "https://api.trakt.tv/search/imdb/tt0848228?type=movie&extended=min", result: .success(jsonData(named: "test_id_lookup"))) let expectation = XCTestExpectation(description: "Lookup Id") traktManager.lookup(id: .IMDB(id: "tt0848228"), type: .movie) { result in @@ -57,9 +46,7 @@ class SearchTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.path, "/search/imdb/tt0848228") - XCTAssertTrue(session.lastURL?.query?.contains("type=movie") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) + switch result { case .timedOut: XCTFail("Something isn't working") diff --git a/Tests/TraktKitTests/SeasonTests.swift b/Tests/TraktKitTests/SeasonTests.swift index 4784314..f2edb4b 100644 --- a/Tests/TraktKitTests/SeasonTests.swift +++ b/Tests/TraktKitTests/SeasonTests.swift @@ -9,22 +9,12 @@ import XCTest @testable import TraktKit -class SeasonTests: XCTestCase { - - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) - - override func tearDown() { - super.tearDown() - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil - } +final class SeasonTests: TraktTestCase { // MARK: - Summary - func test_get_all_seasons() { - session.nextData = jsonData(named: "test_get_all_seasons") + func test_get_all_seasons() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons?extended=min", result: .success(jsonData(named: "test_get_all_seasons"))) let expectation = XCTestExpectation(description: "Get all seasons") traktManager.getSeasons(showID: "game-of-thrones") { result in @@ -34,8 +24,7 @@ class SeasonTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -44,8 +33,8 @@ class SeasonTests: XCTestCase { } } - func test_get_all_seasons_and_episodes() { - session.nextData = jsonData(named: "test_get_all_seasons_and_episodes") + func test_get_all_seasons_and_episodes() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons?extended=episodes", result: .success(jsonData(named: "test_get_all_seasons_and_episodes"))) let expectation = XCTestExpectation(description: "Get all seasons and episodes") traktManager.getSeasons(showID: "game-of-thrones", extended: [.Episodes]) { result in @@ -55,8 +44,7 @@ class SeasonTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons?extended=episodes") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -67,8 +55,8 @@ class SeasonTests: XCTestCase { // MARK: - Season - func test_get_season() { - session.nextData = jsonData(named: "test_get_season") + func test_get_season() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1?extended=min", result: .success(jsonData(named: "test_get_season"))) let expectation = XCTestExpectation(description: "Get all seasons and episodes") traktManager.getEpisodesForSeason(showID: "game-of-thrones", season: 1) { result in @@ -78,8 +66,7 @@ class SeasonTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -88,8 +75,8 @@ class SeasonTests: XCTestCase { } } - func test_get_translated_season() { - session.nextData = jsonData(named: "test_get_season") + func test_get_translated_season() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1?extended=min&translations=es", result: .success(jsonData(named: "test_get_season"))) let expectation = XCTestExpectation(description: "Get translated episodes") traktManager.getEpisodesForSeason(showID: "game-of-thrones", season: 1, translatedInto: "es") { result in @@ -99,9 +86,7 @@ class SeasonTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.path, "/shows/game-of-thrones/seasons/1") - XCTAssertTrue(session.lastURL?.query?.contains("translations=es") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) + switch result { case .timedOut: XCTFail("Something isn't working") @@ -112,8 +97,8 @@ class SeasonTests: XCTestCase { // MARK: - Comments - func test_get_season_comments() { - session.nextData = jsonData(named: "test_get_season_comments") + func test_get_season_comments() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/comments", result: .success(jsonData(named: "test_get_season_comments"))) let expectation = XCTestExpectation(description: "Get season comments") traktManager.getAllSeasonComments(showID: "game-of-thrones", season: 1) { result in @@ -123,8 +108,7 @@ class SeasonTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/comments") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -135,8 +119,8 @@ class SeasonTests: XCTestCase { // MARK: - Lists - func test_get_lists_containing_season() { - session.nextData = jsonData(named: "test_get_lists_containing_season") + func test_get_lists_containing_season() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/lists/personal/added", result: .success(jsonData(named: "test_get_lists_containing_season"))) let expectation = XCTestExpectation(description: "Get lists containing season") traktManager.getListsContainingSeason(showID: "game-of-thrones", season: 1, listType: ListType.personal, sortBy: .added) { result in @@ -146,8 +130,7 @@ class SeasonTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/lists/personal/added") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -158,8 +141,8 @@ class SeasonTests: XCTestCase { // MARK: - Ratings - func test_get_season_rating() { - session.nextData = jsonData(named: "test_get_season_rating") + func test_get_season_rating() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/ratings", result: .success(jsonData(named: "test_get_season_rating"))) let expectation = XCTestExpectation(description: "Get season ratings") traktManager.getSeasonRatings(showID: "game-of-thrones", season: 1) { result in @@ -171,8 +154,7 @@ class SeasonTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/ratings") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -183,8 +165,8 @@ class SeasonTests: XCTestCase { // MARK: - Stats - func test_get_season_stats() { - session.nextData = jsonData(named: "test_get_season_stats") + func test_get_season_stats() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/stats", result: .success(jsonData(named: "test_get_season_stats"))) let expectation = XCTestExpectation(description: "Get season stats") traktManager.getSeasonStatistics(showID: "game-of-thrones", season: 1) { result in @@ -200,8 +182,7 @@ class SeasonTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/stats") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -212,8 +193,8 @@ class SeasonTests: XCTestCase { // MARK: - Watching - func test_get_users_watching_season() { - session.nextData = jsonData(named: "test_get_users_watching_season") + func test_get_users_watching_season() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/watching", result: .success(jsonData(named: "test_get_users_watching_season"))) let expectation = XCTestExpectation(description: "Get users watching season") traktManager.getUsersWatchingSeasons(showID: "game-of-thrones", season: 1) { result in @@ -223,8 +204,7 @@ class SeasonTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/watching") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -235,8 +215,8 @@ class SeasonTests: XCTestCase { // MARK: - People - func test_get_show_people_min() { - session.nextData = jsonData(named: "test_get_season_cast") + func test_get_show_people_min() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/people?extended=min", result: .success(jsonData(named: "test_get_season_cast"))) let expectation = XCTestExpectation(description: "ShowCastAndCrew") traktManager.getPeopleInSeason(showID: "game-of-thrones", season: 1) { result in @@ -253,8 +233,7 @@ class SeasonTests: XCTestCase { expectation.fulfill() } let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/seasons/1/people?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") diff --git a/Tests/TraktKitTests/ShowTests.swift b/Tests/TraktKitTests/ShowTests.swift deleted file mode 100644 index 676f5b0..0000000 --- a/Tests/TraktKitTests/ShowTests.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// ShowTests.swift -// TraktKit -// -// Created by Maximilian Litteral on 7/20/15. -// Copyright © 2015 Maximilian Litteral. All rights reserved. -// - -import XCTest -@testable import TraktKit -/* -class ShowTests: XCTestCase { - - override func setUp() { - super.setUp() - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDown() { - // Put teardown code here. This method is called after the invocation of each test method in the class. - super.tearDown() - } - - func testParseShow() { - let expectation = self.expectation(description: "High Expectations") - let numberOfTrendingShows = 100 - var count = 0 - - TraktManager.sharedManager.getTrendingShows(page: 1, limit: numberOfTrendingShows) { (objects, error) -> Void in - guard error == nil else { - print("Error getting trending shows: \(error)") - XCTAssert(false, "Error getting trending shows") - return - } - - guard let trendingShows = objects else { - XCTAssert(false, "objects is nil") - return - } - - for trendingObject in trendingShows { - if let showDict = trendingObject["show"] as? [String: AnyObject] { - if let showIDs = showDict["ids"] as? [String: AnyObject] { - if let traktID = showIDs["trakt"] as? NSNumber { - - TraktManager.sharedManager.getShowSummary(showID: traktID, extended: [ExtendedType.Full]) { (dictionary, error) -> Void in - count = count + 1 - - guard error == nil else { - print("Error getting Show Summary: \(error)") - XCTAssert(false, "Error getting show summary") - return - } - - guard let summary = dictionary else { - XCTAssert(false, "Error getting show summary") - return - } - - let showTitle = summary["title"] as? String - let _ = summary["certification"] as? String ?? "Unknown" - let showRuntime = summary["runtime"] as? NSNumber - let showOverview = summary["overview"] as? String - let _ = summary["country"] as? String ?? "us" - let showNetwork = summary["network"] as? String - let showStatus = summary["status"] as? String - let showYear = summary["year"] as? NSNumber - let _ = summary["language"] as? String ?? "en" - - if let title = showTitle, let _ = showRuntime, let _ = showOverview, let _ = showNetwork, let _ = showStatus, let _ = showYear { - print("Parsed \(title) succesfully!") - XCTAssert(true, "JSON was parsed correctly") - } - else { - print("JSON: \(summary)") - XCTAssert(false, "JSON was not parsed correctly") - } - - if count == numberOfTrendingShows { - expectation.fulfill() - } - } - } - } - } - } - } - - self.waitForExpectations(timeout: 30.0, handler: { (error) -> Void in - if let error = error { - print("Timeout error: \(error)") - } - }) - } - - func testEpisodeRatings() { - let expectation = self.expectation(description: "High Expectations") - - TraktManager.sharedManager.getEpisodeRatings(showID: 77686, seasonNumber: 1, episodeNumber: 1) { (dictionary, error) -> Void in - guard error == nil else { - XCTAssert(false, "Error getting episode ratings") - return - } - - XCTAssert(true, "Passed!") - expectation.fulfill() - } - - self.waitForExpectations(timeout: 10) { (error) -> Void in - if let error = error { - print("Timeout error: \(error)") - } - } - } -}*/ diff --git a/Tests/TraktKitTests/ShowsTests.swift b/Tests/TraktKitTests/ShowsTests.swift index c2b8929..c8662af 100644 --- a/Tests/TraktKitTests/ShowsTests.swift +++ b/Tests/TraktKitTests/ShowsTests.swift @@ -10,52 +10,48 @@ import XCTest import Foundation @testable import TraktKit -class ShowsTests: XCTestCase { - - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) - - override func tearDown() { - super.tearDown() - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil - } +final class ShowsTests: TraktTestCase { // MARK: - Trending func test_get_min_trending_shows_await() async throws { - session.nextData = jsonData(named: "TrendingShows_Min") + try mock(.GET, "https://api.trakt.tv/shows/trending?extended=min&page=1&limit=10", result: .success(jsonData(named: "TrendingShows_Min")), headers: [.page(1), .pageCount(100)]) - let trendingShows = try await traktManager.explore.trending.shows() + let response = try await traktManager.explore.trending.shows() .extend(.Min) .page(1) .limit(10) .perform() + let trendingShows = response.object XCTAssertEqual(trendingShows.count, 10) - XCTAssertEqual(session.lastURL?.path, "/shows/trending") - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) + XCTAssertEqual(response.currentPage, 1) + XCTAssertEqual(response.pageCount, 100) + let firstTrendingShow = try XCTUnwrap(trendingShows.first) + XCTAssertEqual(firstTrendingShow.watchers, 252) + XCTAssertEqual(firstTrendingShow.show.title, "Marvel's Jessica Jones") + XCTAssertNil(firstTrendingShow.show.overview) } func test_get_min_trending_shows() { - session.nextData = jsonData(named: "TrendingShows_Min") + try? mock(.GET, "https://api.trakt.tv/shows/trending?extended=min&page=1&limit=10", result: .success(jsonData(named: "TrendingShows_Min"))) let expectation = XCTestExpectation(description: "TrendingShows") traktManager.getTrendingShows(pagination: Pagination(page: 1, limit: 10)) { result in if case .success(let trendingShows, _, _) = result { - XCTAssertEqual(trendingShows.count, 10) + do { + XCTAssertEqual(trendingShows.count, 10) + let firstTrendingShow = try XCTUnwrap(trendingShows.first) + XCTAssertEqual(firstTrendingShow.watchers, 252) + XCTAssertEqual(firstTrendingShow.show.title, "Marvel's Jessica Jones") + XCTAssertNil(firstTrendingShow.show.overview) + } catch { + XCTFail("Something isn't working") + } expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - - XCTAssertEqual(session.lastURL?.path, "/shows/trending") - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -65,22 +61,25 @@ class ShowsTests: XCTestCase { } func test_get_full_trending_shows() { - session.nextData = jsonData(named: "TrendingShows_Full") + try? mock(.GET, "https://api.trakt.tv/shows/trending?extended=full&page=1&limit=10", result: .success(jsonData(named: "TrendingShows_Full"))) let expectation = XCTestExpectation(description: "TrendingShows") traktManager.getTrendingShows(pagination: Pagination(page: 1, limit: 10), extended: [.Full]) { result in if case .success(let trendingShows, _, _) = result { - XCTAssertEqual(trendingShows.count, 10) + do { + XCTAssertEqual(trendingShows.count, 10) + let firstTrendingShow = try XCTUnwrap(trendingShows.first) + XCTAssertEqual(firstTrendingShow.watchers, 252) + XCTAssertEqual(firstTrendingShow.show.title, "Marvel's Jessica Jones") + XCTAssertEqual(firstTrendingShow.show.overview, "A former superhero decides to reboot her life by becoming a private investigator.") + } catch { + XCTFail("Something isn't working") + } expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - - XCTAssertEqual(session.lastURL?.path, "/shows/trending") - XCTAssertTrue(session.lastURL?.query?.contains("extended=full") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -92,7 +91,7 @@ class ShowsTests: XCTestCase { // MARK: - Popular func test_get_popular_shows() { - session.nextData = jsonData(named: "test_get_popular_shows") + try? mock(.GET, "https://api.trakt.tv/shows/popular?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_popular_shows"))) let expectation = XCTestExpectation(description: "Get popular shows") traktManager.getPopularShows(pagination: Pagination(page: 1, limit: 10)) { result in @@ -101,11 +100,7 @@ class ShowsTests: XCTestCase { expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.path, "/shows/popular") - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -117,7 +112,7 @@ class ShowsTests: XCTestCase { // MARK: - Played func test_get_most_played_shows() { - session.nextData = jsonData(named: "test_get_most_played_shows") + try? mock(.GET, "https://api.trakt.tv/shows/played/weekly?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_most_played_shows"))) let expectation = XCTestExpectation(description: "Get most played shows") traktManager.getPlayedShows(pagination: Pagination(page: 1, limit: 10)) { result in @@ -126,11 +121,7 @@ class ShowsTests: XCTestCase { expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.path, "/shows/played/weekly") - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -142,7 +133,7 @@ class ShowsTests: XCTestCase { // MARK: - Watched func test_get_most_watched_shows() { - session.nextData = jsonData(named: "test_get_most_watched_shows") + try? mock(.GET, "https://api.trakt.tv/shows/watched/weekly?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_most_watched_shows"))) let expectation = XCTestExpectation(description: "Get most watched shows") traktManager.getWatchedShows(pagination: Pagination(page: 1, limit: 10)) { result in @@ -151,11 +142,7 @@ class ShowsTests: XCTestCase { expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.path, "/shows/watched/weekly") - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -167,7 +154,7 @@ class ShowsTests: XCTestCase { // MARK: - Collected func test_get_most_collected_shows() { - session.nextData = jsonData(named: "test_get_most_collected_shows") + try? mock(.GET, "https://api.trakt.tv/shows/collected/weekly?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_most_collected_shows"))) let expectation = XCTestExpectation(description: "Get most collected shows") traktManager.getCollectedShows(pagination: Pagination(page: 1, limit: 10)) { result in @@ -176,11 +163,7 @@ class ShowsTests: XCTestCase { expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.path, "/shows/collected/weekly") - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -192,7 +175,7 @@ class ShowsTests: XCTestCase { // MARK: - Anticipated func test_get_most_anticipated_shows() { - session.nextData = jsonData(named: "test_get_most_anticipated_shows") + try? mock(.GET, "https://api.trakt.tv/shows/anticipated?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_most_anticipated_shows"))) let expectation = XCTestExpectation(description: "Get anticipated shows") traktManager.getAnticipatedShows(pagination: Pagination(page: 1, limit: 10)) { result in @@ -201,11 +184,7 @@ class ShowsTests: XCTestCase { expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.path, "/shows/anticipated") - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -217,7 +196,7 @@ class ShowsTests: XCTestCase { // MARK: - Updates func test_get_updated_shows() { - session.nextData = jsonData(named: "test_get_updated_shows") + try? mock(.GET, "https://api.trakt.tv/shows/updates/2014-09-22?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_updated_shows"))) let expectation = XCTestExpectation(description: "Get updated shows") traktManager.getUpdatedShows(startDate: try! Date.dateFromString("2014-09-22"), pagination: Pagination(page: 1, limit: 10)) { result in @@ -226,11 +205,7 @@ class ShowsTests: XCTestCase { expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.path, "/shows/updates/2014-09-22") - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -242,7 +217,7 @@ class ShowsTests: XCTestCase { // MARK: - Summary func test_get_min_show() { - session.nextData = jsonData(named: "Show_Min") + try? mock(.GET, "https://api.trakt.tv/shows/game-of-thrones?extended=min", result: .success(jsonData(named: "Show_Min"))) let expectation = XCTestExpectation(description: "ShowSummary") traktManager.getShowSummary(showID: "game-of-thrones") { result in @@ -272,10 +247,7 @@ class ShowsTests: XCTestCase { } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones?extended=min") - + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -285,7 +257,7 @@ class ShowsTests: XCTestCase { } func test_get_full_show() { - session.nextData = jsonData(named: "Show_Full") + try? mock(.GET, "https://api.trakt.tv/shows/game-of-thrones?extended=full", result: .success(jsonData(named: "Show_Full"))) let expectation = XCTestExpectation(description: "ShowSummary") traktManager.getShowSummary(showID: "game-of-thrones", extended: [.Full]) { result in @@ -315,10 +287,7 @@ class ShowsTests: XCTestCase { } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones?extended=full") - + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -328,7 +297,7 @@ class ShowsTests: XCTestCase { } func test_get_full_show_await() async throws { - session.nextData = jsonData(named: "Show_Full") + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones?extended=full", result: .success(jsonData(named: "Show_Full"))) let show = try await traktManager.show(id: "game-of-thrones").summary() .extend(.Full) @@ -360,7 +329,7 @@ class ShowsTests: XCTestCase { // MARK: - Aliases func test_get_show_aliases() { - session.nextData = jsonData(named: "ShowAliases") + try? mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/aliases", result: .success(jsonData(named: "ShowAliases"))) let expectation = XCTestExpectation(description: "ShowAliases") traktManager.getShowAliases(showID: "game-of-thrones") { result in @@ -370,10 +339,7 @@ class ShowsTests: XCTestCase { } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/aliases") - + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -383,17 +349,16 @@ class ShowsTests: XCTestCase { } func test_get_show_aliases_await() async throws { - session.nextData = jsonData(named: "ShowAliases") + try? mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/aliases", result: .success(jsonData(named: "ShowAliases"))) let aliases = try await traktManager.show(id: "game-of-thrones").aliases().perform() XCTAssertEqual(aliases.count, 32) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/aliases") } // MARK: - Translations func test_get_show_translations() { - session.nextData = jsonData(named: "test_get_show_translations") + try? mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/translations/es", result: .success(jsonData(named: "test_get_show_translations"))) let expectation = XCTestExpectation(description: "Get show translations") traktManager.getShowTranslations(showID: "game-of-thrones", language: "es") { result in @@ -403,10 +368,7 @@ class ShowsTests: XCTestCase { } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/translations/es") - + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -417,8 +379,8 @@ class ShowsTests: XCTestCase { // MARK: - Comments - func test_get_show_comments() { - session.nextData = jsonData(named: "test_get_show_comments") + func test_get_show_comments() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/comments", result: .success(jsonData(named: "test_get_show_comments"))) let expectation = XCTestExpectation(description: "Get show comments") traktManager.getShowComments(showID: "game-of-thrones") { result in @@ -427,9 +389,7 @@ class ShowsTests: XCTestCase { expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/comments") - + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -440,8 +400,8 @@ class ShowsTests: XCTestCase { // MARK: - Lists - func test_get_lists_containing_show() { - session.nextData = jsonData(named: "test_get_lists_containing_show") + func test_get_lists_containing_show() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/lists", result: .success(jsonData(named: "test_get_lists_containing_show"))) let expectation = XCTestExpectation(description: "Get lists containing shows") traktManager.getListsContainingShow(showID: "game-of-thrones") { result in @@ -450,9 +410,7 @@ class ShowsTests: XCTestCase { expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/lists") - + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -463,8 +421,8 @@ class ShowsTests: XCTestCase { // MARK: - Collection Progress - func testParseShowCollectionProgress() { - session.nextData = jsonData(named: "ShowCollectionProgress") + func testParseShowCollectionProgress() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/progress/collection?hidden=false&specials=false", result: .success(jsonData(named: "ShowCollectionProgress"))) let expectation = XCTestExpectation(description: "Get collected progress") traktManager.getShowCollectionProgress(showID: "game-of-thrones") { result in @@ -480,10 +438,7 @@ class ShowsTests: XCTestCase { expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.path, "/shows/game-of-thrones/progress/collection") - XCTAssertTrue(session.lastURL?.query?.contains("hidden=false") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("specials=false") ?? false) + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -494,8 +449,8 @@ class ShowsTests: XCTestCase { // MARK: - Watched Progress - func test_get_wathced_progress() { - session.nextData = jsonData(named: "test_get_wathced_progress") + func test_get_wathced_progress() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/progress/watched?hidden=false&specials=false", result: .success(jsonData(named: "test_get_wathced_progress"))) let expectation = XCTestExpectation(description: "Get watched progress") traktManager.getShowWatchedProgress(showID: "game-of-thrones") { result in @@ -505,10 +460,7 @@ class ShowsTests: XCTestCase { expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.path, "/shows/game-of-thrones/progress/watched") - XCTAssertTrue(session.lastURL?.query?.contains("hidden=false") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("specials=false") ?? false) + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -519,8 +471,8 @@ class ShowsTests: XCTestCase { // MARK: - People - func test_get_show_people_min() { - session.nextData = jsonData(named: "ShowCastAndCrew_Min") + func test_get_show_people_min() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/people?extended=min", result: .success(jsonData(named: "ShowCastAndCrew_Min"))) let expectation = XCTestExpectation(description: "ShowCastAndCrew") traktManager.getPeopleInShow(showID: "game-of-thrones") { result in @@ -536,9 +488,7 @@ class ShowsTests: XCTestCase { } expectation.fulfill() } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/people?extended=min") - + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -547,9 +497,9 @@ class ShowsTests: XCTestCase { } } - func test_get_show_people_full() { - session.nextData = jsonData(named: "ShowCastAndCrew_Full") - + func test_get_show_people_full() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/people?extended=full", result: .success(jsonData(named: "ShowCastAndCrew_Full"))) + let expectation = XCTestExpectation(description: "ShowCastAndCrew") traktManager.getPeopleInShow(showID: "game-of-thrones", extended: [.Full]) { result in if case .success(let castAndCrew) = result { @@ -560,9 +510,7 @@ class ShowsTests: XCTestCase { } expectation.fulfill() } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/people?extended=full") - + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -571,9 +519,9 @@ class ShowsTests: XCTestCase { } } - func test_get_show_people_guest_stars() { - session.nextData = jsonData(named: "ShowCastAndCrew_GuestStars") - + func test_get_show_people_guest_stars() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/people?extended=guest_stars", result: .success(jsonData(named: "ShowCastAndCrew_GuestStars"))) + let expectation = XCTestExpectation(description: "ShowCastAndCrew") traktManager.getPeopleInShow(showID: "game-of-thrones", extended: [.guestStars]) { result in if case .success(let castAndCrew) = result { @@ -586,9 +534,7 @@ class ShowsTests: XCTestCase { } expectation.fulfill() } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/people?extended=guest_stars") - + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -599,8 +545,8 @@ class ShowsTests: XCTestCase { // MARK: - Ratings - func test_get_show_ratings() { - session.nextData = jsonData(named: "test_get_show_ratings") + func test_get_show_ratings() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/ratings", result: .success(jsonData(named: "test_get_show_ratings"))) let expectation = XCTestExpectation(description: "Get show ratings") traktManager.getShowRatings(showID: "game-of-thrones") { result in @@ -610,9 +556,7 @@ class ShowsTests: XCTestCase { expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/ratings") - + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -623,8 +567,8 @@ class ShowsTests: XCTestCase { // MARK: - Related - func test_get_related_shows() { - session.nextData = jsonData(named: "test_get_related_shows") + func test_get_related_shows() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/related?extended=min", result: .success(jsonData(named: "test_get_related_shows"))) let expectation = XCTestExpectation(description: "Get related shows") traktManager.getRelatedShows(showID: "game-of-thrones") { result in @@ -633,9 +577,7 @@ class ShowsTests: XCTestCase { expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/related?extended=min") - + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -646,8 +588,8 @@ class ShowsTests: XCTestCase { // MARK: - Stats - func test_get_stats() { - session.nextData = jsonData(named: "ShowStats") + func test_get_stats() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/stats", result: .success(jsonData(named: "ShowStats"))) let expectation = XCTestExpectation(description: "Stats") traktManager.getShowStatistics(showID: "game-of-thrones") { result in @@ -661,9 +603,7 @@ class ShowsTests: XCTestCase { expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/stats") - + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -674,8 +614,8 @@ class ShowsTests: XCTestCase { // MARK: - Watching - func test_get_users_watching_show() { - session.nextData = jsonData(named: "test_get_users_watching_show") + func test_get_users_watching_show() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/watching", result: .success(jsonData(named: "test_get_users_watching_show"))) let expectation = XCTestExpectation(description: "Get users watching the show") traktManager.getUsersWatchingShow(showID: "game-of-thrones") { result in @@ -684,9 +624,7 @@ class ShowsTests: XCTestCase { expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/watching") - + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: XCTFail("Something isn't working") @@ -697,20 +635,23 @@ class ShowsTests: XCTestCase { // MARK: - Next episode - func test_get_nextEpisode() { - // TODO: Add success with status code, and move `createErrorWithStatusCode` outside TraktManager - session.nextError = traktManager.createErrorWithStatusCode(204) + func test_get_nextEpisode() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/next_episode?extended=min", result: .success(.init()), httpCode: 204) let expectation = XCTestExpectation(description: "NextEpisode") traktManager.getNextEpisode(showID: "game-of-thrones") { result in if case .error(let error) = result { XCTAssertNotNil(error) - XCTAssertEqual(error!._code, 204) - expectation.fulfill() + do { + let traktError = try XCTUnwrap(error as? TraktManager.TraktError) + XCTAssertEqual(traktError, TraktManager.TraktError.noContent) + expectation.fulfill() + } catch { + XCTFail(error.localizedDescription) + } } else { XCTFail("Unexpected result") } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/next_episode?extended=min") + let result = XCTWaiter().wait(for: [expectation], timeout: 1) switch result { case .timedOut: @@ -722,8 +663,8 @@ class ShowsTests: XCTestCase { // MARK: - Last episode - func test_get_lastEpisode() { - session.nextData = jsonData(named: "LastEpisodeAired_min") + func test_get_lastEpisode() throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/last_episode?extended=min", result: .success(jsonData(named: "LastEpisodeAired_min"))) let expectation = XCTestExpectation(description: "LastEpisode") traktManager.getLastEpisode(showID: "game-of-thrones") { result in @@ -734,9 +675,8 @@ class ShowsTests: XCTestCase { expectation.fulfill() } } - let result = XCTWaiter().wait(for: [expectation], timeout: 5) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/shows/game-of-thrones/last_episode?extended=min") - + let result = XCTWaiter().wait(for: [expectation], timeout: 1) + switch result { case .timedOut: XCTFail("Something isn't working") diff --git a/Tests/TraktKitTests/SyncTests.swift b/Tests/TraktKitTests/SyncTests.swift index 13fbf83..d1ba614 100644 --- a/Tests/TraktKitTests/SyncTests.swift +++ b/Tests/TraktKitTests/SyncTests.swift @@ -10,22 +10,12 @@ import XCTest import Foundation @testable import TraktKit -class SyncTests: XCTestCase { - - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) - - override func tearDown() { - super.tearDown() - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil - } +final class SyncTests: TraktTestCase { // MARK: - Last Activities - func test_get_last_activity() { - session.nextData = jsonData(named: "test_get_last_activity") + func test_get_last_activity() throws { + try mock(.GET, "https://api.trakt.tv/sync/last_activities", result: .success(jsonData(named: "test_get_last_activity"))) let expectation = XCTestExpectation(description: "Get Last Activity") traktManager.lastActivities { result in @@ -34,8 +24,7 @@ class SyncTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/last_activities") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -46,8 +35,8 @@ class SyncTests: XCTestCase { // MARK: - Playback - func test_get_playback_progress() { - session.nextData = jsonData(named: "test_get_playback_progress") + func test_get_playback_progress() throws { + try mock(.GET, "https://api.trakt.tv/sync/playback/movies", result: .success(jsonData(named: "test_get_playback_progress"))) let expectation = XCTestExpectation(description: "Get Playback progress") traktManager.getPlaybackProgress(type: .Movies) { result in @@ -61,8 +50,7 @@ class SyncTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/playback/movies") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -73,8 +61,8 @@ class SyncTests: XCTestCase { // MARK: - Remove Playback - func test_remove_a_playback_item() { - session.nextStatusCode = StatusCodes.SuccessNoContentToReturn + func test_remove_a_playback_item() throws { + try mock(.DELETE, "https://api.trakt.tv/sync/playback/13", result: .success(.init()), httpCode: StatusCodes.SuccessNoContentToReturn) let expectation = XCTestExpectation(description: "Remove playback item") traktManager.removePlaybackItem(id: 13) { result in @@ -83,7 +71,6 @@ class SyncTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/playback/13") switch result { case .timedOut: @@ -95,8 +82,8 @@ class SyncTests: XCTestCase { // MARK: - Get Collection - func test_get_collection() { - session.nextData = jsonData(named: "test_get_collection") + func test_get_collection() throws { + try mock(.GET, "https://api.trakt.tv/sync/collection/movies?extended=min", result: .success(jsonData(named: "test_get_collection"))) let expectation = XCTestExpectation(description: "Get collection") traktManager.getCollection(type: .Movies) { result in @@ -106,8 +93,7 @@ class SyncTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/collection/movies?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -116,8 +102,8 @@ class SyncTests: XCTestCase { } } - func test_get_collection_shows() { - session.nextData = jsonData(named: "test_get_collection_shows") + func test_get_collection_shows() throws { + try mock(.GET, "https://api.trakt.tv/sync/collection/shows?extended=min", result: .success(jsonData(named: "test_get_collection_shows"))) let expectation = XCTestExpectation(description: "Get shows collection") traktManager.getCollection(type: .Shows) { result in @@ -131,8 +117,7 @@ class SyncTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/collection/shows?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -143,12 +128,11 @@ class SyncTests: XCTestCase { // MARK: - Add to Collection - func test_add_items_to_collection() { - session.nextData = jsonData(named: "test_add_items_to_collection") - session.nextStatusCode = StatusCodes.SuccessNewResourceCreated + func test_add_items_to_collection() throws { + try mock(.POST, "https://api.trakt.tv/sync/collection", result: .success(jsonData(named: "test_add_items_to_collection"))) let expectation = XCTestExpectation(description: "Add items to collection") - try! traktManager.addToCollection(movies: [], shows: [], episodes: []) { result in + try traktManager.addToCollection(movies: [], shows: [], episodes: []) { result in if case .success(let result) = result { XCTAssertEqual(result.added.movies, 1) XCTAssertEqual(result.added.episodes, 12) @@ -156,8 +140,7 @@ class SyncTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/collection") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -168,11 +151,11 @@ class SyncTests: XCTestCase { // MARK: - Remove from Collection - func test_remove_items_from_collection() { - session.nextData = jsonData(named: "test_remove_items_from_collection") + func test_remove_items_from_collection() throws { + try mock(.GET, "https://api.trakt.tv/sync/collection/remove", result: .success(jsonData(named: "test_remove_items_from_collection"))) let expectation = XCTestExpectation(description: "Remove items from collection") - try! traktManager.removeFromCollection(movies: [], shows: [], episodes: []) { result in + try traktManager.removeFromCollection(movies: [], shows: [], episodes: []) { result in if case .success(let result) = result { XCTAssertEqual(result.deleted.movies, 1) XCTAssertEqual(result.deleted.episodes, 12) @@ -182,8 +165,7 @@ class SyncTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/collection/remove") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -194,8 +176,8 @@ class SyncTests: XCTestCase { // MARK: - Get Watched - func test_get_watched() { - session.nextData = jsonData(named: "test_get_watched") + func test_get_watched() throws { + try mock(.GET, "https://api.trakt.tv/sync/watched/movies?extended=min", result: .success(jsonData(named: "test_get_watched"))) let expectation = XCTestExpectation(description: "Get Watched") traktManager.getWatchedMovies { result in @@ -205,8 +187,7 @@ class SyncTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/watched/movies?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -215,8 +196,8 @@ class SyncTests: XCTestCase { } } - func test_get_watched_shows_noseasons() { - session.nextData = jsonData(named: "test_get_watched_shows_noseasons") + func test_get_watched_shows_noseasons() throws { + try mock(.GET, "https://api.trakt.tv/sync/watched/shows?extended=noseasons", result: .success(jsonData(named: "test_get_watched_shows_noseasons"))) let expectation = XCTestExpectation(description: "Get Watched - noSeasons") traktManager.getWatchedShows(extended: [.noSeasons]) { result in @@ -230,8 +211,7 @@ class SyncTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/watched/shows?extended=noseasons") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -240,8 +220,8 @@ class SyncTests: XCTestCase { } } - func test_get_watched_shows() { - session.nextData = jsonData(named: "test_get_watched_shows") + func test_get_watched_shows() throws { + try mock(.GET, "https://api.trakt.tv/sync/watched/shows?extended=min", result: .success(jsonData(named: "test_get_watched_shows"))) let expectation = XCTestExpectation(description: "Get Watched") traktManager.getWatchedShows { result in @@ -255,8 +235,7 @@ class SyncTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/watched/shows?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -267,8 +246,8 @@ class SyncTests: XCTestCase { // MARK: - Get History - func test_get_watched_history() { - session.nextData = jsonData(named: "test_get_watched_history") + func test_get_watched_history() throws { + try mock(.GET, "https://api.trakt.tv/sync/history/movies?extended=min", result: .success(jsonData(named: "test_get_watched_history"))) let expectation = XCTestExpectation(description: "Get Watched history") traktManager.getHistory(type: .Movies) { result in @@ -278,8 +257,7 @@ class SyncTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/history/movies?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -290,12 +268,11 @@ class SyncTests: XCTestCase { // MARK: - Add to History - func test_add_items_to_watched_history() { - session.nextData = jsonData(named: "test_add_items_to_watched_history") - session.nextStatusCode = StatusCodes.SuccessNewResourceCreated + func test_add_items_to_watched_history() throws { + try mock(.POST, "https://api.trakt.tv/sync/history", result: .success(jsonData(named: "test_add_items_to_watched_history"))) let expectation = XCTestExpectation(description: "Add items to history") - try! traktManager.addToHistory(movies: [], shows: [], episodes: []) { result in + try traktManager.addToHistory(movies: [], shows: [], episodes: []) { result in switch result { case .success(let ids): XCTAssertEqual(ids.added.movies, 2) @@ -306,8 +283,7 @@ class SyncTests: XCTestCase { expectation.fulfill() } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/history") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -318,11 +294,11 @@ class SyncTests: XCTestCase { // MARK: - Remove from History - func test_remove_items_from_history() { - session.nextData = jsonData(named: "test_remove_items_from_history") + func test_remove_items_from_history() throws { + try mock(.GET, "https://api.trakt.tv/sync/history/remove", result: .success(jsonData(named: "test_remove_items_from_history"))) let expectation = XCTestExpectation(description: "Remove items from history") - try! traktManager.removeFromHistory(movies: [], shows: [], episodes: []) { result in + try traktManager.removeFromHistory(movies: [], shows: [], episodes: []) { result in switch result { case .success(let ids): XCTAssertEqual(ids.deleted.movies, 2) @@ -333,8 +309,7 @@ class SyncTests: XCTestCase { expectation.fulfill() } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/history/remove") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -345,8 +320,8 @@ class SyncTests: XCTestCase { // MARK: - Get Ratings - func test_get_ratings() { - session.nextData = jsonData(named: "test_get_ratings") + func test_get_ratings() throws { + try mock(.GET, "https://api.trakt.tv/sync/ratings/movies/9", result: .success(jsonData(named: "test_get_ratings"))) let expectation = XCTestExpectation(description: "Get ratings") traktManager.getRatings(type: .Movies, rating: 9) { result in @@ -356,8 +331,7 @@ class SyncTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/ratings/movies/9") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -368,12 +342,11 @@ class SyncTests: XCTestCase { // MARK: - Add Ratings - func test_add_new_ratings() { - session.nextData = jsonData(named: "test_add_new_ratings") - session.nextStatusCode = StatusCodes.SuccessNewResourceCreated + func test_add_new_ratings() throws { + try mock(.POST, "https://api.trakt.tv/sync/ratings", result: .success(jsonData(named: "test_add_new_ratings"))) let expectation = XCTestExpectation(description: "Add rating") - try! traktManager.addRatings(movies: [RatingId(trakt: 12345, rating: 10, ratedAt: Date())]) { result in + try traktManager.addRatings(movies: [RatingId(trakt: 12345, rating: 10, ratedAt: Date())]) { result in if case .success(let result) = result { XCTAssertEqual(result.added.movies, 1) XCTAssertEqual(result.added.shows, 1) @@ -384,8 +357,7 @@ class SyncTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/ratings") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -396,11 +368,11 @@ class SyncTests: XCTestCase { // MARK: - Remove Ratings - func test_remove_ratings() { - session.nextData = jsonData(named: "test_remove_ratings") + func test_remove_ratings() throws { + try mock(.GET, "https://api.trakt.tv/sync/ratings/remove", result: .success(jsonData(named: "test_remove_ratings"))) let expectation = XCTestExpectation(description: "Remove rating") - try! traktManager.removeRatings(movies: [], shows: [], episodes: []) { result in + try traktManager.removeRatings(movies: [], shows: [], episodes: []) { result in if case .success(let result) = result { XCTAssertEqual(result.deleted.movies, 1) XCTAssertEqual(result.deleted.shows, 1) @@ -410,8 +382,7 @@ class SyncTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/ratings/remove") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -422,8 +393,8 @@ class SyncTests: XCTestCase { // MARK: - Get Watchlist - func test_get_watchlist() { - session.nextData = jsonData(named: "test_get_watchlist") + func test_get_watchlist() throws { + try mock(.GET, "https://api.trakt.tv/sync/watchlist/movies?extended=min", result: .success(jsonData(named: "test_get_watchlist"))) let expectation = XCTestExpectation(description: "Get watchlist") traktManager.getWatchlist(watchType: .Movies) { result in @@ -433,8 +404,7 @@ class SyncTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/watchlist/movies?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -445,19 +415,17 @@ class SyncTests: XCTestCase { // MARK: - Add to Watchlist - func test_add_items_to_watchlist() { - session.nextData = jsonData(named: "test_add_items_to_watchlist") - session.nextStatusCode = StatusCodes.SuccessNewResourceCreated + func test_add_items_to_watchlist() throws { + try mock(.POST, "https://api.trakt.tv/sync/watchlist", result: .success(jsonData(named: "test_add_items_to_watchlist"))) let expectation = XCTestExpectation(description: "Add items to watchlist") - try! traktManager.addToWatchlist(movies: [], shows: [], episodes: []) { result in + try traktManager.addToWatchlist(movies: [], shows: [], episodes: []) { result in if case .success = result { expectation.fulfill() } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/watchlist") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -468,18 +436,17 @@ class SyncTests: XCTestCase { // MARK: - Remove from Watchlist - func test_remove_items_from_watchlist() { - session.nextData = jsonData(named: "test_remove_items_from_watchlist") + func test_remove_items_from_watchlist() throws { + try mock(.GET, "https://api.trakt.tv/sync/watchlist/remove", result: .success(jsonData(named: "test_remove_items_from_watchlist"))) let expectation = XCTestExpectation(description: "Remove items from watchlist") - try! traktManager.removeFromWatchlist(movies: [], shows: [], episodes: []) { result in + try traktManager.removeFromWatchlist(movies: [], shows: [], episodes: []) { result in if case .success = result { expectation.fulfill() } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/sync/watchlist/remove") - + switch result { case .timedOut: XCTFail("Something isn't working") diff --git a/Tests/TraktKitTests/TestTraktManager.swift b/Tests/TraktKitTests/TestTraktManager.swift deleted file mode 100644 index fc878ae..0000000 --- a/Tests/TraktKitTests/TestTraktManager.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// TestTraktManager.swift -// TraktKitTests -// -// Created by Maximilian Litteral on 1/12/19. -// Copyright © 2019 Maximilian Litteral. All rights reserved. -// - -@testable import TraktKit - -final class TestTraktManager: TraktManager { - override init(session: URLSessionProtocol) { - super.init(session: session) - self.set(clientID: "", clientSecret: "", redirectURI: "", staging: false) - } -} diff --git a/Tests/TraktKitTests/TraktManagerTests.swift b/Tests/TraktKitTests/TraktManagerTests.swift index b85f4fb..ff3e4a2 100644 --- a/Tests/TraktKitTests/TraktManagerTests.swift +++ b/Tests/TraktKitTests/TraktManagerTests.swift @@ -12,19 +12,16 @@ import Testing @Suite class TraktManagerTests { - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) + lazy var traktManager = TraktManager(session: URLSession.mockedResponsesOnly, clientId: "", clientSecret: "", redirectURI: "") deinit { - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil + RequestMocking.removeAllMocks() } @Test func pollForAccessTokenInvalidDeviceCode() async throws { - session.nextStatusCode = 404 - session.nextData = Data() + let mock = try RequestMocking.MockedResponse(urlString: "https://api.trakt.tv/oauth/device/token", result: .success(.init()), httpCode: 404) + RequestMocking.add(mock: mock) let deviceCodeJSON: [String: Any] = [ "device_code": "d9c126a7706328d808914cfd1e40274b6e009f684b1aca271b9b3f90b3630d64", diff --git a/Tests/TraktKitTests/TraktTestCase.swift b/Tests/TraktKitTests/TraktTestCase.swift new file mode 100644 index 0000000..edbba4d --- /dev/null +++ b/Tests/TraktKitTests/TraktTestCase.swift @@ -0,0 +1,27 @@ +// +// TraktTestCase.swift +// TraktKit +// +// Created by Maximilian Litteral on 2/16/25. +// + +import XCTest +@testable import TraktKit + +class TraktTestCase: XCTestCase { + lazy var traktManager = TraktManager(session: URLSession.mockedResponsesOnly, clientId: "", clientSecret: "", redirectURI: "") + + override func setUp() { + DependencyContainer.shared.traktClient = traktManager + } + + override func tearDown() { + super.tearDown() + RequestMocking.removeAllMocks() + } + + func mock(_ method: TraktKit.Method, _ urlString: String, result: Result, httpCode: Int? = nil, headers: [HTTPHeader] = [.contentType, .apiVersion, .apiKey("")]) throws { + let mock = try RequestMocking.MockedResponse(urlString: urlString, result: result, httpCode: httpCode ?? method.expectedResult, headers: headers) + RequestMocking.add(mock: mock) + } +} diff --git a/Tests/TraktKitTests/UserTests.swift b/Tests/TraktKitTests/UserTests.swift index 0ec355c..3c735db 100644 --- a/Tests/TraktKitTests/UserTests.swift +++ b/Tests/TraktKitTests/UserTests.swift @@ -10,50 +10,26 @@ import XCTest import Foundation @testable import TraktKit -class UserTests: XCTestCase { - - let session = MockURLSession() - lazy var traktManager = TestTraktManager(session: session) - - override func tearDown() { - super.tearDown() - session.nextData = nil - session.nextStatusCode = StatusCodes.Success - session.nextError = nil - } +final class UserTests: TraktTestCase { // MARK: - Settings - func test_get_settings() { - session.nextData = jsonData(named: "test_get_settings") - - let expectation = XCTestExpectation(description: "settings") - traktManager.getSettings { result in - if case .success(let accountSessings) = result { - XCTAssertEqual(accountSessings.user.name, "Justin Nemeth") - XCTAssertEqual(accountSessings.user.gender, "male") - XCTAssertEqual(accountSessings.connections.twitter, true) - XCTAssertEqual(accountSessings.connections.slack, false) - expectation.fulfill() - } - } + func test_get_settings() async throws { + try mock(.GET, "https://api.trakt.tv/users/settings", result: .success(jsonData(named: "test_get_settings"))) - let result = XCTWaiter().wait(for: [expectation], timeout: 1) - - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/settings") - - switch result { - case .timedOut: - XCTFail("Something isn't working") - default: - break - } + let settings = try await traktManager.currentUser().settings().perform() + XCTAssertEqual(settings.user.name, "Justin Nemeth") + XCTAssertEqual(settings.user.gender, "male") + XCTAssertEqual(settings.connections.twitter, true) + XCTAssertEqual(settings.connections.slack, false) + XCTAssertEqual(settings.limits.list.count, 2) + XCTAssertEqual(settings.limits.list.itemCount, 100) } // MARK: - Follower requests - func test_get_follow_request() { - session.nextData = jsonData(named: "test_get_follow_request") + func test_get_follow_request() throws { + try mock(.GET, "https://api.trakt.tv/users/requests", result: .success(jsonData(named: "test_get_follow_request"))) let expectation = XCTestExpectation(description: "FollowRequest") traktManager.getFollowRequests { result in @@ -64,8 +40,7 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/requests") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -76,8 +51,8 @@ class UserTests: XCTestCase { // MARK: - Approve or deny follower requests - func test_approve_follow_request() { - session.nextData = jsonData(named: "test_approve_follow_request") + func test_approve_follow_request() throws { + try mock(.GET, "https://api.trakt.tv/users/requests/123", result: .success(jsonData(named: "test_approve_follow_request"))) let expectation = XCTestExpectation(description: "Approve follow request") traktManager.approveFollowRequest(requestID: 123) { result in @@ -88,8 +63,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/requests/123") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -98,9 +71,8 @@ class UserTests: XCTestCase { } } - func test_deny_follow_request() { - session.nextData = nil - session.nextStatusCode = StatusCodes.SuccessNoContentToReturn + func test_deny_follow_request() throws { + try mock(.DELETE, "https://api.trakt.tv/users/requests/123", result: .success(.init())) let expectation = XCTestExpectation(description: "Deny follow request") traktManager.denyFollowRequest(requestID: 123) { result in @@ -110,8 +82,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/requests/123") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -122,9 +92,8 @@ class UserTests: XCTestCase { // MARK: - Hidden items - func test_get_hidden_items() { - session.nextData = jsonData(named: "test_get_hidden_items") - session.nextStatusCode = StatusCodes.Success + func test_get_hidden_items() throws { + try mock(.GET, "https://api.trakt.tv/users/hidden/progress_watched?page=1&limit=10&type=show&extended=min", result: .success(jsonData(named: "test_get_hidden_items"))) let expectation = XCTestExpectation(description: "HiddenItems") traktManager.hiddenItems(section: .ProgressWatched, type: .Show, pagination: Pagination(page: 1, limit: 10)) { result in @@ -134,11 +103,6 @@ class UserTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.path, "/users/hidden/progress_watched") - XCTAssertTrue(session.lastURL?.query?.contains("page=1") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("limit=10") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("type=show") ?? false) - XCTAssertTrue(session.lastURL?.query?.contains("extended=min") ?? false) switch result { case .timedOut: XCTFail("Something isn't working") @@ -149,9 +113,8 @@ class UserTests: XCTestCase { // MARK: - Add hidden item - func test_add_hidden_item() { - session.nextData = jsonData(named: "test_add_hidden_item") - session.nextStatusCode = StatusCodes.SuccessNewResourceCreated + func test_add_hidden_item() throws { + try mock(.POST, "https://api.trakt.tv/users/hidden/calendar", result: .success(jsonData(named: "test_add_hidden_item"))) let expectation = XCTestExpectation(description: "Add hidden item") try! traktManager.hide(from: .Calendar) { result in @@ -163,8 +126,7 @@ class UserTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/hidden/calendar") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -175,9 +137,8 @@ class UserTests: XCTestCase { // MARK: - Remove hidden item - func test_post_remove_hidden_items() { - session.nextData = jsonData(named: "test_post_remove_hidden_items") - session.nextStatusCode = StatusCodes.Success + func test_post_remove_hidden_items() throws { + try mock(.GET, "https://api.trakt.tv/users/hidden/calendar/remove", result: .success(jsonData(named: "test_post_remove_hidden_items"))) let expectation = XCTestExpectation(description: "Remove hidden items") try! traktManager.unhide(from: .Calendar) { result in @@ -189,7 +150,6 @@ class UserTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/hidden/calendar/remove") switch result { case .timedOut: @@ -201,8 +161,8 @@ class UserTests: XCTestCase { // MARK: - Likes - func test_get_comments_likes() { - session.nextData = jsonData(named: "test_get_comments_likes") + func test_get_comments_likes() throws { + try mock(.GET, "https://api.trakt.tv/users/likes/comments", result: .success(jsonData(named: "test_get_comments_likes"))) let expectation = XCTestExpectation(description: "Comments likes") traktManager.getLikes(type: .Comments) { result in @@ -217,8 +177,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/likes/comments") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -227,8 +185,8 @@ class UserTests: XCTestCase { } } - func test_get_lists_likes() { - session.nextData = jsonData(named: "test_get_lists_likes") + func test_get_lists_likes() throws { + try mock(.GET, "https://api.trakt.tv/users/likes/lists", result: .success(jsonData(named: "test_get_lists_likes"))) let expectation = XCTestExpectation(description: "Lists likes") traktManager.getLikes(type: .Lists) { result in @@ -243,8 +201,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/likes/lists") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -255,8 +211,8 @@ class UserTests: XCTestCase { // MARK: - Profile - func test_get_min_profile() { - session.nextData = jsonData(named: "test_get_min_profile") + func test_get_min_profile() throws { + try mock(.GET, "https://api.trakt.tv/users/me?extended=min", result: .success(jsonData(named: "test_get_min_profile"))) let expectation = XCTestExpectation(description: "User Profile") traktManager.getUserProfile { result in @@ -271,8 +227,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/me?extended=min") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -281,8 +235,8 @@ class UserTests: XCTestCase { } } - func test_get_full_profile() { - session.nextData = jsonData(named: "test_get_full_profile") + func test_get_full_profile() throws { + try mock(.GET, "https://api.trakt.tv/users/me?extended=full", result: .success(jsonData(named: "test_get_full_profile"))) let expectation = XCTestExpectation(description: "User Profile") traktManager.getUserProfile(extended: [.Full]) { result in @@ -302,8 +256,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/me?extended=full") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -312,8 +264,8 @@ class UserTests: XCTestCase { } } - func test_get_VIP_profile() { - session.nextData = jsonData(named: "test_get_VIP_profile") + func test_get_VIP_profile() throws { + try mock(.GET, "https://api.trakt.tv/users/me?extended=full", result: .success(jsonData(named: "test_get_VIP_profile"))) let expectation = XCTestExpectation(description: "User Profile") traktManager.getUserProfile(extended: [.Full]) { result in @@ -330,8 +282,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/me?extended=full") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -342,8 +292,8 @@ class UserTests: XCTestCase { // MARK: - Collection - func test_get_user_collection() { - session.nextData = jsonData(named: "test_get_user_collection") + func test_get_user_collection() throws { + try mock(.GET, "https://api.trakt.tv/users/me/collection/shows", result: .success(jsonData(named: "test_get_user_collection"))) let expectation = XCTestExpectation(description: "Get User Collection") traktManager.getUserCollection(type: .shows) { result in @@ -369,8 +319,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/me/collection/shows") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -381,8 +329,8 @@ class UserTests: XCTestCase { // MARK: - Comments - func test_get_user_comments() { - session.nextData = jsonData(named: "test_get_user_comments") + func test_get_user_comments() throws { + try mock(.GET, "https://api.trakt.tv/users/sean/comments", result: .success(jsonData(named: "test_get_user_comments"))) let expectation = XCTestExpectation(description: "User Commets") traktManager.getUserComments(username: "sean") { result in @@ -393,8 +341,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/sean/comments") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -405,8 +351,8 @@ class UserTests: XCTestCase { // MARK: - Lists - func test_get_custom_lists() { - session.nextData = jsonData(named: "test_get_custom_lists") + func test_get_custom_lists() throws { + try mock(.GET, "https://api.trakt.tv/users/sean/lists", result: .success(jsonData(named: "test_get_custom_lists"))) let expectation = XCTestExpectation(description: "User Custom Lists") traktManager.getCustomLists(username: "sean") { result in @@ -417,8 +363,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/sean/lists") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -427,9 +371,8 @@ class UserTests: XCTestCase { } } - func test_post_custom_list() { - session.nextData = jsonData(named: "test_post_custom_list") - session.nextStatusCode = StatusCodes.SuccessNewResourceCreated + func test_post_custom_list() throws { + try mock(.POST, "https://api.trakt.tv/users/me/lists", result: .success(jsonData(named: "test_post_custom_list"))) let expectation = XCTestExpectation(description: "User create custom lists") @@ -444,8 +387,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/me/lists") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -456,11 +397,10 @@ class UserTests: XCTestCase { // MARK: - List - func test_get_custom_list() { - session.nextData = jsonData(named: "test_get_custom_list") + func test_get_custom_list() throws { + try mock(.GET, "https://api.trakt.tv/users/me/lists/star-wars-in-machete-order", result: .success(jsonData(named: "test_get_custom_list"))) let expectation = XCTestExpectation(description: "User create custom list") - traktManager.getCustomList(listID: "star-wars-in-machete-order") { result in if case .success(let list) = result { XCTAssertEqual(list.name, "Star Wars in machete order") @@ -470,8 +410,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/me/lists/star-wars-in-machete-order") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -480,8 +418,8 @@ class UserTests: XCTestCase { } } - func test_update_custom_list() { - session.nextData = jsonData(named: "test_update_custom_list") + func test_update_custom_list() throws { + try mock(.GET, "https://api.trakt.tv/users/me/lists/star-wars-in-machete-order", result: .success(jsonData(named: "test_update_custom_list"))) let expectation = XCTestExpectation(description: "User update custom list") try! traktManager.updateCustomList(listID: "star-wars-in-machete-order", listName: "Star Wars in NEW machete order", privacy: "private", displayNumbers: false) { result in @@ -493,8 +431,7 @@ class UserTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/me/lists/star-wars-in-machete-order") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -503,8 +440,8 @@ class UserTests: XCTestCase { } } - func test_delete_custom_list() { - session.nextStatusCode = StatusCodes.SuccessNoContentToReturn + func test_delete_custom_list() throws { + try mock(.DELETE, "https://api.trakt.tv/users/me/lists/star-wars-in-machete-order", result: .success(.init())) let expectation = XCTestExpectation(description: "User delete custom list") traktManager.deleteCustomList(listID: "star-wars-in-machete-order") { result in @@ -513,7 +450,6 @@ class UserTests: XCTestCase { } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/me/lists/star-wars-in-machete-order") switch result { case .timedOut: @@ -525,11 +461,10 @@ class UserTests: XCTestCase { // MARK: - List like - func test_like_list() { - session.nextStatusCode = StatusCodes.SuccessNoContentToReturn + func test_like_list() throws { + try mock(.POST, "https://api.trakt.tv/users/sean/lists/star-wars-in-machete-order/like", result: .success(.init())) let expectation = XCTestExpectation(description: "Like a list") - traktManager.likeList(username: "sean", listID: "star-wars-in-machete-order") { result in if case .success = result { expectation.fulfill() @@ -537,8 +472,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/sean/lists/star-wars-in-machete-order/like") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -547,11 +480,10 @@ class UserTests: XCTestCase { } } - func test_remove_like_from_list() { - session.nextStatusCode = StatusCodes.SuccessNoContentToReturn + func test_remove_like_from_list() throws { + try mock(.DELETE, "https://api.trakt.tv/users/sean/lists/star-wars-in-machete-order/like", result: .success(.init())) let expectation = XCTestExpectation(description: "Like a list") - traktManager.removeListLike(username: "sean", listID: "star-wars-in-machete-order") { result in if case .success = result { expectation.fulfill() @@ -559,8 +491,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/sean/lists/star-wars-in-machete-order/like") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -571,8 +501,8 @@ class UserTests: XCTestCase { // MARK: - List items - func test_get_items_on_custom_list() { - session.nextData = jsonData(named: "test_get_items_on_custom_list") + func test_get_items_on_custom_list() throws { + try mock(.GET, "https://api.trakt.tv/users/sean/lists/star-wars-in-machete-order/items?extended=min", result: .success(jsonData(named: "test_get_items_on_custom_list"))) let expectation = XCTestExpectation(description: "Get custom list items") traktManager.getItemsForCustomList(username: "sean", listID: "star-wars-in-machete-order") { result in @@ -583,8 +513,7 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/sean/lists/star-wars-in-machete-order/items?extended=min") - + switch result { case .timedOut: XCTFail("Something isn't working") @@ -595,9 +524,8 @@ class UserTests: XCTestCase { // MARK: - Add list items - func test_add_item_to_custom_list() { - session.nextData = jsonData(named: "test_add_item_to_custom_list") - session.nextStatusCode = StatusCodes.SuccessNewResourceCreated + func test_add_item_to_custom_list() throws { + try mock(.POST, "https://api.trakt.tv/users/sean/lists/star-wars-in-machete-order/items", result: .success(jsonData(named: "test_add_item_to_custom_list"))) let expectation = XCTestExpectation(description: "Add item to custom list") try! traktManager.addItemToCustomList(username: "sean", listID: "star-wars-in-machete-order", movies: [], shows: [], episodes: []) { result in @@ -620,8 +548,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/sean/lists/star-wars-in-machete-order/items") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -632,9 +558,8 @@ class UserTests: XCTestCase { // MARK: - Remove list items - func test_remove_item_from_custom_list() { - session.nextData = jsonData(named: "test_remove_item_from_custom_list") - session.nextStatusCode = StatusCodes.Success + func test_remove_item_from_custom_list() throws { + try mock(.DELETE, "https://api.trakt.tv/users/sean/lists/star-wars-in-machete-order/items/remove", result: .success(jsonData(named: "test_remove_item_from_custom_list"))) let expectation = XCTestExpectation(description: "Remove item to custom list") try! traktManager.removeItemFromCustomList(username: "sean", listID: "star-wars-in-machete-order", movies: [], shows: [], episodes: []) { result in @@ -651,8 +576,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/sean/lists/star-wars-in-machete-order/items/remove") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -663,9 +586,8 @@ class UserTests: XCTestCase { // MARK: - List comments - func test_get_all_list_comments() { - session.nextData = jsonData(named: "test_get_all_list_comments") - session.nextStatusCode = StatusCodes.Success + func test_get_all_list_comments() throws { + try mock(.GET, "https://api.trakt.tv/users/sean/lists/star-wars-in-machete-order/comments", result: .success(jsonData(named: "test_get_all_list_comments"))) let expectation = XCTestExpectation(description: "List comments") traktManager.getUserAllListComments(username: "sean", listID: "star-wars-in-machete-order") { result in @@ -682,8 +604,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/sean/lists/star-wars-in-machete-order/comments") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -694,9 +614,8 @@ class UserTests: XCTestCase { // MARK: - Follow - func test_follow_user() { - session.nextData = jsonData(named: "test_follow_user") - session.nextStatusCode = StatusCodes.SuccessNewResourceCreated + func test_follow_user() throws { + try mock(.POST, "https://api.trakt.tv/users/sean/follow", result: .success(jsonData(named: "test_follow_user"))) let expectation = XCTestExpectation(description: "Follow user") traktManager.followUser(username: "sean") { result in @@ -707,8 +626,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/sean/follow") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -717,9 +634,8 @@ class UserTests: XCTestCase { } } - func test_unfollow_user() { - session.nextData = nil - session.nextStatusCode = StatusCodes.SuccessNoContentToReturn + func test_unfollow_user() throws { + try mock(.DELETE, "https://api.trakt.tv/users/sean/follow", result: .success(jsonData(named: "test_follow_user"))) let expectation = XCTestExpectation(description: "Unfollow user") traktManager.unfollowUser(username: "sean") { result in @@ -729,8 +645,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/sean/follow") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -741,9 +655,8 @@ class UserTests: XCTestCase { // MARK: - Followers - func test_get_followers() { - session.nextData = jsonData(named: "test_get_followers") - session.nextStatusCode = StatusCodes.Success + func test_get_followers() throws { + try mock(.GET, "https://api.trakt.tv/users/me/followers", result: .success(jsonData(named: "test_get_followers"))) let expectation = XCTestExpectation(description: "Get followers") traktManager.getUserFollowers { result in @@ -758,8 +671,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/me/followers") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -770,9 +681,8 @@ class UserTests: XCTestCase { // MARK: - Following - func test_get_following() { - session.nextData = jsonData(named: "test_get_following") - session.nextStatusCode = StatusCodes.Success + func test_get_following() throws { + try mock(.GET, "https://api.trakt.tv/users/me/following", result: .success(jsonData(named: "test_get_following"))) let expectation = XCTestExpectation(description: "Get following") traktManager.getUserFollowing { result in @@ -787,8 +697,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/me/following") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -799,9 +707,8 @@ class UserTests: XCTestCase { // MARK: - Friends - func test_get_friends() { - session.nextData = jsonData(named: "test_get_friends") - session.nextStatusCode = StatusCodes.Success + func test_get_friends() throws { + try mock(.GET, "https://api.trakt.tv/users/me/friends", result: .success(jsonData(named: "test_get_friends"))) let expectation = XCTestExpectation(description: "Get friends") traktManager.getUserFriends { result in @@ -816,8 +723,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/me/friends") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -828,9 +733,8 @@ class UserTests: XCTestCase { // MARK: - History - func test_get_user_watched_history() { - session.nextData = jsonData(named: "test_get_user_watched_history") - session.nextStatusCode = StatusCodes.Success + func test_get_user_watched_history() throws { + try mock(.GET, "https://api.trakt.tv/users/sean/history?extended=min", result: .success(jsonData(named: "test_get_user_watched_history"))) let expectation = XCTestExpectation(description: "Get user watched history") traktManager.getUserWatchedHistory(username: "sean") { result in @@ -841,8 +745,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/sean/history?extended=min") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -853,9 +755,8 @@ class UserTests: XCTestCase { // MARK: - Ratings - func test_get_user_ratings() { - session.nextData = jsonData(named: "test_get_user_ratings") - session.nextStatusCode = StatusCodes.Success + func test_get_user_ratings() throws { + try mock(.GET, "https://api.trakt.tv/users/sean/ratings", result: .success(jsonData(named: "test_get_user_ratings"))) let expectation = XCTestExpectation(description: "Get user ratings") traktManager.getUserRatings(username: "sean") { result in @@ -866,8 +767,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/sean/ratings") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -878,9 +777,8 @@ class UserTests: XCTestCase { // MARK: - Watchlist - func test_get_user_watchlist() { - session.nextData = jsonData(named: "test_get_user_watchlist") - session.nextStatusCode = StatusCodes.Success + func test_get_user_watchlist() throws { + try mock(.GET, "https://api.trakt.tv/users/sean/watchlist/movies?extended=min", result: .success(jsonData(named: "test_get_user_watchlist"))) let expectation = XCTestExpectation(description: "Get user watchlist") traktManager.getUserWatchlist(username: "sean", type: .Movies, extended: [.Min]) { result in @@ -891,8 +789,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/sean/watchlist/movies?extended=min") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -903,9 +799,8 @@ class UserTests: XCTestCase { // MARK: - Watching - func test_get_user_watching() { - session.nextData = jsonData(named: "test_get_user_watching") - session.nextStatusCode = StatusCodes.Success + func test_get_user_watching() throws { + try mock(.GET, "https://api.trakt.tv/users/sean/watching", result: .success(jsonData(named: "test_get_user_watching"))) let expectation = XCTestExpectation(description: "Get watching") traktManager.getUserWatching(username: "sean") { result in @@ -916,8 +811,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/sean/watching") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -928,8 +821,8 @@ class UserTests: XCTestCase { // MARK: - Watched movies / shows - func test_get_user_watched_movies() { - session.nextData = jsonData(named: "test_get_user_watched_movies") + func test_get_user_watched_movies() throws { + try mock(.GET, "https://api.trakt.tv/users/me/watched/movies?extended=min", result: .success(jsonData(named: "test_get_user_watched_movies"))) let expectation = XCTestExpectation(description: "User Watched movies") traktManager.getUserWatched(type: .movies) { result in @@ -944,8 +837,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/me/watched/movies?extended=min") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -954,8 +845,8 @@ class UserTests: XCTestCase { } } - func test_get_user_watched_shows() { - session.nextData = jsonData(named: "test_get_user_watched_shows") + func test_get_user_watched_shows() throws { + try mock(.GET, "https://api.trakt.tv/users/me/watched/shows?extended=min", result: .success(jsonData(named: "test_get_user_watched_shows"))) let expectation = XCTestExpectation(description: "User Watched shows") traktManager.getUserWatched(type: .shows) { result in @@ -970,8 +861,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/me/watched/shows?extended=min") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -980,8 +869,8 @@ class UserTests: XCTestCase { } } - func test_get_user_watched_shows_no_seasons() { - session.nextData = jsonData(named: "test_get_user_watched_shows_no_seasons") + func test_get_user_watched_shows_no_seasons() throws { + try mock(.GET, "https://api.trakt.tv/users/me/watched/shows?extended=noseasons", result: .success(jsonData(named: "test_get_user_watched_shows_no_seasons"))) let expectation = XCTestExpectation(description: "User Watched shows without seasons") traktManager.getUserWatched(type: .shows, extended: [.noSeasons]) { result in @@ -997,8 +886,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/me/watched/shows?extended=noseasons") - switch result { case .timedOut: XCTFail("Something isn't working") @@ -1009,8 +896,8 @@ class UserTests: XCTestCase { // MARK: - Stats - func test_get_user_stats() { - session.nextData = jsonData(named: "test_get_user_stats") + func test_get_user_stats() throws { + try mock(.GET, "https://api.trakt.tv/users/me/stats", result: .success(jsonData(named: "test_get_user_stats"))) let expectation = XCTestExpectation(description: "User Stats") traktManager.getUserStats { result in @@ -1057,8 +944,6 @@ class UserTests: XCTestCase { } let result = XCTWaiter().wait(for: [expectation], timeout: 1) - XCTAssertEqual(session.lastURL?.absoluteString, "https://api.trakt.tv/users/me/stats") - switch result { case .timedOut: XCTFail("Something isn't working") diff --git a/Tests/TraktKitTests/update_tests.py b/Tests/TraktKitTests/update_tests.py new file mode 100644 index 0000000..9819a5f --- /dev/null +++ b/Tests/TraktKitTests/update_tests.py @@ -0,0 +1,63 @@ +import re +import sys + +def update_test_code(file_path): + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + # Matches each test function with its body + test_function_pattern = re.compile(r'(func test_[a-zA-Z0-9_]+\(\))\s*\{((?:.|\n)*?)\n\s{4}\}', re.DOTALL) + + def process_test_function(match): + func_def, body = match.groups() + print(f"body debug: {body}") + + # Skip if already updated (contains `mock(...)`) + if "mock(" in body: + return match.group(0) + + # Ensure `throws` is correctly added after the parentheses + if "throws" not in func_def: + func_def = func_def.rstrip('()') + "() throws {" + + # Extract JSON filename in this function + next_data_match = re.search(r'session\.nextData = jsonData\(named: "(.*?)"\)', body) + json_filename = next_data_match.group(1) if next_data_match else None + + # Extract URL in this function + url_match = re.search(r'XCTAssertEqual\(session\.lastURL\?\.(?:absoluteString)?, "(.*?)"\)', body) + url = url_match.group(1) if url_match else None + + print(f"json filename debug: {json_filename}") + print(f"url debug: {url}") + + # Apply transformations only if both values exist + if json_filename and url: + # Replace `session.nextData` line with `mock(...)` + body = re.sub( + r'session\.nextData = jsonData\(named: ".*?"\)', + f'try mock(.GET, "{url}", result: .success(jsonData(named: "{json_filename}")))', + body + ) + + # Remove `XCTAssertEqual(session.lastURL...)` + body = re.sub(r'XCTAssertEqual\(session\.lastURL\?\.(?:absoluteString)?, ".*?"\)\n?', '', body) + + # Add the closing bracket `}` at the end + return f"{func_def}{body}\n }}" + + # Apply transformation to all functions in the file + updated_content = test_function_pattern.sub(process_test_function, content) + + with open(file_path, "w", encoding="utf-8") as f: + f.write(updated_content) + + print(f"Updated {file_path}") + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python update_tests.py ...") + sys.exit(1) + + for file_path in sys.argv[1:]: + update_test_code(file_path) From b8234f3814f1f2f3820b7c0c33a4df7a25f2ec2b Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Tue, 18 Feb 2025 22:12:10 -0500 Subject: [PATCH 04/38] Update async endpoints --- Common/DependencyContainer.swift | 53 +++++++++ .../Wrapper/Resources/EpisodeResource.swift | 111 ++++++++++++++++-- .../Wrapper/Resources/ExploreResource.swift | 103 +++++----------- Common/Wrapper/Resources/SearchResource.swift | 20 +--- Common/Wrapper/Resources/SeasonResource.swift | 51 ++++++-- Common/Wrapper/Resources/ShowResource.swift | 63 ++++++++-- .../Resources/TraktManager+AsyncAwait.swift | 15 --- .../Resources/TraktManager+Resources.swift | 10 +- Common/Wrapper/Resources/UserResource.swift | 52 +++++--- Common/Wrapper/TraktManager.swift | 2 +- 10 files changed, 322 insertions(+), 158 deletions(-) create mode 100644 Common/DependencyContainer.swift delete mode 100644 Common/Wrapper/Resources/TraktManager+AsyncAwait.swift diff --git a/Common/DependencyContainer.swift b/Common/DependencyContainer.swift new file mode 100644 index 0000000..085e9a4 --- /dev/null +++ b/Common/DependencyContainer.swift @@ -0,0 +1,53 @@ +// +// DependencyContainer.swift +// TraktKit +// +// Created by Maximilian Litteral on 2/9/25. +// + +import Foundation + +public final class DependencyContainer: @unchecked Sendable { + private static let lock = NSLock() + nonisolated(unsafe) private static var _shared: DependencyContainer? + + private let instanceLock = NSLock() + + public static var shared: DependencyContainer { + lock.withLock { + if _shared == nil { + _shared = DependencyContainer() + } + return _shared! + } + } + + // MARK: - Dependencies + private var _traktClient: TraktManager + + private init() { + self._traktClient = TraktManager.sharedManager + } + + // MARK: - Dependency Access + var traktClient: TraktManager { + get { instanceLock.withLock { _traktClient } } + set { instanceLock.withLock { _traktClient = newValue } } + } + + // MARK: - Testing Support + public static func reset() { + lock.withLock { + _shared = DependencyContainer() + } + } +} + +@propertyWrapper +public struct InjectedClient { + public var wrappedValue: TraktManager { + DependencyContainer.shared.traktClient + } + + public init() { } +} diff --git a/Common/Wrapper/Resources/EpisodeResource.swift b/Common/Wrapper/Resources/EpisodeResource.swift index 64288fe..239dc1d 100644 --- a/Common/Wrapper/Resources/EpisodeResource.swift +++ b/Common/Wrapper/Resources/EpisodeResource.swift @@ -12,26 +12,113 @@ public struct EpisodeResource { public let showId: CustomStringConvertible public let seasonNumber: Int public let episodeNumber: Int - public let traktManager: TraktManager private let path: String - init(showId: CustomStringConvertible, seasonNumber: Int, episodeNumber: Int, traktManager: TraktManager = .sharedManager) { + init(showId: CustomStringConvertible, seasonNumber: Int, episodeNumber: Int) { self.showId = showId self.seasonNumber = seasonNumber self.episodeNumber = episodeNumber - self.traktManager = traktManager self.path = "shows/\(showId)/seasons/\(seasonNumber)/episodes/\(episodeNumber)" } - - public func summary() async throws -> Route { - try await traktManager.get(path) + + /** + Returns a single episode's details. All date and times are in UTC and were calculated using the episode's `air_date` and show's `country` and `air_time`. + + **Note**: If the `first_aired` is unknown, it will be set to `null`. + */ + public func summary() -> Route { + Route(path: path, method: .GET) } - - public func comments() async throws -> Route<[Comment]> { - try await traktManager.get(path + "/comments") + + /** + Returns all translations for an episode, including language and translated values for title and overview. + + - parameter language: 2 character language code + */ + public func translations(language: String? = nil) -> Route<[TraktEpisodeTranslation]> { + var path = path + "/translations" + if let language { + path += "/\(language)" + } + return Route(path: path, method: .GET) } - - public func people() async throws -> Route> { - try await traktManager.get(path + "/people") + + /** + Returns all top level comments for an episode. Most recent comments returned first. + + 📄 Pagination + */ + public func comments() -> Route<[Comment]> { + Route(path: path + "/comments", method: .GET) + } + + /** + Returns all lists that contain this episode. By default, `personal` lists are returned sorted by the most `popular`. + + 📄 Pagination + */ + public func containingLists() -> Route<[TraktList]> { + Route(path: path + "/lists", method: .GET) + } + + /** + Returns rating (between 0 and 10) and distribution for an episode. + */ + public func ratings() -> Route { + Route(path: path + "/ratings", method: .GET) + } + + /** + Returns all `cast` and `crew` for an episode. Each `cast` member will have a `characters` array and a standard person object. + + The `crew` object will be broken up by department into `production, art, crew, costume & make-up, directing, writing, sound, camera, visual effects, lighting, and editing` (if there are people for those crew positions). Each of those members will have a `jobs` array and a standard `person` object. + + **Guest Stars** + + If you add `?extended=guest_stars` to the URL, it will return all guest stars that appeared in the episode. + + > note: This returns a lot of data, so please only use this extended parameter if you actually need it! + + ✨ Extended Info + */ + public func people() -> Route> { + Route(path: path + "/comments", method: .GET) + } + + /** + Returns lots of episode stats. + */ + public func stats() -> Route { + Route(path: path + "/stats", method: .GET) + } + + /** + Returns all users watching this episode right now. + ✨ Extended Info + */ + public func usersWatching() -> Route<[User]> { + Route(path: path + "/watching", method: .GET) + } + + /** + Returns all videos including trailers, teasers, clips, and featurettes. + ✨ Extended Info + */ + public func videos() -> Route<[TraktVideo]> { + Route(path: path + "/videos", method: .GET) + } +} + +extension Route where T == [TraktList] { + func listType(_ listType: ListType) -> Self { + var copy = self + copy.path = "\(path)/\(listType.rawValue)" + return copy + } + + func sort(by sortType: ListSortType) -> Self { + var copy = self + copy.path = "\(path)/\(sortType.rawValue)" + return copy } } diff --git a/Common/Wrapper/Resources/ExploreResource.swift b/Common/Wrapper/Resources/ExploreResource.swift index a2fd6f1..ca67fc6 100644 --- a/Common/Wrapper/Resources/ExploreResource.swift +++ b/Common/Wrapper/Resources/ExploreResource.swift @@ -8,128 +8,87 @@ import Foundation -public struct ExploreResource { - +public struct ExploreResource: Sendable { + // MARK: - Properties - public let traktManager: TraktManager - - public lazy var trending = Trending(traktManager: traktManager) - public lazy var popular = Popular(traktManager: traktManager) - public lazy var recommended = Recommended(traktManager: traktManager) - public lazy var played = Played(traktManager: traktManager) - public lazy var watched = Watched(traktManager: traktManager) - public lazy var collected = Collected(traktManager: traktManager) - public lazy var anticipated = Anticipated(traktManager: traktManager) + public let trending = Trending() + public let popular = Popular() + public let recommended = Recommended() + public let played = Played() + public let watched = Watched() + public let collected = Collected() + public let anticipated = Anticipated() - public init(traktManager: TraktManager = .sharedManager) { - self.traktManager = traktManager - } - // MARK: - Routes - public struct Trending { - public let traktManager: TraktManager - public init(traktManager: TraktManager = .sharedManager) { - self.traktManager = traktManager - } - - public func shows() -> Route<[TraktTrendingShow]> { - Route(path: "shows/trending", method: .GET, traktManager: traktManager) + public struct Trending: Sendable { + public func shows() -> Route> { + Route(path: "shows/trending", method: .GET) } public func movies() -> Route<[TraktTrendingMovie]> { - Route(path: "movies/trending", method: .GET, traktManager: traktManager) + Route(path: "movies/trending", method: .GET) } } - public struct Popular { - public let traktManager: TraktManager - public init(traktManager: TraktManager = .sharedManager) { - self.traktManager = traktManager - } - + public struct Popular: Sendable { public func shows() -> Route<[TraktShow]> { - Route(path: "shows/popular", method: .GET, traktManager: traktManager) + Route(path: "shows/popular", method: .GET) } public func movies() -> Route<[TraktMovie]> { - Route(path: "movies/popular", method: .GET, traktManager: traktManager) + Route(path: "movies/popular", method: .GET) } } - public struct Recommended { - public let traktManager: TraktManager - public init(traktManager: TraktManager = .sharedManager) { - self.traktManager = traktManager - } - + public struct Recommended: Sendable { public func shows() -> Route<[TraktTrendingShow]> { - Route(path: "shows/recommended", method: .GET, traktManager: traktManager) + Route(path: "shows/recommended", method: .GET) } public func movies() -> Route<[TraktTrendingMovie]> { - Route(path: "movies/recommended", method: .GET, traktManager: traktManager) + Route(path: "movies/recommended", method: .GET) } } - public struct Played { - public let traktManager: TraktManager - public init(traktManager: TraktManager = .sharedManager) { - self.traktManager = traktManager - } - + public struct Played: Sendable { public func shows() -> Route<[TraktMostShow]> { - Route(path: "shows/played", method: .GET, traktManager: traktManager) + Route(path: "shows/played", method: .GET) } public func movies() -> Route<[TraktMostMovie]> { - Route(path: "movies/played", method: .GET, traktManager: traktManager) + Route(path: "movies/played", method: .GET) } } - public struct Watched { - public let traktManager: TraktManager - public init(traktManager: TraktManager = .sharedManager) { - self.traktManager = traktManager - } - + public struct Watched: Sendable { public func shows() -> Route<[TraktMostShow]> { - Route(path: "shows/watched", method: .GET, traktManager: traktManager) + Route(path: "shows/watched", method: .GET) } public func movies() -> Route<[TraktMostMovie]> { - Route(path: "movies/watched", method: .GET, traktManager: traktManager) + Route(path: "movies/watched", method: .GET) } } - public struct Collected { - public let traktManager: TraktManager - public init(traktManager: TraktManager = .sharedManager) { - self.traktManager = traktManager - } - + public struct Collected: Sendable { public func shows() -> Route<[TraktTrendingShow]> { - Route(path: "shows/collected", method: .GET, traktManager: traktManager) + Route(path: "shows/collected", method: .GET) } public func movies() -> Route<[TraktTrendingMovie]> { - Route(path: "movies/collected", method: .GET, traktManager: traktManager) + Route(path: "movies/collected", method: .GET) } } - public struct Anticipated { - public let traktManager: TraktManager - public init(traktManager: TraktManager = .sharedManager) { - self.traktManager = traktManager - } - + public struct Anticipated: Sendable { public func shows() -> Route<[TraktAnticipatedShow]> { - Route(path: "shows/anticipated", method: .GET, traktManager: traktManager) + Route(path: "shows/anticipated", method: .GET) } public func movies() -> Route<[TraktAnticipatedMovie]> { - Route(path: "movies/anticipated", method: .GET, traktManager: traktManager) + Route(path: "movies/anticipated", method: .GET) } } } diff --git a/Common/Wrapper/Resources/SearchResource.swift b/Common/Wrapper/Resources/SearchResource.swift index a2244a8..a15e057 100644 --- a/Common/Wrapper/Resources/SearchResource.swift +++ b/Common/Wrapper/Resources/SearchResource.swift @@ -1,5 +1,5 @@ // -// File.swift +// SearchResource.swift // // // Created by Maximilian Litteral on 10/2/22. @@ -9,27 +9,17 @@ import Foundation public struct SearchResource { - // MARK: - Properties - - public let traktManager: TraktManager - - // MARK: - Lifecycle - - public init(traktManager: TraktManager = .sharedManager) { - self.traktManager = traktManager - } - // MARK: - Actions public func search( _ query: String, types: [SearchType]// = [.movie, .show, .episode, .person, .list] - ) async throws -> Route<[TraktSearchResult]> { + ) -> Route<[TraktSearchResult]> { let searchTypes = types.map { $0.rawValue }.joined(separator: ",") - return try await traktManager.get("search/\(searchTypes)", resultType: [TraktSearchResult].self).query(query) + return Route(path: "search/\(searchTypes)", method: .GET).query(query) } - public func lookup(_ id: LookupType) async throws -> Route<[TraktSearchResult]> { - try await traktManager.get("search/\(id.name)/\(id.id)") + public func lookup(_ id: LookupType) -> Route<[TraktSearchResult]> { + Route(path: "search/\(id.name)/\(id.id)", method: .GET) } } diff --git a/Common/Wrapper/Resources/SeasonResource.swift b/Common/Wrapper/Resources/SeasonResource.swift index 53fffad..f1bcc8d 100644 --- a/Common/Wrapper/Resources/SeasonResource.swift +++ b/Common/Wrapper/Resources/SeasonResource.swift @@ -11,22 +11,56 @@ import Foundation public struct SeasonResource { public let showId: CustomStringConvertible public let seasonNumber: Int - public let traktManager: TraktManager + private let path: String - init(showId: CustomStringConvertible, seasonNumber: Int, traktManager: TraktManager = .sharedManager) { + init(showId: CustomStringConvertible, seasonNumber: Int) { self.showId = showId self.seasonNumber = seasonNumber - self.traktManager = traktManager + self.path = "shows/\(showId)/seasons/\(seasonNumber)" } // MARK: - Methods - public func summary() async throws -> Route { - try await traktManager.get("shows/\(showId)/seasons/\(seasonNumber)") + /** + Returns a single seasons for a show. + + ✨ Extended Info + */ + public func info() -> Route { + Route(path: "\(path)/info", method: .GET) } - public func comments() async throws -> Route<[Comment]> { - try await traktManager.get("shows/\(showId)/seasons/\(seasonNumber)/comments") + /** + Returns all episodes for a specific season of a show. + + **Translations** + + If you'd like to included translated episode titles and overviews in the response, include the translations parameter in the URL. Include all languages by setting the parameter to `all` or use a specific 2 digit country language code to further limit it. + + > note: This returns a lot of data, so please only use this extended parameter if you actually need it! + + ✨ Extended Info + */ + public func episodes() -> Route<[TraktEpisode]> { + Route(path: "\(path)", method: .GET) + } + + /** + Returns all translations for an season, including language and translated values for title and overview. + */ + public func translations(language: String) -> Route<[TraktSeasonTranslation]> { + Route(path: "\(path)/translations/\(language)", method: .GET) + } + + /** + Returns all top level comments for a season. By default, the `newest` comments are returned first. Other sorting options include `oldest`, most `likes`, most `replies`, `highest` rated, `lowest` rated, most `plays`, and highest `watched` percentage. + + > note: If you send OAuth, comments from blocked users will be automatically filtered out. + + 🔓 OAuth Optional 📄 Pagination 😁 Emojis + */ + public func comments() -> Route<[Comment]> { + Route(path: "\(path)/comments", method: .GET) } // MARK: - Resources @@ -35,8 +69,7 @@ public struct SeasonResource { EpisodeResource( showId: showId, seasonNumber: seasonNumber, - episodeNumber: number, - traktManager: traktManager + episodeNumber: number ) } } diff --git a/Common/Wrapper/Resources/ShowResource.swift b/Common/Wrapper/Resources/ShowResource.swift index 2957b3d..60df02d 100644 --- a/Common/Wrapper/Resources/ShowResource.swift +++ b/Common/Wrapper/Resources/ShowResource.swift @@ -10,35 +10,78 @@ import Foundation public struct ShowResource { + // MARK: - Static (Non-specific show endpoints) + + /** + Returns all shows updated since the specified date. We recommended storing the date you can be efficient using this method moving forward. + + 📄 Pagination + + > important: The `startDate` is only accurate to the hour, for caching purposes. Please drop the minutes and seconds from your timestamp to help optimize our cached data. For example, use `2021-07-17T12:00:00Z` and not `2021-07-17T12:23:34Z` + + > note: .The `startDate` can only be a maximum of 30 days in the past. + */ + public static func recentlyUpdated(since startDate: Date) async throws -> Route<[Update]> { + let formattedDate = startDate.dateString(withFormat: "yyyy-MM-dd'T'HH:mm:ss") + return Route(path: "shows/updates/\(formattedDate)", method: .GET) + } + + /** + Returns all show Trakt IDs updated since the specified UTC date and time. We recommended storing the X-Start-Date header you can be efficient using this method moving forward. By default, 10 results are returned. You can send a limit to get up to 100 results per page. + + 📄 Pagination + + > important: The `startDate` is only accurate to the hour, for caching purposes. Please drop the minutes and seconds from your timestamp to help optimize our cached data. For example, use `2021-07-17T12:00:00Z` and not `2021-07-17T12:23:34Z` + + > note: .The `startDate` can only be a maximum of 30 days in the past. + */ + public static func recentlyUpdatedIds(since startDate: Date) async throws -> Route<[Update]> { + let formattedDate = startDate.dateString(withFormat: "yyyy-MM-dd'T'HH:mm:ss") + return Route(path: "shows/updates/id\(formattedDate)", method: .GET) + } + // MARK: - Properties public let id: CustomStringConvertible - public let traktManager: TraktManager // MARK: - Lifecycle - public init(id: CustomStringConvertible, traktManager: TraktManager = .sharedManager) { + public init(id: CustomStringConvertible) { self.id = id - self.traktManager = traktManager } // MARK: - Methods - public func summary() async throws -> Route { - try await traktManager.get("shows/\(id)") + public func summary() -> Route { + Route(path: "shows/\(id)", method: .GET) + } + + public func aliases() -> Route<[Alias]> { + Route(path: "shows/\(id)/aliases", method: .GET) } - public func aliases() async throws -> Route<[Alias]> { - Route(path: "shows/\(id)/aliases", method: .GET, traktManager: traktManager) + public func certifications() -> Route { + Route(path: "shows/\(id)/certifications", method: .GET) } - public func certifications() async throws -> Route { - Route(path: "shows/\(id)/certifications", method: .GET, traktManager: traktManager) + /** + Returns all seasons for a show including the number of episodes in each season. + + **Episodes** + + If you add `?extended=episodes` to the URL, it will return all episodes for all seasons. + + > note: This returns a lot of data, so please only use this extended parameter if you actually need it! + + ✨ Extended Info + */ + public func seasons() -> Route<[TraktSeason]> { + Route(path: "shows/\(id)/seasons", method: .GET) } // MARK: - Resources public func season(_ number: Int) -> SeasonResource { - SeasonResource(showId: id, seasonNumber: number, traktManager: traktManager) + SeasonResource(showId: id, seasonNumber: number) } } diff --git a/Common/Wrapper/Resources/TraktManager+AsyncAwait.swift b/Common/Wrapper/Resources/TraktManager+AsyncAwait.swift deleted file mode 100644 index 706e2fc..0000000 --- a/Common/Wrapper/Resources/TraktManager+AsyncAwait.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// TraktManager+AsyncAwait.swift -// TraktKit -// -// Created by Maxamilian Litteral on 6/14/21. -// Copyright © 2021 Maximilian Litteral. All rights reserved. -// - -import Foundation - -extension TraktManager { - func `get`(_ path: String, authorized: Bool = false, resultType: T.Type = T.self) async throws -> Route { - Route(path: path, method: .GET, requiresAuthentication: authorized, traktManager: self) - } -} diff --git a/Common/Wrapper/Resources/TraktManager+Resources.swift b/Common/Wrapper/Resources/TraktManager+Resources.swift index 238596f..7c09dfe 100644 --- a/Common/Wrapper/Resources/TraktManager+Resources.swift +++ b/Common/Wrapper/Resources/TraktManager+Resources.swift @@ -11,15 +11,15 @@ import Foundation extension TraktManager { public func show(id: CustomStringConvertible) -> ShowResource { - ShowResource(id: id, traktManager: self) + ShowResource(id: id) } public func season(showId: CustomStringConvertible, season: Int) -> SeasonResource { - SeasonResource(showId: showId, seasonNumber: season, traktManager: self) + SeasonResource(showId: showId, seasonNumber: season) } public func episode(showId: CustomStringConvertible, season: Int, episode: Int) -> EpisodeResource { - EpisodeResource(showId: showId, seasonNumber: season, episodeNumber: episode, traktManager: self) + EpisodeResource(showId: showId, seasonNumber: season, episodeNumber: episode) } public func currentUser() -> CurrentUserResource { @@ -27,10 +27,10 @@ extension TraktManager { } public func user(_ username: String) -> UsersResource { - UsersResource(username: username, traktManager: self) + UsersResource(username: username) } public func search() -> SearchResource { - SearchResource(traktManager: self) + SearchResource() } } diff --git a/Common/Wrapper/Resources/UserResource.swift b/Common/Wrapper/Resources/UserResource.swift index 8991d37..f947441 100644 --- a/Common/Wrapper/Resources/UserResource.swift +++ b/Common/Wrapper/Resources/UserResource.swift @@ -10,41 +10,55 @@ import Foundation extension TraktManager { /// Resource for authenticated user public struct CurrentUserResource { - public let traktManager: TraktManager - - init(traktManager: TraktManager = .sharedManager) { - self.traktManager = traktManager - } - // MARK: - Methods - public func settings() async throws -> Route { - try await traktManager.get("users/settings", authorized: true) + public func settings() -> Route { + Route(path: "users/settings", method: .GET, requiresAuthentication: true) + } + + // MARK: Following Requests + + /// List a user's pending following requests that they're waiting for the other user's to approve. + public func getPendingFollowingRequests() -> Route<[FollowRequest]> { + Route(path: "users/requests/following", method: .GET, requiresAuthentication: true) + } + + /// List a user's pending follow requests so they can either approve or deny them. + public func getFollowerRequests() -> Route<[FollowRequest]> { + Route(path: "users/requests", method: .GET, requiresAuthentication: true) + } + + public func approveFollowRequest(id: Int) -> Route { + Route(path: "users/requests/\(id)", method: .POST, requiresAuthentication: true) + } + + public func denyFollowRequest(id: Int) -> EmptyRoute { +// Route(path: "users/requests/\(id)", method: .DELETE, requiresAuthentication: true) + EmptyRoute(request: URLRequest(url: URL(string: "")!)) } } - + /// Resource for /Users/id public struct UsersResource { - public let username: String - public let traktManager: TraktManager - init(username: String, traktManager: TraktManager = .sharedManager) { + public init(username: String) { self.username = username - self.traktManager = traktManager } // MARK: - Methods - - public func lists() async throws -> Route<[TraktList]> { - try await traktManager.get("users/\(username)/lists") + + // MARK: Settings + + public func lists() -> Route<[TraktList]> { + Route(path: "users/\(username)/lists", method: .GET) } - public func itemsOnList(_ listId: String, type: ListItemType? = nil) async throws -> Route<[TraktListItem]> { + public func itemsOnList(_ listId: String, type: ListItemType? = nil) -> Route<[TraktListItem]> { if let type = type { - return try await traktManager.get("users/\(username)/lists/\(listId)/items/\(type.rawValue)") + return Route(path: "users/\(username)/lists/\(listId)/items/\(type.rawValue)", method: .GET) } else { - return try await traktManager.get("users/\(username)/lists/\(listId)/items") + return Route(path: "users/\(username)/lists/\(listId)/items", method: .GET) } } } diff --git a/Common/Wrapper/TraktManager.swift b/Common/Wrapper/TraktManager.swift index efe3994..ca0d997 100644 --- a/Common/Wrapper/TraktManager.swift +++ b/Common/Wrapper/TraktManager.swift @@ -94,7 +94,7 @@ public final class TraktManager: Sendable { let session: URLSession - public lazy var explore: ExploreResource = ExploreResource(traktManager: self) + public let explore = ExploreResource() // MARK: Public From 885d414fdf11f0d39a615d25a7fb4d86d87401db Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Tue, 18 Feb 2025 22:12:21 -0500 Subject: [PATCH 05/38] Update README --- License.md | 2 +- README.md | 60 ++++++++++-------------------------------------------- 2 files changed, 12 insertions(+), 50 deletions(-) diff --git a/License.md b/License.md index f381d2a..b718421 100644 --- a/License.md +++ b/License.md @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2015 Maximilian Litteral +Copyright (c) 2015-2025 Maximilian Litteral Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index dadf887..4b2f9f2 100644 --- a/README.md +++ b/README.md @@ -5,54 +5,13 @@ # TraktKit Swift wrapper for Trakt.tv API. -[![Pod version](https://badge.fury.io/co/TraktKit.svg)](https://badge.fury.io/co/TraktKit) -[![Carthage Compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg)](https://github.com/Carthage/Carthage) -[![License](https://img.shields.io/cocoapods/l/TraktKit.svg)](https://cocoapods.org/pods/TraktKit) -[![Platform](https://img.shields.io/cocoapods/p/TraktKit.svg)](https://cocoapods.org/pods/TraktKit) -![language Swift 4.2](https://img.shields.io/badge/language-Swift%204.2-orange.svg) +[![SPM: Compatible](https://img.shields.io/badge/SPM-Compatible-4BC51D.svg?style=flat)](https://swift.org/package-manager/) +[![Platforms: iOS | watchOS | macCatalyst | macOS | tvOS | visionOS](https://img.shields.io/badge/Platforms-iOS%20%7C%20watchOS%20%7C%20macCatalyst%20%7C%20macOS%20%7C%20tvOS%20%7C%20visionOS-blue.svg?style=flat)] +[![Language: Swift 6.0](https://img.shields.io/badge/Language-Swift%206.0-F48041.svg?style=flat)](https://developer.apple.com/swift) +[![License: MIT](http://img.shields.io/badge/License-MIT-lightgray.svg?style=flat)](https://github.com/MaxHasADHD/TraktKit/blob/master/License.md) -## Installation - -### CocoaPods - -[CocoaPods](http://cocoapods.org) is a dependency manager for Cocoa projects. You can install it with the following command: - -```bash -$ gem install cocoapods -``` - -To integrate TraktKit into your Xcode project using CocoaPods, specify it in your `Podfile`: - -```ruby -use_frameworks! - -pod 'TraktKit' -``` - -Then, run the following command: - -```bash -$ pod install -``` -### Carthage - -[Carthage](https://github.com/Carthage/Carthage) is a decentralized dependency manager that builds your dependencies and provides you with binary frameworks. - -You can install Carthage with [Homebrew](http://brew.sh/) using the following command: - -```bash -$ brew update -$ brew install carthage -``` - -To integrate TraktKit into your Xcode project using Carthage, specify it in your `Cartfile`: - -```ogdl -github "MaxHasADHD/TraktKit" -``` - -Run `carthage update` to build the framework and drag the built `TraktKit.framework` into your Xcode project. +## Installation ### Swift Package Manager @@ -63,16 +22,19 @@ To integrate TraktKit into your Xcode project using Swift Package Manager. Selec When prompted, simply search for TraktKit or specify the project's GitHub repository: ``` -git@github.com:MaxHasADHD/TraktKit.git -``` +https://github.com/MaxHasADHD/TraktKit +``` ### Usage See the [example project](https://github.com/MaxHasADHD/TraktKit/tree/master/Example) for usage +### Author +Maximilian Litteral + ### License The MIT License (MIT) -Copyright (c) 2016 Maximilian Litteral +Copyright (c) 2015-2025 Maximilian Litteral Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: From 2375bfc0029adc5c08265dfad45faf68da8e38a8 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Thu, 20 Feb 2025 05:47:58 -0500 Subject: [PATCH 06/38] Implement EmptyRoute Implemented EmptyRoute for requests that are expected to return no data, and where we expect a 201 or 204 response. --- .../xcshareddata/xcschemes/TraktKit.xcscheme | 2 +- Common/Wrapper/CompletionHandlers.swift | 38 ++++++++----------- Common/Wrapper/Resources/UserResource.swift | 13 ++++++- Common/Wrapper/Route.swift | 33 ++++++++++++++-- Tests/TraktKitTests/UserTests.swift | 6 +++ 5 files changed, 64 insertions(+), 28 deletions(-) diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/TraktKit.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/TraktKit.xcscheme index 957c110..9258b6a 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/TraktKit.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/TraktKit.xcscheme @@ -1,6 +1,6 @@ (Data, URLResponse) { let (data, response) = try await session.data(for: request) - try await handle(response: response) - return (data, response) + try handleResponse(response: response) + do throws(TraktError) { + try self.handleResponse(response: response) + return (data, response) + } catch { + switch error { + case .retry(let after): + try await Task.sleep(for: .seconds(after)) + try Task.checkCancellation() + return try await fetchData(request: request) + default: + throw error + } + } } func perform(request: URLRequest) async throws -> T { let (data, response) = try await session.data(for: request) - try await handle(response: response) + try handleResponse(response: response) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom(customDateDecodingStrategy) diff --git a/Common/Wrapper/Resources/UserResource.swift b/Common/Wrapper/Resources/UserResource.swift index f947441..522718e 100644 --- a/Common/Wrapper/Resources/UserResource.swift +++ b/Common/Wrapper/Resources/UserResource.swift @@ -28,13 +28,22 @@ extension TraktManager { Route(path: "users/requests", method: .GET, requiresAuthentication: true) } + /** + Approve a follower using the id of the request. If the id is not found, was already approved, or was already denied, a 404 error will be returned. + + 🔒 OAuth Required + */ public func approveFollowRequest(id: Int) -> Route { Route(path: "users/requests/\(id)", method: .POST, requiresAuthentication: true) } + /** + Deny a follower using the id of the request. If the id is not found, was already approved, or was already denied, a 404 error will be returned. + + 🔒 OAuth Required + */ public func denyFollowRequest(id: Int) -> EmptyRoute { -// Route(path: "users/requests/\(id)", method: .DELETE, requiresAuthentication: true) - EmptyRoute(request: URLRequest(url: URL(string: "")!)) + EmptyRoute(path: "users/requests/\(id)", method: .DELETE, requiresAuthentication: true) } } diff --git a/Common/Wrapper/Route.swift b/Common/Wrapper/Route.swift index 88e9414..04aeb84 100644 --- a/Common/Wrapper/Route.swift +++ b/Common/Wrapper/Route.swift @@ -129,15 +129,42 @@ public struct Route: Sendable { } } +public struct EmptyRoute: Sendable { + internal var path: String + internal let method: Method + internal let requiresAuthentication: Bool + + // MARK: - Lifecycle + + public init(path: String, method: Method, requiresAuthentication: Bool = false) { + self.path = path + self.method = method + self.requiresAuthentication = requiresAuthentication + } + + // MARK: - Perform + + public func perform() async throws { + @InjectedClient var traktManager + let request = try traktManager.mutableRequest( + forPath: path, + withQuery: [:], + isAuthorized: requiresAuthentication, + withHTTPMethod: method + ) + let _ = try await traktManager.fetchData(request: request) + } +} + public protocol PagedObjectProtocol { static var objectType: Decodable.Type { get } static func createPagedObject(with object: Decodable, currentPage: Int, pageCount: Int) -> Self } public struct PagedObject: PagedObjectProtocol, TraktObject { - let object: T - let currentPage: Int - let pageCount: Int + public let object: T + public let currentPage: Int + public let pageCount: Int public static var objectType: any Decodable.Type { T.self diff --git a/Tests/TraktKitTests/UserTests.swift b/Tests/TraktKitTests/UserTests.swift index 3c735db..d155e79 100644 --- a/Tests/TraktKitTests/UserTests.swift +++ b/Tests/TraktKitTests/UserTests.swift @@ -90,6 +90,12 @@ final class UserTests: TraktTestCase { } } + func test_deny_follow_request_async() async throws { + try mock(.DELETE, "https://api.trakt.tv/users/requests/123", result: .success(.init())) + + try await traktManager.currentUser().denyFollowRequest(id: 123).perform() + } + // MARK: - Hidden items func test_get_hidden_items() throws { From d6136f49f3b2fafef38575faeec46ddd0b5300d5 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Thu, 20 Feb 2025 05:48:25 -0500 Subject: [PATCH 07/38] Implement saved filters VIP request --- Common/Models/Users/SavedFilter.swift | 28 ++++++++++++++ Common/Wrapper/Resources/UserResource.swift | 12 ++++++ .../Models/Users/test_get_saved_filters.json | 38 +++++++++++++++++++ Tests/TraktKitTests/UserTests.swift | 12 ++++++ 4 files changed, 90 insertions(+) create mode 100644 Common/Models/Users/SavedFilter.swift create mode 100644 Tests/TraktKitTests/Models/Users/test_get_saved_filters.json diff --git a/Common/Models/Users/SavedFilter.swift b/Common/Models/Users/SavedFilter.swift new file mode 100644 index 0000000..339c172 --- /dev/null +++ b/Common/Models/Users/SavedFilter.swift @@ -0,0 +1,28 @@ +// +// SavedFilter.swift +// TraktKit +// +// Created by Maximilian Litteral on 2/18/25. +// + +import Foundation + +public struct SavedFilter: TraktObject { + public let rank: Int + public let id: Int + public let section: String + public let name: String + public let path: String + public let query: String + public let updatedAt: Date + + enum CodingKeys: String, CodingKey { + case rank + case id + case section + case name + case path + case query + case updatedAt = "updated_at" + } +} diff --git a/Common/Wrapper/Resources/UserResource.swift b/Common/Wrapper/Resources/UserResource.swift index 522718e..b1fd446 100644 --- a/Common/Wrapper/Resources/UserResource.swift +++ b/Common/Wrapper/Resources/UserResource.swift @@ -45,6 +45,18 @@ extension TraktManager { public func denyFollowRequest(id: Int) -> EmptyRoute { EmptyRoute(path: "users/requests/\(id)", method: .DELETE, requiresAuthentication: true) } + + /** + Get all saved filters a user has created. The path and query can be used to construct an API path to retrieve the saved data. Think of this like a dynamically updated list. + + 🔥 VIP Only + 🔒 OAuth Required + 📄 Pagination + */ + public func savedFilters(for section: String? = nil) -> Route<[SavedFilter]> { + let path = ["users/saved_filters", section].compactMap { $0 }.joined(separator: "/") + return Route(path: path, method: .POST, requiresAuthentication: true) + } } /// Resource for /Users/id diff --git a/Tests/TraktKitTests/Models/Users/test_get_saved_filters.json b/Tests/TraktKitTests/Models/Users/test_get_saved_filters.json new file mode 100644 index 0000000..01eaf71 --- /dev/null +++ b/Tests/TraktKitTests/Models/Users/test_get_saved_filters.json @@ -0,0 +1,38 @@ +[ + { + "rank": 1, + "id": 101, + "section": "movies", + "name": "Movies: IMDB + TMDB ratings", + "path": "/movies/favorited/weekly", + "query": "imdb_ratings=6.9-10.0&tmdb_ratings=4.2-10.0", + "updated_at": "2022-06-15T11:15:06.000Z" + }, + { + "rank": 2, + "id": 102, + "section": "shows", + "name": "Shows: US + Disney+", + "path": "/shows/popular", + "query": "watchnow=disney_plus&countries=us", + "updated_at": "2022-06-15T12:15:06.000Z" + }, + { + "rank": 3, + "id": 103, + "section": "calendars", + "name": "On Netflix", + "path": "/calendars/my/shows", + "query": "network_ids=53", + "updated_at": "2022-06-15T13:15:06.000Z" + }, + { + "rank": 4, + "id": 104, + "section": "search", + "name": "Action & Adventure", + "path": "/search/movie,show", + "query": "genres=+action,+adventure", + "updated_at": "2022-06-15T14:15:06.000Z" + } +] diff --git a/Tests/TraktKitTests/UserTests.swift b/Tests/TraktKitTests/UserTests.swift index d155e79..e819527 100644 --- a/Tests/TraktKitTests/UserTests.swift +++ b/Tests/TraktKitTests/UserTests.swift @@ -96,6 +96,18 @@ final class UserTests: TraktTestCase { try await traktManager.currentUser().denyFollowRequest(id: 123).perform() } + // MARK: - Saved filters + + func test_get_saved_filters() async throws { + try mock(.GET, "https://api.trakt.tv/users/saved_filters", result: .success(jsonData(named: "test_get_saved_filters"))) + + let filters = try await traktManager.currentUser().savedFilters().perform() + XCTAssertEqual(filters.count, 4) + + let firstFilter = try XCTUnwrap(filters.first) + XCTAssertEqual(firstFilter.id, 101) + } + // MARK: - Hidden items func test_get_hidden_items() throws { From d28f16a125818e1d4e603c6e9967fc91e7426470 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Thu, 20 Feb 2025 05:48:47 -0500 Subject: [PATCH 08/38] Update example app to build with Xcode 16 --- Common/DependencyContainer.swift | 2 +- .../contents.xcworkspacedata | 3 - .../TraktKitExample.xcodeproj/project.pbxproj | 61 +++++++++++++++---- .../TraktKitExample/AppDelegate.swift | 35 ++++++----- .../TraktKitExample/Info.plist | 6 +- .../SearchResultsViewController.swift | 6 +- .../TraktProfileViewController.swift | 4 +- .../TraktKitExample/ViewController.swift | 24 +++++--- 8 files changed, 94 insertions(+), 47 deletions(-) diff --git a/Common/DependencyContainer.swift b/Common/DependencyContainer.swift index 085e9a4..28c25e0 100644 --- a/Common/DependencyContainer.swift +++ b/Common/DependencyContainer.swift @@ -30,7 +30,7 @@ public final class DependencyContainer: @unchecked Sendable { } // MARK: - Dependency Access - var traktClient: TraktManager { + public var traktClient: TraktManager { get { instanceLock.withLock { _traktClient } } set { instanceLock.withLock { _traktClient = newValue } } } diff --git a/Example/TraktKitExample.xcworkspace/contents.xcworkspacedata b/Example/TraktKitExample.xcworkspace/contents.xcworkspacedata index ab6efae..848351c 100644 --- a/Example/TraktKitExample.xcworkspace/contents.xcworkspacedata +++ b/Example/TraktKitExample.xcworkspace/contents.xcworkspacedata @@ -4,7 +4,4 @@ - - diff --git a/Example/TraktKitExample/TraktKitExample.xcodeproj/project.pbxproj b/Example/TraktKitExample/TraktKitExample.xcodeproj/project.pbxproj index ba183e1..45ef153 100644 --- a/Example/TraktKitExample/TraktKitExample.xcodeproj/project.pbxproj +++ b/Example/TraktKitExample/TraktKitExample.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -13,9 +13,8 @@ 217335BA21E9355D0080BC5B /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 217335B821E9355D0080BC5B /* Main.storyboard */; }; 217335BC21E9355F0080BC5B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 217335BB21E9355F0080BC5B /* Assets.xcassets */; }; 217335BF21E9355F0080BC5B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 217335BD21E9355F0080BC5B /* LaunchScreen.storyboard */; }; - 217335D621E940030080BC5B /* TraktKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 217335D521E940030080BC5B /* TraktKit.framework */; }; - 217335D721E940100080BC5B /* TraktKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 217335D521E940030080BC5B /* TraktKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 217335DA21E949C60080BC5B /* TraktProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 217335D921E949C60080BC5B /* TraktProfileViewController.swift */; }; + F58A0FC52D67396100DF9BF4 /* TraktKit in Frameworks */ = {isa = PBXBuildFile; productRef = F58A0FC42D67396100DF9BF4 /* TraktKit */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -25,7 +24,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 217335D721E940100080BC5B /* TraktKit.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -50,7 +48,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 217335D621E940030080BC5B /* TraktKit.framework in Frameworks */, + F58A0FC52D67396100DF9BF4 /* TraktKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -112,6 +110,7 @@ buildRules = ( ); dependencies = ( + F58A0FC72D673E6100DF9BF4 /* PBXTargetDependency */, ); name = TraktKitExample; productName = TraktKitExample; @@ -124,8 +123,9 @@ 217335A921E9355D0080BC5B /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1010; - LastUpgradeCheck = 1010; + LastUpgradeCheck = 1620; ORGANIZATIONNAME = "Maximilian Litteral"; TargetAttributes = { 217335B021E9355D0080BC5B = { @@ -142,6 +142,9 @@ Base, ); mainGroup = 217335A821E9355D0080BC5B; + packageReferences = ( + F58A0FC32D67396100DF9BF4 /* XCLocalSwiftPackageReference "../../../TraktKit" */, + ); productRefGroup = 217335B221E9355D0080BC5B /* Products */; projectDirPath = ""; projectRoot = ""; @@ -178,6 +181,13 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + F58A0FC72D673E6100DF9BF4 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = F58A0FC62D673E6100DF9BF4 /* TraktKit */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 217335B821E9355D0080BC5B /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -202,6 +212,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -224,6 +235,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -235,6 +247,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -249,13 +262,14 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.1; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -263,6 +277,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -285,6 +300,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -296,6 +312,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -304,12 +321,13 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.1; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; }; name = Release; @@ -320,14 +338,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TraktKitExample/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.litteral.TraktKitExample; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -338,14 +356,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TraktKitExample/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.litteral.TraktKitExample; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -372,6 +390,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + F58A0FC32D67396100DF9BF4 /* XCLocalSwiftPackageReference "../../../TraktKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../../TraktKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + F58A0FC42D67396100DF9BF4 /* TraktKit */ = { + isa = XCSwiftPackageProductDependency; + productName = TraktKit; + }; + F58A0FC62D673E6100DF9BF4 /* TraktKit */ = { + isa = XCSwiftPackageProductDependency; + package = F58A0FC32D67396100DF9BF4 /* XCLocalSwiftPackageReference "../../../TraktKit" */; + productName = TraktKit; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 217335A921E9355D0080BC5B /* Project object */; } diff --git a/Example/TraktKitExample/TraktKitExample/AppDelegate.swift b/Example/TraktKitExample/TraktKitExample/AppDelegate.swift index b96af09..a1d6191 100644 --- a/Example/TraktKitExample/TraktKitExample/AppDelegate.swift +++ b/Example/TraktKitExample/TraktKitExample/AppDelegate.swift @@ -13,7 +13,7 @@ extension Notification.Name { static let TraktSignedIn = Notification.Name(rawValue: "TraktSignedIn") } -@UIApplicationMain +@main class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: - Properties @@ -25,16 +25,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Get keys from https://trakt.tv/oauth/applications } - var window: UIWindow? + let traktManager = TraktManager( + clientId: Constants.clientId, + clientSecret: Constants.clientSecret, + redirectURI: Constants.redirectURI + ) + var window: UIWindow? // MARK: - Lifecycle func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. - TraktManager.sharedManager.set(clientID: Constants.clientId, - clientSecret: Constants.clientSecret, - redirectURI: Constants.redirectURI) + DependencyContainer.shared.traktClient = traktManager return true } @@ -44,22 +47,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate { if url.host == "auth", let code = queryDict["code"] as? String { // Get authorization code - do { - try TraktManager.sharedManager.getTokenFromAuthorizationCode(code: code) { result in - switch result { - case .success: - print("Signed in to Trakt") - DispatchQueue.main.async { - NotificationCenter.default.post(name: .TraktSignedIn, object: nil) - } - case .fail: - print("Failed to sign in to Trakt") - } + + Task { @MainActor in + do { + let authorization = try await traktManager.getToken(authorizationCode: code) + print("Signed in to Trakt") + NotificationCenter.default.post(name: .TraktSignedIn, object: nil) + } catch { + print("Failed to get token: \(error)") } - } catch { - print(error.localizedDescription) } } + return true } } diff --git a/Example/TraktKitExample/TraktKitExample/Info.plist b/Example/TraktKitExample/TraktKitExample/Info.plist index 36e0d24..489a9e6 100644 --- a/Example/TraktKitExample/TraktKitExample/Info.plist +++ b/Example/TraktKitExample/TraktKitExample/Info.plist @@ -44,15 +44,13 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown diff --git a/Example/TraktKitExample/TraktKitExample/SearchResultsViewController.swift b/Example/TraktKitExample/TraktKitExample/SearchResultsViewController.swift index 35c2cbc..e2f262f 100644 --- a/Example/TraktKitExample/TraktKitExample/SearchResultsViewController.swift +++ b/Example/TraktKitExample/TraktKitExample/SearchResultsViewController.swift @@ -12,6 +12,8 @@ import TraktKit final class SearchResultsViewController: UITableViewController { // MARK: - Properties + @InjectedClient var traktManager + private var shows: [TraktShow] = [] { didSet { tableView.reloadData() @@ -22,10 +24,12 @@ final class SearchResultsViewController: UITableViewController { // MARK: - Lifecycle + @available(*, unavailable) required init?(coder aDecoder: NSCoder) { fatalError() } + @available(*, unavailable) override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { fatalError() } @@ -45,7 +49,7 @@ final class SearchResultsViewController: UITableViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - TraktManager.sharedManager.search(query: query, types: [.show], extended: [.Full], pagination: nil, filters: nil, fields: nil) { [weak self] result in + traktManager.search(query: query, types: [.show], extended: [.Full], pagination: nil, filters: nil, fields: nil) { [weak self] result in switch result { case .success(let objects): DispatchQueue.main.async { diff --git a/Example/TraktKitExample/TraktKitExample/TraktProfileViewController.swift b/Example/TraktKitExample/TraktKitExample/TraktProfileViewController.swift index 20b91f3..8af25ca 100644 --- a/Example/TraktKitExample/TraktKitExample/TraktProfileViewController.swift +++ b/Example/TraktKitExample/TraktKitExample/TraktProfileViewController.swift @@ -13,6 +13,8 @@ final class TraktProfileViewController: UIViewController { // MARK: - Properties + @InjectedClient var traktManager + private let stackView = UIStackView() // MARK: - Lifecycle @@ -26,7 +28,7 @@ final class TraktProfileViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - TraktManager.sharedManager.getUserProfile { [weak self] result in + traktManager.getUserProfile { [weak self] result in switch result { case .success(let user): DispatchQueue.main.async { [weak self] in diff --git a/Example/TraktKitExample/TraktKitExample/ViewController.swift b/Example/TraktKitExample/TraktKitExample/ViewController.swift index 2fd0fcf..2525234 100644 --- a/Example/TraktKitExample/TraktKitExample/ViewController.swift +++ b/Example/TraktKitExample/TraktKitExample/ViewController.swift @@ -6,6 +6,7 @@ // Copyright © 2019 Maximilian Litteral. All rights reserved. // +import Combine import UIKit import SafariServices import TraktKit @@ -14,8 +15,12 @@ final class ViewController: UIViewController { // MARK: - Properties + @InjectedClient var traktManager + private let stackView = UIStackView() + private var cancellables: Set = [] + // MARK: - Lifecycle override func viewDidLoad() { @@ -27,14 +32,14 @@ final class ViewController: UIViewController { // MARK: - Actions private func presentLogIn() { - guard let oauthURL = TraktManager.sharedManager.oauthURL else { return } + guard let oauthURL = traktManager.oauthURL else { return } let traktAuth = SFSafariViewController(url: oauthURL) present(traktAuth, animated: true, completion: nil) } private func signOut() { - TraktManager.sharedManager.signOut() + traktManager.signOut() refreshUI() } @@ -86,10 +91,15 @@ final class ViewController: UIViewController { } private func setupObservers() { - NotificationCenter.default.addObserver(forName: .TraktSignedIn, object: nil, queue: nil) { [weak self] _ in - self?.dismiss(animated: true, completion: nil) // Dismiss the SFSafariViewController - self?.refreshUI() - } + NotificationCenter.default + .publisher(for: .TraktSignedIn) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + dismiss(animated: true, completion: nil) // Dismiss the SFSafariViewController + refreshUI() + } + .store(in: &cancellables) } private func refreshUI() { @@ -104,7 +114,7 @@ final class ViewController: UIViewController { var views = [UIView]() - if TraktManager.sharedManager.isSignedIn { + if traktManager.isSignedIn { let signInButton = createButton(title: "Sign Out", action: { [weak self] _ in self?.signOut() }) views.append(signInButton) From cdb4b1f290ff55594ccd0ed82c6b88da85a1989a Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Thu, 20 Feb 2025 08:09:03 -0500 Subject: [PATCH 09/38] Add endpoints for Trakt interacted series Still working on the architecture of the async endpoints. Rather than having an `explore` resource that groups trending / popular / etc series and movies, I'm moving the endpoints as static functions of the individual resources. --- .../Models/Shows/TraktShowTranslation.swift | 4 +- Common/Models/Shows/TraktTrendingShow.swift | 12 +++- Common/Wrapper/Enums.swift | 14 +++-- Common/Wrapper/Movies.swift | 6 +- Common/Wrapper/Resources/ShowResource.swift | 63 ++++++++++++++++++- .../Resources/TraktManager+Resources.swift | 4 ++ Common/Wrapper/Route.swift | 7 +++ Common/Wrapper/SharedFunctions.swift | 6 +- Common/Wrapper/Shows.swift | 8 +-- Tests/TraktKitTests/MovieTests.swift | 6 +- Tests/TraktKitTests/ShowsTests.swift | 18 ++++++ 11 files changed, 126 insertions(+), 22 deletions(-) diff --git a/Common/Models/Shows/TraktShowTranslation.swift b/Common/Models/Shows/TraktShowTranslation.swift index 1ace1c8..e59885f 100644 --- a/Common/Models/Shows/TraktShowTranslation.swift +++ b/Common/Models/Shows/TraktShowTranslation.swift @@ -10,6 +10,8 @@ import Foundation public struct TraktShowTranslation: TraktObject { public let title: String? - public let overview: String + public let overview: String? + public let tagline: String? public let language: String + public let country: String } diff --git a/Common/Models/Shows/TraktTrendingShow.swift b/Common/Models/Shows/TraktTrendingShow.swift index f272183..59e903d 100644 --- a/Common/Models/Shows/TraktTrendingShow.swift +++ b/Common/Models/Shows/TraktTrendingShow.swift @@ -9,8 +9,16 @@ import Foundation public struct TraktTrendingShow: TraktObject { - - // Extended: Min public let watchers: Int public let show: TraktShow } + +public struct TraktFavoritedShow: TraktObject { + public let userCount: Int + public let show: TraktShow + + enum CodingKeys: String, CodingKey { + case userCount = "user_count" + case show + } +} diff --git a/Common/Wrapper/Enums.swift b/Common/Wrapper/Enums.swift index e13fb3e..af1a153 100644 --- a/Common/Wrapper/Enums.swift +++ b/Common/Wrapper/Enums.swift @@ -281,10 +281,16 @@ public enum ListItemType: String, Sendable { case people = "person" } -public enum Period: String, Sendable { - case Weekly = "weekly" - case Monthly = "monthly" - case All = "all" +public enum Period: String, Sendable, CustomStringConvertible { + case daily = "daily" + case weekly = "weekly" + case monthly = "monthly" + case yearly = "yearly" + case all = "all" + + public var description: String { + rawValue + } } public enum SectionType: String, Sendable { diff --git a/Common/Wrapper/Movies.swift b/Common/Wrapper/Movies.swift index 7097705..abf73e4 100644 --- a/Common/Wrapper/Movies.swift +++ b/Common/Wrapper/Movies.swift @@ -42,7 +42,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getPlayedMovies(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostMoviesCompletionHandler) -> URLSessionDataTask? { + public func getPlayedMovies(period: Period = .weekly, pagination: Pagination? = nil, completion: @escaping MostMoviesCompletionHandler) -> URLSessionDataTask? { return getPlayed(.Movies, period: period, pagination: pagination, completion: completion) } @@ -54,7 +54,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getWatchedMovies(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostMoviesCompletionHandler) -> URLSessionDataTask? { + public func getWatchedMovies(period: Period = .weekly, pagination: Pagination? = nil, completion: @escaping MostMoviesCompletionHandler) -> URLSessionDataTask? { return getWatched(.Movies, period: period, pagination: pagination, completion: completion) } @@ -66,7 +66,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getCollectedMovies(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostMoviesCompletionHandler) -> URLSessionDataTask? { + public func getCollectedMovies(period: Period = .weekly, pagination: Pagination? = nil, completion: @escaping MostMoviesCompletionHandler) -> URLSessionDataTask? { return getCollected(.Movies, period: period, pagination: pagination, completion: completion) } diff --git a/Common/Wrapper/Resources/ShowResource.swift b/Common/Wrapper/Resources/ShowResource.swift index 60df02d..30c69d4 100644 --- a/Common/Wrapper/Resources/ShowResource.swift +++ b/Common/Wrapper/Resources/ShowResource.swift @@ -12,6 +12,55 @@ public struct ShowResource { // MARK: - Static (Non-specific show endpoints) + /** + Returns all shows being watched right now. Shows with the most users are returned first. + */ + public static func trending() -> Route> { + Route(path: "shows/trending", method: .GET) + } + + /** + Returns the most popular shows. Popularity is calculated using the rating percentage and the number of ratings. + */ + public static func popular() -> Route> { + Route(path: "shows/trending", method: .GET) + } + + /** + Returns the most favorited shows in the specified time `period`, defaulting to `weekly`. All stats are relative to the specific time `period`. + */ + public static func favorited(period: Period? = nil) -> Route> { + Route(paths: ["shows/favorited", period], method: .GET) + } + + /** + Returns the most played (a single user can watch multiple episodes multiple times) shows in the specified time `period`, defaulting to `weekly`. All stats are relative to the specific time `period`. + */ + public static func played(period: Period? = nil) -> Route> { + Route(paths: ["shows/played", period], method: .GET) + } + + /** + Returns the most watched (unique users) shows in the specified time `period`, defaulting to `weekly`. All stats are relative to the specific time `period`. + */ + public static func watched(period: Period? = nil) -> Route> { + Route(paths: ["shows/watched", period], method: .GET) + } + + /** + Returns the most collected (unique users) shows in the specified time `period`, defaulting to `weekly`. All stats are relative to the specific time `period`. + */ + public static func collected(period: Period? = nil) -> Route> { + Route(paths: ["shows/collected", period], method: .GET) + } + + /** + Returns the most anticipated shows based on the number of lists a show appears on. + */ + public static func anticipated() -> Route> { + Route(path: "shows/anticipated", method: .GET) + } + /** Returns all shows updated since the specified date. We recommended storing the date you can be efficient using this method moving forward. @@ -21,7 +70,7 @@ public struct ShowResource { > note: .The `startDate` can only be a maximum of 30 days in the past. */ - public static func recentlyUpdated(since startDate: Date) async throws -> Route<[Update]> { + public static func recentlyUpdated(since startDate: Date) async throws -> Route> { let formattedDate = startDate.dateString(withFormat: "yyyy-MM-dd'T'HH:mm:ss") return Route(path: "shows/updates/\(formattedDate)", method: .GET) } @@ -35,13 +84,14 @@ public struct ShowResource { > note: .The `startDate` can only be a maximum of 30 days in the past. */ - public static func recentlyUpdatedIds(since startDate: Date) async throws -> Route<[Update]> { + public static func recentlyUpdatedIds(since startDate: Date) async throws -> Route> { let formattedDate = startDate.dateString(withFormat: "yyyy-MM-dd'T'HH:mm:ss") return Route(path: "shows/updates/id\(formattedDate)", method: .GET) } // MARK: - Properties + /// Trakt ID, Trakt slug, or IMDB ID public let id: CustomStringConvertible // MARK: - Lifecycle @@ -52,14 +102,23 @@ public struct ShowResource { // MARK: - Methods + /** + Returns a single shows's details. If you request extended info, the `airs` object is relative to the show's country. You can use the `day`, `time`, and `timezone` to construct your own date then convert it to whatever timezone your user is in. + */ public func summary() -> Route { Route(path: "shows/\(id)", method: .GET) } + /** + Returns all title aliases for a show. Includes country where name is different. + */ public func aliases() -> Route<[Alias]> { Route(path: "shows/\(id)/aliases", method: .GET) } + /** + Returns all content certifications for a show, including the country. + */ public func certifications() -> Route { Route(path: "shows/\(id)/certifications", method: .GET) } diff --git a/Common/Wrapper/Resources/TraktManager+Resources.swift b/Common/Wrapper/Resources/TraktManager+Resources.swift index 7c09dfe..afb85cf 100644 --- a/Common/Wrapper/Resources/TraktManager+Resources.swift +++ b/Common/Wrapper/Resources/TraktManager+Resources.swift @@ -10,6 +10,10 @@ import Foundation extension TraktManager { + public func shows() -> ShowResource.Type { + ShowResource.self + } + public func show(id: CustomStringConvertible) -> ShowResource { ShowResource(id: id) } diff --git a/Common/Wrapper/Route.swift b/Common/Wrapper/Route.swift index 04aeb84..ac9e7b0 100644 --- a/Common/Wrapper/Route.swift +++ b/Common/Wrapper/Route.swift @@ -35,6 +35,13 @@ public struct Route: Sendable { self.resultType = resultType } + public init(paths: [CustomStringConvertible?], method: Method, requiresAuthentication: Bool = false, resultType: T.Type = T.self) { + self.path = paths.compactMap { $0?.description }.joined(separator: "/") + self.method = method + self.requiresAuthentication = requiresAuthentication + self.resultType = resultType + } + // MARK: - Actions public func extend(_ extended: ExtendedType...) -> Self { diff --git a/Common/Wrapper/SharedFunctions.swift b/Common/Wrapper/SharedFunctions.swift index 41c3849..4dedb07 100644 --- a/Common/Wrapper/SharedFunctions.swift +++ b/Common/Wrapper/SharedFunctions.swift @@ -66,7 +66,7 @@ internal extension TraktManager { // MARK: - Played - func getPlayed(_ type: WatchedType, period: Period = .Weekly, pagination: Pagination?, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { + func getPlayed(_ type: WatchedType, period: Period = .weekly, pagination: Pagination?, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString()] @@ -87,7 +87,7 @@ internal extension TraktManager { // MARK: - Watched - func getWatched(_ type: WatchedType, period: Period = .Weekly, pagination: Pagination?, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { + func getWatched(_ type: WatchedType, period: Period = .weekly, pagination: Pagination?, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString()] @@ -108,7 +108,7 @@ internal extension TraktManager { // MARK: - Collected - func getCollected(_ type: WatchedType, period: Period = .Weekly, pagination: Pagination?, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { + func getCollected(_ type: WatchedType, period: Period = .weekly, pagination: Pagination?, extended: [ExtendedType] = [.Min], completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString()] diff --git a/Common/Wrapper/Shows.swift b/Common/Wrapper/Shows.swift index ba290ee..468310c 100644 --- a/Common/Wrapper/Shows.swift +++ b/Common/Wrapper/Shows.swift @@ -42,7 +42,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getPlayedShows(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostShowsCompletionHandler) -> URLSessionDataTask? { + public func getPlayedShows(period: Period = .weekly, pagination: Pagination? = nil, completion: @escaping MostShowsCompletionHandler) -> URLSessionDataTask? { return getPlayed(.Shows, period: period, pagination: pagination, completion: completion) } @@ -54,7 +54,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getWatchedShows(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostShowsCompletionHandler) -> URLSessionDataTask? { + public func getWatchedShows(period: Period = .weekly, pagination: Pagination? = nil, completion: @escaping MostShowsCompletionHandler) -> URLSessionDataTask? { return getWatched(.Shows, period: period, pagination: pagination, completion: completion) } @@ -66,7 +66,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getCollectedShows(period: Period = .Weekly, pagination: Pagination? = nil, completion: @escaping MostShowsCompletionHandler) -> URLSessionDataTask? { + public func getCollectedShows(period: Period = .weekly, pagination: Pagination? = nil, completion: @escaping MostShowsCompletionHandler) -> URLSessionDataTask? { return getCollected(.Shows, pagination: pagination, completion: completion) } @@ -78,7 +78,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getAnticipatedShows(period: Period = .Weekly, pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], completion: @escaping AnticipatedShowCompletionHandler) -> URLSessionDataTask? { + public func getAnticipatedShows(period: Period = .weekly, pagination: Pagination? = nil, extended: [ExtendedType] = [.Min], completion: @escaping AnticipatedShowCompletionHandler) -> URLSessionDataTask? { return getAnticipated(.Shows, pagination: pagination, extended: extended, completion: completion) } diff --git a/Tests/TraktKitTests/MovieTests.swift b/Tests/TraktKitTests/MovieTests.swift index a8292b6..3384b34 100644 --- a/Tests/TraktKitTests/MovieTests.swift +++ b/Tests/TraktKitTests/MovieTests.swift @@ -61,7 +61,7 @@ final class MovieTests: TraktTestCase { try mock(.GET, "https://api.trakt.tv/movies/played/all?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_most_played_movies"))) let expectation = XCTestExpectation(description: "Get Most Played Movies") - traktManager.getPlayedMovies(period: .All, pagination: Pagination(page: 1, limit: 10)) { result in + traktManager.getPlayedMovies(period: .all, pagination: Pagination(page: 1, limit: 10)) { result in if case .success(let playedMovies, _, _) = result { XCTAssertEqual(playedMovies.count, 10) expectation.fulfill() @@ -83,7 +83,7 @@ final class MovieTests: TraktTestCase { try mock(.GET, "https://api.trakt.tv/movies/watched/all?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_most_watched_movies"))) let expectation = XCTestExpectation(description: "Get Most Watched Movies") - traktManager.getWatchedMovies(period: .All, pagination: Pagination(page: 1, limit: 10)) { result in + traktManager.getWatchedMovies(period: .all, pagination: Pagination(page: 1, limit: 10)) { result in if case .success(let watchedMovies, _, _) = result { XCTAssertEqual(watchedMovies.count, 10) expectation.fulfill() @@ -105,7 +105,7 @@ final class MovieTests: TraktTestCase { try mock(.GET, "https://api.trakt.tv/movies/collected/all?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_most_collected_movies"))) let expectation = XCTestExpectation(description: "Get Most Collected Movies") - traktManager.getCollectedMovies(period: .All, pagination: Pagination(page: 1, limit: 10)) { result in + traktManager.getCollectedMovies(period: .all, pagination: Pagination(page: 1, limit: 10)) { result in if case .success(let collectedMovies, _, _) = result { XCTAssertEqual(collectedMovies.count, 10) expectation.fulfill() diff --git a/Tests/TraktKitTests/ShowsTests.swift b/Tests/TraktKitTests/ShowsTests.swift index c8662af..3f723f1 100644 --- a/Tests/TraktKitTests/ShowsTests.swift +++ b/Tests/TraktKitTests/ShowsTests.swift @@ -151,6 +151,24 @@ final class ShowsTests: TraktTestCase { } } + func test_get_most_watched_shows_async() async throws { + try? mock(.GET, "https://api.trakt.tv/shows/watched/weekly?page=1&limit=10", result: .success(jsonData(named: "test_get_most_watched_shows")), headers: [.page(1), .pageCount(8)]) + + let pagedObject = try await traktManager.shows() + .watched(period: .weekly) + .page(1) + .limit(10) + .perform() + let (watchedShows, page, total) = (pagedObject.object, pagedObject.currentPage, pagedObject.pageCount) + + XCTAssertEqual(watchedShows.count, 10) + XCTAssertEqual(page, 1) + XCTAssertEqual(total, 8) + let firstWatchedShow = try XCTUnwrap(watchedShows.first) + XCTAssertEqual(firstWatchedShow.playCount, 8784154) + XCTAssertEqual(firstWatchedShow.watcherCount, 203742) + } + // MARK: - Collected func test_get_most_collected_shows() { From 5c79c15b05e2c68ebccd4c68210e26bea09dd228 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Thu, 20 Feb 2025 08:09:20 -0500 Subject: [PATCH 10/38] Update TraktComment model --- Common/Models/Comments/TraktComment.swift | 26 +- Common/Wrapper/Resources/ShowResource.swift | 23 + Tests/TraktKitTests/CommentTests.swift | 1 + .../Models/Comments/test_get_a_comment.json | 41 +- .../test_get_recently_created_comments.json | 405 +++++++++--------- .../test_get_recently_updated_comments.json | 405 +++++++++--------- .../test_get_replies_for_comment.json | 48 ++- .../Comments/test_get_trending_comments.json | 405 +++++++++--------- .../Comments/test_post_reply_for_comment.json | 42 +- .../Comments/test_update_a_comment.json | 43 +- .../Episodes/test_get_episode_comments.json | 48 ++- .../Movies/test_get_movie_comments.json | 48 ++- .../Season/test_get_season_comments.json | 48 ++- .../Shows/test_get_most_watched_shows.json | 322 +++++++------- .../Models/Shows/test_get_show_comments.json | 402 ++++++++++++++++- .../Shows/test_get_show_translations.json | 295 ++++++++++++- .../Users/test_get_all_list_comments.json | 48 ++- .../Models/Users/test_get_comments_likes.json | 55 +-- .../Models/Users/test_get_user_comments.json | 405 +++++++++--------- Tests/TraktKitTests/ShowsTests.swift | 25 +- 20 files changed, 1967 insertions(+), 1168 deletions(-) diff --git a/Common/Models/Comments/TraktComment.swift b/Common/Models/Comments/TraktComment.swift index 2075378..748e361 100644 --- a/Common/Models/Comments/TraktComment.swift +++ b/Common/Models/Comments/TraktComment.swift @@ -10,28 +10,44 @@ import Foundation public struct Comment: TraktObject { public let id: Int - public let parentId: Int - public let createdAt: Date public var comment: String public let spoiler: Bool public let review: Bool + public let parentId: Int + public let createdAt: Date + public let updatedAt: Date? public let replies: Int public let likes: Int public let userRating: Int? + public let userStats: UserStats public let user: User - + enum CodingKeys: String, CodingKey { case id - case parentId = "parent_id" - case createdAt = "created_at" case comment case spoiler case review + case parentId = "parent_id" + case createdAt = "created_at" + case updatedAt = "updated_at" case replies case likes case userRating = "user_rating" + case userStats = "user_stats" case user } + + public struct UserStats: TraktObject { + public let rating: Int? + public let playCount: Int + public let completedCount: Int + + enum CodingKeys: String, CodingKey { + case rating + case playCount = "play_count" + case completedCount = "completed_count" + } + } } public extension Sequence where Iterator.Element == Comment { diff --git a/Common/Wrapper/Resources/ShowResource.swift b/Common/Wrapper/Resources/ShowResource.swift index 30c69d4..691d2d3 100644 --- a/Common/Wrapper/Resources/ShowResource.swift +++ b/Common/Wrapper/Resources/ShowResource.swift @@ -123,6 +123,29 @@ public struct ShowResource { Route(path: "shows/\(id)/certifications", method: .GET) } + /** + Returns all translations for a show, including language and translated values for title and overview. + + - parameter language: 2 character language code Example: `es` + */ + public func translations(language: String? = nil) -> Route<[TraktShowTranslation]> { + Route(paths: ["shows/\(id)/translations", language], method: .GET) + } + + /** + Returns all top level comments for a show. By default, the `newest` comments are returned first. Other sorting options include `oldest`, most `likes`, most `replies`, `highest` rated, `lowest` rated, most `plays`, and highest `watched` percentage. + + 🔓 OAuth Optional 📄 Pagination 😁 Emojis + + > note: If you send OAuth, comments from blocked users will be automatically filtered out. + + - parameter sort: how to sort Example: `newest`. + - parameter authenticate: comments from blocked users will be automatically filtered out if `true`. + */ + public func comments(sort: String? = nil, authenticate: Bool = false) -> Route> { + Route(paths: ["shows/\(id)/comments", sort], method: .GET, requiresAuthentication: authenticate) + } + /** Returns all seasons for a show including the number of episodes in each season. diff --git a/Tests/TraktKitTests/CommentTests.swift b/Tests/TraktKitTests/CommentTests.swift index 8908d83..61360fe 100644 --- a/Tests/TraktKitTests/CommentTests.swift +++ b/Tests/TraktKitTests/CommentTests.swift @@ -43,6 +43,7 @@ final class CommentTests: TraktTestCase { if case .success(let comment) = result { XCTAssertEqual(comment.likes, 0) XCTAssertEqual(comment.userRating, 8) + XCTAssertEqual(comment.userStats.rating, 8) XCTAssertEqual(comment.spoiler, false) XCTAssertEqual(comment.review, false) expectation.fulfill() diff --git a/Tests/TraktKitTests/Models/Comments/test_get_a_comment.json b/Tests/TraktKitTests/Models/Comments/test_get_a_comment.json index 96b3fb7..48172d7 100644 --- a/Tests/TraktKitTests/Models/Comments/test_get_a_comment.json +++ b/Tests/TraktKitTests/Models/Comments/test_get_a_comment.json @@ -1,21 +1,26 @@ { - "review" : false, - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin Nemeth", - "ids" : { - "slug" : "justin" + "id": 1, + "parent_id": 0, + "created_at": "2010-11-03T06:30:13.000Z", + "comment": "Agreed, this show is awesome. AMC in general has awesome shows.", + "spoiler": false, + "review": false, + "replies": 1, + "likes": 0, + "user_rating": 8, + "user_stats": { + "rating": 8, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin Nemeth", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } } - }, - "replies" : 1, - "id" : 1, - "created_at" : "2010-11-03T06:30:13.000Z", - "parent_id" : 0, - "spoiler" : false, - "comment" : "Agreed, this show is awesome. AMC in general has awesome shows.", - "likes" : 0, - "user_rating" : 8 } diff --git a/Tests/TraktKitTests/Models/Comments/test_get_recently_created_comments.json b/Tests/TraktKitTests/Models/Comments/test_get_recently_created_comments.json index bb71530..464497c 100644 --- a/Tests/TraktKitTests/Models/Comments/test_get_recently_created_comments.json +++ b/Tests/TraktKitTests/Models/Comments/test_get_recently_created_comments.json @@ -1,204 +1,225 @@ [ - { - "type" : "movie", - "movie" : { - "title" : "Batman Begins", - "year" : 2005, - "ids" : { - "tmdb" : 272, - "slug" : "batman-begins-2005", - "trakt" : 1, - "imdb" : "tt0372784" - } - }, - "comment" : { - "review" : false, - "user_rating" : 10, - "replies" : 0, - "id" : 267, - "created_at" : "2015-04-25T00:14:57.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "movie", + "movie": { + "title": "Batman Begins", + "year": 2005, + "ids": { + "trakt": 1, + "slug": "batman-begins-2005", + "imdb": "tt0372784", + "tmdb": 272 + } + }, + "comment": { + "id": 267, + "comment": "Great kickoff to a new Batman trilogy!", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2015-04-25T00:14:57.000Z", + "updated_at": "2015-04-25T00:14:57.000Z", + "replies": 0, + "likes": 0, + "user_stats": { + "rating": 10, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "Great kickoff to a new Batman trilogy!", - "updated_at" : "2015-04-25T00:14:57.000Z", - "likes" : 0 - } - }, - { - "type" : "show", - "show" : { - "title" : "Breaking Bad", - "year" : 2008, - "ids" : { - "tmdb" : 1396, - "slug" : "breaking-bad", - "tvdb" : 81189, - "trakt" : 1, - "imdb" : "tt0903747" - } }, - "comment" : { - "review" : false, - "user_rating" : 10, - "replies" : 0, - "id" : 199, - "created_at" : "2015-02-18T06:02:30.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "show", + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "slug": "breaking-bad", + "tvdb": 81189, + "imdb": "tt0903747", + "tmdb": 1396 + } + }, + "comment": { + "id": 199, + "comment": "Skyler, I AM THE DANGER.", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2015-02-18T06:02:30.000Z", + "updated_at": "2015-02-18T06:02:30.000Z", + "replies": 0, + "likes": 0, + "user_stats": { + "rating": 10, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "Skyler, I AM THE DANGER.", - "updated_at" : "2015-02-18T06:02:30.000Z", - "likes" : 0 - } - }, - { - "season" : { - "number" : 1, - "ids" : { - "trakt" : 3958, - "tvdb" : 274431, - "tmdb" : 60394 - } - }, - "show" : { - "title" : "Gotham", - "year" : 2014, - "ids" : { - "tmdb" : 60708, - "slug" : "gotham", - "tvdb" : 274431, - "trakt" : 869, - "imdb" : "tt3749900" - } }, - "type" : "season", - "comment" : { - "review" : false, - "user_rating" : 8, - "replies" : 0, - "id" : 220, - "created_at" : "2015-04-21T06:53:25.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "season", + "season": { + "number": 1, + "ids": { + "trakt": 3958, + "tvdb": 274431, + "tmdb": 60394 + } + }, + "show": { + "title": "Gotham", + "year": 2014, + "ids": { + "trakt": 869, + "slug": "gotham", + "tvdb": 274431, + "imdb": "tt3749900", + "tmdb": 60708 + } + }, + "comment": { + "id": 220, + "comment": "Kicking off season 1 for a new Batman show.", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2015-04-21T06:53:25.000Z", + "updated_at": "2015-04-21T06:53:25.000Z", + "replies": 0, + "likes": 0, + "user_stats": { + "rating": 8, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "Kicking off season 1 for a new Batman show.", - "updated_at" : "2015-04-21T06:53:25.000Z", - "likes" : 0 - } - }, - { - "show" : { - "title" : "Gotham", - "year" : 2014, - "ids" : { - "tmdb" : 60708, - "slug" : "gotham", - "tvdb" : 274431, - "trakt" : 869, - "imdb" : "tt3749900" - } }, - "type" : "episode", - "comment" : { - "review" : false, - "user_rating" : 7, - "replies" : 1, - "id" : 229, - "created_at" : "2015-04-21T15:42:31.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "episode", + "episode": { + "season": 1, + "number": 1, + "title": "Jim Gordon", + "ids": { + "trakt": 63958, + "tvdb": 4768720, + "imdb": "tt3216414", + "tmdb": 975968 + } + }, + "show": { + "title": "Gotham", + "year": 2014, + "ids": { + "trakt": 869, + "slug": "gotham", + "tvdb": 274431, + "imdb": "tt3749900", + "tmdb": 60708 + } + }, + "comment": { + "id": 229, + "comment": "Is this the OC?", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2015-04-21T15:42:31.000Z", + "updated_at": "2015-04-21T15:42:31.000Z", + "replies": 1, + "likes": 0, + "user_stats": { + "rating": 7, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "Is this the OC?", - "updated_at" : "2015-04-21T15:42:31.000Z", - "likes" : 0 }, - "episode" : { - "number" : 1, - "season" : 1, - "title" : "Jim Gordon", - "ids" : { - "tmdb" : 975968, - "tvdb" : 4768720, - "trakt" : 63958, - "imdb" : "tt3216414" - } - } - }, - { - "type" : "list", - "comment" : { - "review" : false, - "user_rating" : null, - "replies" : 0, - "id" : 268, - "created_at" : "2014-12-08T17:34:51.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "list", + "list": { + "name": "Star Wars", + "description": "The complete Star Wars saga!", + "privacy": "public", + "share_link": "https://trakt.tv/lists/51", + "display_numbers": false, + "allow_comments": true, + "updated_at": "2015-04-22T22:01:39.000Z", + "item_count": 8, + "comment_count": 0, + "likes": 0, + "ids": { + "trakt": 51, + "slug": "star-wars" + } + }, + "comment": { + "id": 268, + "comment": "May the 4th be with you!", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2014-12-08T17:34:51.000Z", + "updated_at": "2014-12-08T17:34:51.000Z", + "replies": 0, + "likes": 0, + "user_stats": { + "rating": null, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "May the 4th be with you!", - "updated_at" : "2014-12-08T17:34:51.000Z", - "likes" : 0 - }, - "list" : { - "ids" : { - "trakt" : 51, - "slug" : "star-wars" - }, - "display_numbers" : false, - "privacy" : "public", - "allow_comments" : true, - "comment_count" : 0, - "item_count" : 8, - "description" : "The complete Star Wars saga!", - "updated_at" : "2015-04-22T22:01:39.000Z", - "name" : "Star Wars", - "likes" : 0 } - } ] diff --git a/Tests/TraktKitTests/Models/Comments/test_get_recently_updated_comments.json b/Tests/TraktKitTests/Models/Comments/test_get_recently_updated_comments.json index bb71530..464497c 100644 --- a/Tests/TraktKitTests/Models/Comments/test_get_recently_updated_comments.json +++ b/Tests/TraktKitTests/Models/Comments/test_get_recently_updated_comments.json @@ -1,204 +1,225 @@ [ - { - "type" : "movie", - "movie" : { - "title" : "Batman Begins", - "year" : 2005, - "ids" : { - "tmdb" : 272, - "slug" : "batman-begins-2005", - "trakt" : 1, - "imdb" : "tt0372784" - } - }, - "comment" : { - "review" : false, - "user_rating" : 10, - "replies" : 0, - "id" : 267, - "created_at" : "2015-04-25T00:14:57.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "movie", + "movie": { + "title": "Batman Begins", + "year": 2005, + "ids": { + "trakt": 1, + "slug": "batman-begins-2005", + "imdb": "tt0372784", + "tmdb": 272 + } + }, + "comment": { + "id": 267, + "comment": "Great kickoff to a new Batman trilogy!", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2015-04-25T00:14:57.000Z", + "updated_at": "2015-04-25T00:14:57.000Z", + "replies": 0, + "likes": 0, + "user_stats": { + "rating": 10, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "Great kickoff to a new Batman trilogy!", - "updated_at" : "2015-04-25T00:14:57.000Z", - "likes" : 0 - } - }, - { - "type" : "show", - "show" : { - "title" : "Breaking Bad", - "year" : 2008, - "ids" : { - "tmdb" : 1396, - "slug" : "breaking-bad", - "tvdb" : 81189, - "trakt" : 1, - "imdb" : "tt0903747" - } }, - "comment" : { - "review" : false, - "user_rating" : 10, - "replies" : 0, - "id" : 199, - "created_at" : "2015-02-18T06:02:30.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "show", + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "slug": "breaking-bad", + "tvdb": 81189, + "imdb": "tt0903747", + "tmdb": 1396 + } + }, + "comment": { + "id": 199, + "comment": "Skyler, I AM THE DANGER.", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2015-02-18T06:02:30.000Z", + "updated_at": "2015-02-18T06:02:30.000Z", + "replies": 0, + "likes": 0, + "user_stats": { + "rating": 10, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "Skyler, I AM THE DANGER.", - "updated_at" : "2015-02-18T06:02:30.000Z", - "likes" : 0 - } - }, - { - "season" : { - "number" : 1, - "ids" : { - "trakt" : 3958, - "tvdb" : 274431, - "tmdb" : 60394 - } - }, - "show" : { - "title" : "Gotham", - "year" : 2014, - "ids" : { - "tmdb" : 60708, - "slug" : "gotham", - "tvdb" : 274431, - "trakt" : 869, - "imdb" : "tt3749900" - } }, - "type" : "season", - "comment" : { - "review" : false, - "user_rating" : 8, - "replies" : 0, - "id" : 220, - "created_at" : "2015-04-21T06:53:25.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "season", + "season": { + "number": 1, + "ids": { + "trakt": 3958, + "tvdb": 274431, + "tmdb": 60394 + } + }, + "show": { + "title": "Gotham", + "year": 2014, + "ids": { + "trakt": 869, + "slug": "gotham", + "tvdb": 274431, + "imdb": "tt3749900", + "tmdb": 60708 + } + }, + "comment": { + "id": 220, + "comment": "Kicking off season 1 for a new Batman show.", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2015-04-21T06:53:25.000Z", + "updated_at": "2015-04-21T06:53:25.000Z", + "replies": 0, + "likes": 0, + "user_stats": { + "rating": 8, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "Kicking off season 1 for a new Batman show.", - "updated_at" : "2015-04-21T06:53:25.000Z", - "likes" : 0 - } - }, - { - "show" : { - "title" : "Gotham", - "year" : 2014, - "ids" : { - "tmdb" : 60708, - "slug" : "gotham", - "tvdb" : 274431, - "trakt" : 869, - "imdb" : "tt3749900" - } }, - "type" : "episode", - "comment" : { - "review" : false, - "user_rating" : 7, - "replies" : 1, - "id" : 229, - "created_at" : "2015-04-21T15:42:31.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "episode", + "episode": { + "season": 1, + "number": 1, + "title": "Jim Gordon", + "ids": { + "trakt": 63958, + "tvdb": 4768720, + "imdb": "tt3216414", + "tmdb": 975968 + } + }, + "show": { + "title": "Gotham", + "year": 2014, + "ids": { + "trakt": 869, + "slug": "gotham", + "tvdb": 274431, + "imdb": "tt3749900", + "tmdb": 60708 + } + }, + "comment": { + "id": 229, + "comment": "Is this the OC?", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2015-04-21T15:42:31.000Z", + "updated_at": "2015-04-21T15:42:31.000Z", + "replies": 1, + "likes": 0, + "user_stats": { + "rating": 7, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "Is this the OC?", - "updated_at" : "2015-04-21T15:42:31.000Z", - "likes" : 0 }, - "episode" : { - "number" : 1, - "season" : 1, - "title" : "Jim Gordon", - "ids" : { - "tmdb" : 975968, - "tvdb" : 4768720, - "trakt" : 63958, - "imdb" : "tt3216414" - } - } - }, - { - "type" : "list", - "comment" : { - "review" : false, - "user_rating" : null, - "replies" : 0, - "id" : 268, - "created_at" : "2014-12-08T17:34:51.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "list", + "list": { + "name": "Star Wars", + "description": "The complete Star Wars saga!", + "privacy": "public", + "share_link": "https://trakt.tv/lists/51", + "display_numbers": false, + "allow_comments": true, + "updated_at": "2015-04-22T22:01:39.000Z", + "item_count": 8, + "comment_count": 0, + "likes": 0, + "ids": { + "trakt": 51, + "slug": "star-wars" + } + }, + "comment": { + "id": 268, + "comment": "May the 4th be with you!", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2014-12-08T17:34:51.000Z", + "updated_at": "2014-12-08T17:34:51.000Z", + "replies": 0, + "likes": 0, + "user_stats": { + "rating": null, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "May the 4th be with you!", - "updated_at" : "2014-12-08T17:34:51.000Z", - "likes" : 0 - }, - "list" : { - "ids" : { - "trakt" : 51, - "slug" : "star-wars" - }, - "display_numbers" : false, - "privacy" : "public", - "allow_comments" : true, - "comment_count" : 0, - "item_count" : 8, - "description" : "The complete Star Wars saga!", - "updated_at" : "2015-04-22T22:01:39.000Z", - "name" : "Star Wars", - "likes" : 0 } - } ] diff --git a/Tests/TraktKitTests/Models/Comments/test_get_replies_for_comment.json b/Tests/TraktKitTests/Models/Comments/test_get_replies_for_comment.json index a0b2cf9..206ac40 100644 --- a/Tests/TraktKitTests/Models/Comments/test_get_replies_for_comment.json +++ b/Tests/TraktKitTests/Models/Comments/test_get_replies_for_comment.json @@ -1,24 +1,28 @@ [ - { - "review" : false, - "user_rating" : 8, - "replies" : 0, - "id" : 19, - "created_at" : "2014-07-27T23:06:59.000Z", - "user" : { - "username" : "sean", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Sean Rudford", - "ids" : { - "slug" : "sean" - } - }, - "parent_id" : 1, - "updated_at" : "2014-07-27T23:06:59.000Z", - "comment" : "Season 2 has really picked up the action!", - "spoiler" : true, - "likes" : 0 - } + { + "id": 19, + "parent_id": 1, + "created_at": "2014-07-27T23:06:59.000Z", + "updated_at": "2014-07-27T23:06:59.000Z", + "comment": "Season 2 has really picked up the action!", + "spoiler": true, + "review": false, + "replies": 0, + "likes": 0, + "user_stats": { + "rating": 8, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "sean", + "private": false, + "name": "Sean Rudford", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "sean" + } + } + } ] diff --git a/Tests/TraktKitTests/Models/Comments/test_get_trending_comments.json b/Tests/TraktKitTests/Models/Comments/test_get_trending_comments.json index bb71530..464497c 100644 --- a/Tests/TraktKitTests/Models/Comments/test_get_trending_comments.json +++ b/Tests/TraktKitTests/Models/Comments/test_get_trending_comments.json @@ -1,204 +1,225 @@ [ - { - "type" : "movie", - "movie" : { - "title" : "Batman Begins", - "year" : 2005, - "ids" : { - "tmdb" : 272, - "slug" : "batman-begins-2005", - "trakt" : 1, - "imdb" : "tt0372784" - } - }, - "comment" : { - "review" : false, - "user_rating" : 10, - "replies" : 0, - "id" : 267, - "created_at" : "2015-04-25T00:14:57.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "movie", + "movie": { + "title": "Batman Begins", + "year": 2005, + "ids": { + "trakt": 1, + "slug": "batman-begins-2005", + "imdb": "tt0372784", + "tmdb": 272 + } + }, + "comment": { + "id": 267, + "comment": "Great kickoff to a new Batman trilogy!", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2015-04-25T00:14:57.000Z", + "updated_at": "2015-04-25T00:14:57.000Z", + "replies": 0, + "likes": 0, + "user_stats": { + "rating": 10, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "Great kickoff to a new Batman trilogy!", - "updated_at" : "2015-04-25T00:14:57.000Z", - "likes" : 0 - } - }, - { - "type" : "show", - "show" : { - "title" : "Breaking Bad", - "year" : 2008, - "ids" : { - "tmdb" : 1396, - "slug" : "breaking-bad", - "tvdb" : 81189, - "trakt" : 1, - "imdb" : "tt0903747" - } }, - "comment" : { - "review" : false, - "user_rating" : 10, - "replies" : 0, - "id" : 199, - "created_at" : "2015-02-18T06:02:30.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "show", + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "slug": "breaking-bad", + "tvdb": 81189, + "imdb": "tt0903747", + "tmdb": 1396 + } + }, + "comment": { + "id": 199, + "comment": "Skyler, I AM THE DANGER.", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2015-02-18T06:02:30.000Z", + "updated_at": "2015-02-18T06:02:30.000Z", + "replies": 0, + "likes": 0, + "user_stats": { + "rating": 10, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "Skyler, I AM THE DANGER.", - "updated_at" : "2015-02-18T06:02:30.000Z", - "likes" : 0 - } - }, - { - "season" : { - "number" : 1, - "ids" : { - "trakt" : 3958, - "tvdb" : 274431, - "tmdb" : 60394 - } - }, - "show" : { - "title" : "Gotham", - "year" : 2014, - "ids" : { - "tmdb" : 60708, - "slug" : "gotham", - "tvdb" : 274431, - "trakt" : 869, - "imdb" : "tt3749900" - } }, - "type" : "season", - "comment" : { - "review" : false, - "user_rating" : 8, - "replies" : 0, - "id" : 220, - "created_at" : "2015-04-21T06:53:25.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "season", + "season": { + "number": 1, + "ids": { + "trakt": 3958, + "tvdb": 274431, + "tmdb": 60394 + } + }, + "show": { + "title": "Gotham", + "year": 2014, + "ids": { + "trakt": 869, + "slug": "gotham", + "tvdb": 274431, + "imdb": "tt3749900", + "tmdb": 60708 + } + }, + "comment": { + "id": 220, + "comment": "Kicking off season 1 for a new Batman show.", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2015-04-21T06:53:25.000Z", + "updated_at": "2015-04-21T06:53:25.000Z", + "replies": 0, + "likes": 0, + "user_stats": { + "rating": 8, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "Kicking off season 1 for a new Batman show.", - "updated_at" : "2015-04-21T06:53:25.000Z", - "likes" : 0 - } - }, - { - "show" : { - "title" : "Gotham", - "year" : 2014, - "ids" : { - "tmdb" : 60708, - "slug" : "gotham", - "tvdb" : 274431, - "trakt" : 869, - "imdb" : "tt3749900" - } }, - "type" : "episode", - "comment" : { - "review" : false, - "user_rating" : 7, - "replies" : 1, - "id" : 229, - "created_at" : "2015-04-21T15:42:31.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "episode", + "episode": { + "season": 1, + "number": 1, + "title": "Jim Gordon", + "ids": { + "trakt": 63958, + "tvdb": 4768720, + "imdb": "tt3216414", + "tmdb": 975968 + } + }, + "show": { + "title": "Gotham", + "year": 2014, + "ids": { + "trakt": 869, + "slug": "gotham", + "tvdb": 274431, + "imdb": "tt3749900", + "tmdb": 60708 + } + }, + "comment": { + "id": 229, + "comment": "Is this the OC?", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2015-04-21T15:42:31.000Z", + "updated_at": "2015-04-21T15:42:31.000Z", + "replies": 1, + "likes": 0, + "user_stats": { + "rating": 7, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "Is this the OC?", - "updated_at" : "2015-04-21T15:42:31.000Z", - "likes" : 0 }, - "episode" : { - "number" : 1, - "season" : 1, - "title" : "Jim Gordon", - "ids" : { - "tmdb" : 975968, - "tvdb" : 4768720, - "trakt" : 63958, - "imdb" : "tt3216414" - } - } - }, - { - "type" : "list", - "comment" : { - "review" : false, - "user_rating" : null, - "replies" : 0, - "id" : 268, - "created_at" : "2014-12-08T17:34:51.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "list", + "list": { + "name": "Star Wars", + "description": "The complete Star Wars saga!", + "privacy": "public", + "share_link": "https://trakt.tv/lists/51", + "display_numbers": false, + "allow_comments": true, + "updated_at": "2015-04-22T22:01:39.000Z", + "item_count": 8, + "comment_count": 0, + "likes": 0, + "ids": { + "trakt": 51, + "slug": "star-wars" + } + }, + "comment": { + "id": 268, + "comment": "May the 4th be with you!", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2014-12-08T17:34:51.000Z", + "updated_at": "2014-12-08T17:34:51.000Z", + "replies": 0, + "likes": 0, + "user_stats": { + "rating": null, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "May the 4th be with you!", - "updated_at" : "2014-12-08T17:34:51.000Z", - "likes" : 0 - }, - "list" : { - "ids" : { - "trakt" : 51, - "slug" : "star-wars" - }, - "display_numbers" : false, - "privacy" : "public", - "allow_comments" : true, - "comment_count" : 0, - "item_count" : 8, - "description" : "The complete Star Wars saga!", - "updated_at" : "2015-04-22T22:01:39.000Z", - "name" : "Star Wars", - "likes" : 0 } - } ] diff --git a/Tests/TraktKitTests/Models/Comments/test_post_reply_for_comment.json b/Tests/TraktKitTests/Models/Comments/test_post_reply_for_comment.json index b6c6c54..8b749c6 100644 --- a/Tests/TraktKitTests/Models/Comments/test_post_reply_for_comment.json +++ b/Tests/TraktKitTests/Models/Comments/test_post_reply_for_comment.json @@ -1,22 +1,26 @@ { - "review" : false, - "user_rating" : null, - "replies" : 0, - "id" : 2, - "created_at" : "2014-09-01T06:30:13.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin Nemeth", - "ids" : { - "slug" : "justin" + "id": 2, + "parent_id": 1, + "created_at": "2014-09-01T06:30:13.000Z", + "updated_at": "2014-09-01T06:30:13.000Z", + "comment": "Couldn't agree more with your review!", + "spoiler": false, + "review": false, + "replies": 0, + "likes": 0, + "user_stats": { + "rating": null, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin Nemeth", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } } - }, - "parent_id" : 1, - "updated_at" : "2014-09-01T06:30:13.000Z", - "comment" : "Couldn't agree more with your review!", - "spoiler" : false, - "likes" : 0 } diff --git a/Tests/TraktKitTests/Models/Comments/test_update_a_comment.json b/Tests/TraktKitTests/Models/Comments/test_update_a_comment.json index 37f9ae3..74b3c1c 100644 --- a/Tests/TraktKitTests/Models/Comments/test_update_a_comment.json +++ b/Tests/TraktKitTests/Models/Comments/test_update_a_comment.json @@ -1,23 +1,26 @@ { - "review" : false, - "user_rating" : null, - "replies" : 1, - "id" : 1, - "created_at" : "2010-11-03T06:30:13.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin Nemeth", - "ids" : { - "slug" : "justin" + "id": 1, + "parent_id": 0, + "created_at": "2010-11-03T06:30:13.000Z", + "updated_at": "2010-11-13T06:30:13.000Z", + "comment": "Agreed, this show is awesome. AMC in general has awesome shows and I can't wait to see what they come up with next.", + "spoiler": false, + "review": false, + "replies": 1, + "likes": 0, + "user_stats": { + "rating": null, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin Nemeth", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } } - }, - "parent_id" : 0, - "updated_at" : "2010-11-13T06:30:13.000Z", - "comment" : "Agreed, this show is awesome. AMC in general has awesome shows and I can't wait to see what they come up with next.", - "spoiler" : false, - "likes" : 0 } - diff --git a/Tests/TraktKitTests/Models/Episodes/test_get_episode_comments.json b/Tests/TraktKitTests/Models/Episodes/test_get_episode_comments.json index 872b702..745f102 100644 --- a/Tests/TraktKitTests/Models/Episodes/test_get_episode_comments.json +++ b/Tests/TraktKitTests/Models/Episodes/test_get_episode_comments.json @@ -1,24 +1,28 @@ [ - { - "review" : false, - "user_rating" : 8, - "replies" : 1, - "id" : 8, - "created_at" : "2011-03-25T22:35:17.000Z", - "user" : { - "username" : "sean", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Sean Rudford", - "ids" : { - "slug" : "sean" - } - }, - "parent_id" : 0, - "updated_at" : "2011-03-25T22:35:17.000Z", - "comment" : "Great episode!", - "spoiler" : false, - "likes" : 0 - } + { + "id": 8, + "parent_id": 0, + "created_at": "2011-03-25T22:35:17.000Z", + "updated_at": "2011-03-25T22:35:17.000Z", + "comment": "Great episode!", + "spoiler": false, + "review": false, + "replies": 1, + "likes": 0, + "user_stats": { + "rating": 8, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "sean", + "private": false, + "name": "Sean Rudford", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "sean" + } + } + } ] diff --git a/Tests/TraktKitTests/Models/Movies/test_get_movie_comments.json b/Tests/TraktKitTests/Models/Movies/test_get_movie_comments.json index cef9fff..549fcad 100644 --- a/Tests/TraktKitTests/Models/Movies/test_get_movie_comments.json +++ b/Tests/TraktKitTests/Models/Movies/test_get_movie_comments.json @@ -1,24 +1,28 @@ [ - { - "review" : false, - "user_rating" : 8, - "replies" : 1, - "id" : 8, - "created_at" : "2011-03-25T22:35:17.000Z", - "user" : { - "username" : "sean", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Sean Rudford", - "ids" : { - "slug" : "sean" - } - }, - "parent_id" : 0, - "updated_at" : "2011-03-25T22:35:17.000Z", - "comment" : "Great movie!", - "spoiler" : false, - "likes" : 0 - } + { + "id": 8, + "parent_id": 0, + "created_at": "2011-03-25T22:35:17.000Z", + "updated_at": "2011-03-25T22:35:17.000Z", + "comment": "Great movie!", + "spoiler": false, + "review": false, + "replies": 1, + "likes": 0, + "user_stats": { + "rating": 8, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "sean", + "private": false, + "name": "Sean Rudford", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "sean" + } + } + } ] diff --git a/Tests/TraktKitTests/Models/Season/test_get_season_comments.json b/Tests/TraktKitTests/Models/Season/test_get_season_comments.json index e64b1e4..a919087 100644 --- a/Tests/TraktKitTests/Models/Season/test_get_season_comments.json +++ b/Tests/TraktKitTests/Models/Season/test_get_season_comments.json @@ -1,24 +1,28 @@ [ - { - "review" : false, - "user_rating" : 8, - "replies" : 1, - "id" : 8, - "created_at" : "2011-03-25T22:35:17.000Z", - "user" : { - "username" : "sean", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Sean Rudford", - "ids" : { - "slug" : "sean" - } - }, - "parent_id" : 0, - "updated_at" : "2011-03-25T22:35:17.000Z", - "comment" : "Great season!", - "spoiler" : false, - "likes" : 0 - } + { + "id": 8, + "parent_id": 0, + "created_at": "2011-03-25T22:35:17.000Z", + "updated_at": "2011-03-25T22:35:17.000Z", + "comment": "Great season!", + "spoiler": false, + "review": false, + "replies": 1, + "likes": 0, + "user_stats": { + "rating": 8, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "sean", + "private": false, + "name": "Sean Rudford", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "sean" + } + } + } ] diff --git a/Tests/TraktKitTests/Models/Shows/test_get_most_watched_shows.json b/Tests/TraktKitTests/Models/Shows/test_get_most_watched_shows.json index 690f38e..8a3e413 100644 --- a/Tests/TraktKitTests/Models/Shows/test_get_most_watched_shows.json +++ b/Tests/TraktKitTests/Models/Shows/test_get_most_watched_shows.json @@ -1,172 +1,172 @@ [ - { - "collector_count" : 73224, - "watcher_count" : 203742, - "show" : { - "title" : "Game of Thrones", - "year" : 2011, - "ids" : { - "tmdb" : 1399, - "slug" : "game-of-thrones", - "tvdb" : 121361, - "trakt" : 1390, - "imdb" : "tt0944947" - } + { + "watcher_count": 203742, + "play_count": 8784154, + "collected_count": 2436824, + "collector_count": 73224, + "show": { + "title": "Game of Thrones", + "year": 2011, + "ids": { + "trakt": 1390, + "slug": "game-of-thrones", + "tvdb": 121361, + "imdb": "tt0944947", + "tmdb": 1399 + } + } }, - "play_count" : 8784154, - "collected_count" : 2436824 - }, - { - "collector_count" : 57617, - "watcher_count" : 163097, - "show" : { - "title" : "The Walking Dead", - "year" : 2010, - "ids" : { - "tmdb" : 1402, - "slug" : "the-walking-dead", - "tvdb" : 153021, - "trakt" : 1393, - "imdb" : "tt1520211" - } + { + "watcher_count": 163097, + "play_count": 9945882, + "collected_count": 2423833, + "collector_count": 57617, + "show": { + "title": "The Walking Dead", + "year": 2010, + "ids": { + "trakt": 1393, + "slug": "the-walking-dead", + "tvdb": 153021, + "imdb": "tt1520211", + "tmdb": 1402 + } + } }, - "play_count" : 9945882, - "collected_count" : 2423833 - }, - { - "collector_count" : 54953, - "watcher_count" : 155291, - "show" : { - "title" : "The Big Bang Theory", - "year" : 2007, - "ids" : { - "tmdb" : 1418, - "slug" : "the-big-bang-theory", - "tvdb" : 80379, - "trakt" : 1409, - "imdb" : "tt0898266" - } + { + "watcher_count": 155291, + "play_count": 23542030, + "collected_count": 6635583, + "collector_count": 54953, + "show": { + "title": "The Big Bang Theory", + "year": 2007, + "ids": { + "trakt": 1409, + "slug": "the-big-bang-theory", + "tvdb": 80379, + "imdb": "tt0898266", + "tmdb": 1418 + } + } }, - "play_count" : 23542030, - "collected_count" : 6635583 - }, - { - "collector_count" : 49385, - "watcher_count" : 140725, - "show" : { - "title" : "Breaking Bad", - "year" : 2008, - "ids" : { - "tmdb" : 1396, - "slug" : "breaking-bad", - "tvdb" : 81189, - "trakt" : 1388, - "imdb" : "tt0903747" - } + { + "watcher_count": 140725, + "play_count": 8576210, + "collected_count": 2471061, + "collector_count": 49385, + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1388, + "slug": "breaking-bad", + "tvdb": 81189, + "imdb": "tt0903747", + "tmdb": 1396 + } + } }, - "play_count" : 8576210, - "collected_count" : 2471061 - }, - { - "collector_count" : 43351, - "watcher_count" : 123797, - "show" : { - "title" : "Arrow", - "year" : 2012, - "ids" : { - "tmdb" : 1412, - "slug" : "arrow", - "tvdb" : 257655, - "trakt" : 1403, - "imdb" : "tt2193021" - } + { + "watcher_count": 123797, + "play_count": 6173692, + "collected_count": 1856666, + "collector_count": 43351, + "show": { + "title": "Arrow", + "year": 2012, + "ids": { + "trakt": 1403, + "slug": "arrow", + "tvdb": 257655, + "imdb": "tt2193021", + "tmdb": 1412 + } + } }, - "play_count" : 6173692, - "collected_count" : 1856666 - }, - { - "collector_count" : 32335, - "watcher_count" : 100242, - "show" : { - "title" : "How I Met Your Mother", - "year" : 2005, - "ids" : { - "tmdb" : 1100, - "slug" : "how-i-met-your-mother", - "tvdb" : 75760, - "trakt" : 1095, - "imdb" : "tt0460649" - } + { + "watcher_count": 100242, + "play_count": 19787304, + "collected_count": 4529880, + "collector_count": 32335, + "show": { + "title": "How I Met Your Mother", + "year": 2005, + "ids": { + "trakt": 1095, + "slug": "how-i-met-your-mother", + "tvdb": 75760, + "imdb": "tt0460649", + "tmdb": 1100 + } + } }, - "play_count" : 19787304, - "collected_count" : 4529880 - }, - { - "collector_count" : 34077, - "watcher_count" : 94282, - "show" : { - "title" : "Sherlock", - "year" : 2010, - "ids" : { - "tmdb" : 19885, - "slug" : "sherlock", - "tvdb" : 176941, - "trakt" : 19792, - "imdb" : "tt1475582" - } + { + "watcher_count": 94282, + "play_count": 912066, + "collected_count": 260713, + "collector_count": 34077, + "show": { + "title": "Sherlock", + "year": 2010, + "ids": { + "trakt": 19792, + "slug": "sherlock", + "tvdb": 176941, + "imdb": "tt1475582", + "tmdb": 19885 + } + } }, - "play_count" : 912066, - "collected_count" : 260713 - }, - { - "collector_count" : 39724, - "watcher_count" : 89242, - "show" : { - "title" : "Homeland", - "year" : 2011, - "ids" : { - "tmdb" : 1407, - "slug" : "homeland", - "tvdb" : 247897, - "trakt" : 1398, - "imdb" : "tt1796960" - } + { + "watcher_count": 89242, + "play_count": 3375660, + "collected_count": 1299956, + "collector_count": 39724, + "show": { + "title": "Homeland", + "year": 2011, + "ids": { + "trakt": 1398, + "slug": "homeland", + "tvdb": 247897, + "imdb": "tt1796960", + "tmdb": 1407 + } + } }, - "play_count" : 3375660, - "collected_count" : 1299956 - }, - { - "collector_count" : 33424, - "watcher_count" : 87059, - "show" : { - "title" : "Dexter", - "year" : 2006, - "ids" : { - "tmdb" : 1405, - "slug" : "dexter", - "tvdb" : 79349, - "trakt" : 1396, - "imdb" : "tt0773262" - } + { + "watcher_count": 87059, + "play_count": 7799337, + "collected_count": 2239477, + "collector_count": 33424, + "show": { + "title": "Dexter", + "year": 2006, + "ids": { + "trakt": 1396, + "slug": "dexter", + "tvdb": 79349, + "imdb": "tt0773262", + "tmdb": 1405 + } + } }, - "play_count" : 7799337, - "collected_count" : 2239477 - }, - { - "collector_count" : 31982, - "watcher_count" : 83087, - "show" : { - "title" : "Marvel's Agents of S.H.I.E.L.D.", - "year" : 2013, - "ids" : { - "tmdb" : 1403, - "slug" : "marvel-s-agents-of-s-h-i-e-l-d", - "tvdb" : 263365, - "trakt" : 1394, - "imdb" : "tt2364582" - } - }, - "play_count" : 2795758, - "collected_count" : 881552 - } + { + "watcher_count": 83087, + "play_count": 2795758, + "collected_count": 881552, + "collector_count": 31982, + "show": { + "title": "Marvel's Agents of S.H.I.E.L.D.", + "year": 2013, + "ids": { + "trakt": 1394, + "slug": "marvel-s-agents-of-s-h-i-e-l-d", + "tvdb": 263365, + "imdb": "tt2364582", + "tmdb": 1403 + } + } + } ] diff --git a/Tests/TraktKitTests/Models/Shows/test_get_show_comments.json b/Tests/TraktKitTests/Models/Shows/test_get_show_comments.json index d10f0a8..aae9ad8 100644 --- a/Tests/TraktKitTests/Models/Shows/test_get_show_comments.json +++ b/Tests/TraktKitTests/Models/Shows/test_get_show_comments.json @@ -1,24 +1,382 @@ [ - { - "review" : false, - "user_rating" : 8, - "replies" : 1, - "id" : 8, - "created_at" : "2011-03-25T22:35:17.000Z", - "user" : { - "username" : "sean", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Sean Rudford", - "ids" : { - "slug" : "sean" - } - }, - "parent_id" : 0, - "updated_at" : "2011-03-25T22:35:17.000Z", - "comment" : "Great show!", - "spoiler" : false, - "likes" : 0 - } + { + "id": 755342, + "comment": "I did NOT expect that ending. Wow! What a show!", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2025-01-04T02:57:47.000Z", + "updated_at": "2025-01-04T02:57:47.000Z", + "replies": 0, + "likes": 0, + "user_rating": null, + "user_stats": { + "rating": null, + "play_count": 8, + "completed_count": 8 + }, + "user": { + "username": "krissybdavis", + "private": false, + "deleted": false, + "name": "Krissy", + "vip": false, + "vip_ep": false, + "ids": { + "slug": "krissybdavis" + }, + "joined_at": "2024-11-14T17:09:55.000Z", + "location": "Atlanta, Georgia", + "about": null, + "gender": "female", + "age": null, + "images": { + "avatar": { + "full": "https://walter-r2.trakt.tv/images/users/014/738/689/avatars/large/47431ac676.jpg" + } + } + } + }, + { + "id": 726677, + "comment": "Jake Gyllenhaal was so sketchy for real. As a mystery, it did it's job of sucking me in. It was ok.", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2024-10-24T07:27:49.000Z", + "updated_at": "2024-10-24T07:27:49.000Z", + "replies": 0, + "likes": 0, + "user_rating": null, + "user_stats": { + "rating": null, + "play_count": 8, + "completed_count": 8 + }, + "user": { + "username": "caragwapa", + "private": false, + "deleted": false, + "name": "Cara", + "vip": false, + "vip_ep": false, + "ids": { + "slug": "caragwapa" + }, + "joined_at": "2023-05-26T07:38:29.000Z", + "location": "Cebu City, Philippines", + "about": null, + "gender": "female", + "age": 42, + "images": { + "avatar": { + "full": "https://secure.gravatar.com/avatar/499d0e10eca26c2f496a842aa0b93312?d=https%3A%2F%2Fwalter-r2.trakt.tv%2Fhotlink-ok%2Fplaceholders%2Fmedium%2Fleela.png\u0026r=pg\u0026s=256" + } + } + } + }, + { + "id": 726002, + "comment": "The show was wonderful up until the end, sadly. What a massive disappointment.", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2024-10-22T14:12:00.000Z", + "updated_at": "2024-10-22T14:12:00.000Z", + "replies": 0, + "likes": 0, + "user_rating": 7, + "user_stats": { + "rating": 7, + "play_count": 8, + "completed_count": 8 + }, + "user": { + "username": "LaterGator", + "private": false, + "deleted": false, + "name": "LG", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "latergator" + }, + "joined_at": "2022-10-09T20:11:55.000Z", + "location": "", + "about": "", + "gender": "male", + "age": null, + "images": { + "avatar": { + "full": "https://walter-r2.trakt.tv/images/users/012/259/420/avatars/large/d8fc8b9573.png" + } + } + } + }, + { + "id": 725385, + "comment": "Don't waste your time. Ultimately it's just so frustrating.\n\nDavid E Kelley seems to revel in this approach to tv shows. Plod along, end the episode with a shock, next episode ignores the shock and plods along until it ends with a shock, repeat for several episodes, pushing the viewer hard toward a reveal, then end with a left-field under-cooked twist.\n\nWaste of time.", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2024-10-20T19:42:02.000Z", + "updated_at": "2024-10-20T19:42:02.000Z", + "replies": 0, + "likes": 1, + "user_rating": 4, + "user_stats": { + "rating": 4, + "play_count": 8, + "completed_count": 8 + }, + "user": { + "username": "PorterUk", + "private": false, + "deleted": false, + "name": "PorterUk", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "porteruk" + }, + "joined_at": "2017-11-16T11:51:48.000Z", + "location": "England", + "about": "", + "gender": "male", + "age": 45, + "images": { + "avatar": { + "full": "https://walter-r2.trakt.tv/images/users/003/416/325/avatars/large/05aa466845.jpg" + } + } + } + }, + { + "id": 714934, + "comment": "Classic case of a modern show not knowing how to write the ending of a solid series. Good family drama and mystery with some great acting.", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2024-09-20T15:07:22.000Z", + "updated_at": "2024-09-20T15:07:22.000Z", + "replies": 0, + "likes": 0, + "user_rating": 7, + "user_stats": { + "rating": 7, + "play_count": 9, + "completed_count": 8 + }, + "user": { + "username": "kidmodo", + "private": false, + "deleted": false, + "name": "Kidmodo", + "vip": false, + "vip_ep": false, + "ids": { + "slug": "kidmodo" + }, + "joined_at": "2023-09-22T13:01:25.000Z", + "location": "Baltimore MD", + "about": "", + "gender": "male", + "age": 29, + "images": { + "avatar": { + "full": "https://walter-r2.trakt.tv/images/users/013/052/697/avatars/large/9a6314d1e4.png" + } + } + } + }, + { + "id": 707456, + "comment": "This is a series I enjoyed very much! I am a big fan of Jake Gyllenhaal. But I also realy liked the performances of Fagbenle and Peter Sarsgaard. It's a good series! I definitely recommend it! There is a lot of suspense and I liked the twists!", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2024-08-26T12:25:58.000Z", + "updated_at": "2024-08-26T12:25:58.000Z", + "replies": 0, + "likes": 0, + "user_rating": 7, + "user_stats": { + "rating": 7, + "play_count": 8, + "completed_count": 8 + }, + "user": { + "username": "pato22", + "private": false, + "deleted": false, + "name": "Wéjih M'zoughi", + "vip": false, + "vip_ep": false, + "ids": { + "slug": "pato22" + }, + "joined_at": "2017-01-26T14:13:25.000Z", + "location": "Hogwarts", + "about": "", + "gender": "male", + "age": 28, + "images": { + "avatar": { + "full": "https://walter-r2.trakt.tv/images/users/002/650/222/avatars/large/9b32b56669.gif" + } + } + } + }, + { + "id": 704369, + "comment": "weird they said season 1 is last now they added one", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2024-08-16T05:43:58.000Z", + "updated_at": "2024-08-16T05:43:58.000Z", + "replies": 0, + "likes": 0, + "user_rating": 8, + "user_stats": { + "rating": 8, + "play_count": 8, + "completed_count": 8 + }, + "user": { + "username": "marionaise1990", + "private": false, + "deleted": false, + "name": "Mario Helmes", + "vip": false, + "vip_ep": false, + "ids": { + "slug": "marionaise1990" + }, + "joined_at": "2024-05-25T13:50:33.000Z", + "location": "Landgraaf, Limburg", + "about": "", + "gender": "male", + "age": 34, + "images": { + "avatar": { + "full": "https://walter-r2.trakt.tv/images/users/013/878/832/avatars/large/a6868f2131.png" + } + } + } + }, + { + "id": 703287, + "comment": "Overall a good entertaining show!", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2024-08-12T20:01:54.000Z", + "updated_at": "2024-08-12T20:01:54.000Z", + "replies": 0, + "likes": 0, + "user_rating": 7, + "user_stats": { + "rating": 7, + "play_count": 8, + "completed_count": 8 + }, + "user": { + "username": "astrix", + "private": false, + "deleted": false, + "name": "Astrix", + "vip": false, + "vip_ep": false, + "ids": { + "slug": "astrix" + }, + "joined_at": "2014-12-25T19:04:12.000Z", + "location": "Uranus", + "about": "I was looking out at the vastness of the ocean, and it was overwhelming. I imagined a point way out there and how deep it would go. Then I get the vastness of the ocean and in that moment I can understand my pain and the size of my fear. My actual self is so tied up with it. With shame. ❖", + "gender": "male", + "age": null, + "images": { + "avatar": { + "full": "https://walter-r2.trakt.tv/images/users/000/782/038/avatars/large/594279c981.gif" + } + } + } + }, + { + "id": 702944, + "comment": "Started of interesting and the pacing was good, but ruined by that twist at the end. Just not believable or plausible and made the whole thing pointless to watch in the end. That twist was unnecessary and could have been done many ways better. Rest of the show and the acting is quite good but does feel dragged in the middle", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2024-08-11T18:19:45.000Z", + "updated_at": "2024-08-12T16:40:30.000Z", + "replies": 0, + "likes": 0, + "user_rating": 6, + "user_stats": { + "rating": 6, + "play_count": 8, + "completed_count": 8 + }, + "user": { + "username": "Erik7Aeiou", + "private": false, + "deleted": false, + "name": "Erik7Aeiou", + "vip": false, + "vip_ep": false, + "ids": { + "slug": "erik7aeiou" + }, + "joined_at": "2024-08-09T09:41:55.000Z", + "location": "", + "about": "", + "gender": "", + "age": null, + "images": { + "avatar": { + "full": "https://walter-r2.trakt.tv/images/users/014/219/490/avatars/large/d36656b71e.jpg" + } + } + } + }, + { + "id": 702913, + "comment": "This could've been a movie.", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2024-08-11T16:36:32.000Z", + "updated_at": "2024-08-11T16:36:32.000Z", + "replies": 1, + "likes": 0, + "user_rating": null, + "user_stats": { + "rating": null, + "play_count": 8, + "completed_count": 8 + }, + "user": { + "username": "sherlockedx", + "private": false, + "deleted": false, + "name": "isaías", + "vip": false, + "vip_ep": false, + "ids": { + "slug": "sherlockedx" + }, + "joined_at": "2013-10-05T21:26:21.000Z", + "location": "Brazil", + "about": "", + "gender": "male", + "age": 26, + "images": { + "avatar": { + "full": "https://walter-r2.trakt.tv/images/users/000/320/602/avatars/large/4bdd6af46a.png" + } + } + } + } ] diff --git a/Tests/TraktKitTests/Models/Shows/test_get_show_translations.json b/Tests/TraktKitTests/Models/Shows/test_get_show_translations.json index 19fabdc..8925307 100644 --- a/Tests/TraktKitTests/Models/Shows/test_get_show_translations.json +++ b/Tests/TraktKitTests/Models/Shows/test_get_show_translations.json @@ -1,17 +1,282 @@ [ - { - "title" : "Breaking Bad", - "language" : "en", - "overview" : "Breaking Bad is an American crime drama television series created and produced by Vince Gilligan. Set and produced in Albuquerque, New Mexico, Breaking Bad is the story of Walter White, a struggling high school chemistry teacher who is diagnosed with inoperable lung cancer at the beginning of the series. He turns to a life of crime, producing and selling methamphetamine, in order to secure his family's financial future before he dies, teaming with his former student, Jesse Pinkman. Heavily serialized, the series is known for positioning its characters in seemingly inextricable corners and has been labeled a contemporary western by its creator." - }, - { - "title" : "Breaking Bad", - "language" : "tr", - "overview" : "..." - }, - { - "title" : "Perníkový tatko", - "language" : "sk", - "overview" : "" - } + { + "title": null, + "overview": "جريمة قتل مريعة تقلب مكتب النائب العام رأساً على عقب عندما يُتهم أحد أعضاءه بارتكاب الجريمة، ويبدأ المتهم بالمعاناة ليحافظ على شمل عائلته.", + "tagline": "لا تفترض شيئًا.", + "language": "ar", + "country": "ae" + }, + { + "title": null, + "overview": "جريمة قتل مريعة تقلب مكتب النائب العام رأساً على عقب عندما يُتهم أحد أعضاءه بارتكاب الجريمة، ويبدأ المتهم بالمعاناة ليحافظ على شمل عائلته.", + "tagline": "لا تفترض شيئًا.", + "language": "ar", + "country": "sa" + }, + { + "title": "Nedostatek důkazů", + "overview": "Příšerná vražda otřese základy chicagské prokuratury. Ze zločinu je totiž podezřelý někdo z jejích řad, kdo nyní musí bojovat za udržení vlastní rodiny pohromadě.", + "tagline": "Čekejte nečekané.", + "language": "cs", + "country": "cz" + }, + { + "title": null, + "overview": "Et grusomt drab vender op og ned på den offentlige anklagers kontor i Chicago, da en af kollegerne mistænkes for forbrydelsen – så den anklagede må kæmpe for at holde sammen på sin familie.", + "tagline": "Intet kan tages for givet.", + "language": "da", + "country": "dk" + }, + { + "title": "Aus Mangel an Beweisen", + "overview": "Ein brutaler Mord erschüttert die Staatsanwaltschaft von Chicago, als einer ihrer Mitarbeiter des Verbrechens beschuldigt wird und der Beschuldigte um den Zusammenhalt seiner Familie kämpfen muss.", + "tagline": "Nichts ist, wie es scheint.", + "language": "de", + "country": "de" + }, + { + "title": "Αθώος Μέχρι Αποδείξεως του Εναντίου", + "overview": "Ένας φρικτός φόνος αναστατώνει την Εισαγγελία του Σικάγο όταν ένας δικός της γίνεται ύποπτος για το έγκλημα, την ώρα που ο ίδιος παλεύει να κρατήσει την οικογένειά του ενωμένη.", + "tagline": "Μην υποθέτεις τίποτα.", + "language": "el", + "country": "gr" + }, + { + "title": "Presumed Innocent", + "overview": "A horrific murder upends the Chicago Prosecuting Attorney's Office when one of its own is suspected of the crime—leaving the accused fighting to keep his family together.", + "tagline": "Presume nothing.", + "language": "en", + "country": "us" + }, + { + "title": "Presunto inocente", + "overview": "Un asesinato horrible trastoca a la Fiscalía de Chicago cuando uno de los suyos es sospechoso del crimen. El acusado deberá luchar por mantener unida a su familia.", + "tagline": "Nada es lo que parece.", + "language": "es", + "country": "es" + }, + { + "title": "Se presume inocente", + "overview": "Un asesinato espantoso pone de cabeza la oficina de la fiscalía de Chicago cuando uno de los suyos resulta sospechoso del crimen, por lo que el acusado tendrá que luchar por mantener unida a su familia.", + "tagline": "Olvida lo que creías saber.", + "language": "es", + "country": "mx" + }, + { + "title": null, + "overview": "پس از اینکه یکی از اعضای دفتر دادستانی شیکاگو به طرز وحشتناکی به قتل می رسد، یک نفر از همین اعضا به مظنون اصلی این جنایت تبدیل می شود و...", + "tagline": null, + "language": "fa", + "country": "ir" + }, + { + "title": null, + "overview": "Kamala murha murtaa Chicagon syyttäjänviraston, kun yhtä sen omista epäillään rikoksesta. Syytetty joutuu taistelemaan pitääkseen perheensä koossa.", + "tagline": "Älä oleta mitään.", + "language": "fi", + "country": "fi" + }, + { + "title": "Présumé innocent", + "overview": "Un horrible meurtre bouleverse le bureau du procureur de Chicago quand un de ses membres est suspecté dans cette affaire, obligeant l’accusé à se battre pour garder sa famille unie.", + "tagline": "Ne présumez de rien.", + "language": "fr", + "country": "fr" + }, + { + "title": "Présumé innocent", + "overview": "Un horrible meurtre bouleverse le bureau du procureur de Chicago quand un de ses membres est suspecté dans cette affaire, obligeant l’accusé à se battre pour garder sa famille unie.", + "tagline": "Ne présumez de rien.", + "language": "fr", + "country": "ca" + }, + { + "title": null, + "overview": "רצח נורא מזעזע את משרד התובע המחוזי של שיקגו. כשאחד מעובדיו נחשד בביצוע הפשע, הוא נאלץ להילחם כדי שמשפחתו לא תתפרק.", + "tagline": "אסור להניח מראש שום דבר.", + "language": "he", + "country": "il" + }, + { + "title": "प्रिज़्यूम्ड इनोसेंट", + "overview": "एक भयावह हत्या से शिकागो अभियोजन वकील के कार्यालय में तब उथल-पुथल मच जाती है, जब उसका अपना ही एक व्यक्ति शक के घेरे में आ जाता है—और अभियुक्त को अपने परिवार को साथ बनाए रखने के लिए संघर्ष करना पड़ता है।", + "tagline": "पहले से कुछ मानकर न चलें।", + "language": "hi", + "country": "in" + }, + { + "title": "Ártatlanságra ítélve", + "overview": "Szörnyű gyilkosság forgatja fel a Chicagói Ügyészség hivatalát, amikor az egyik emberüket vádolják meg az elkövetéssel. A gyanúsítottnak azzal is meg kell küzdenie, hogy összetartsa a családját.", + "tagline": "Várd ki a végét.", + "language": "hu", + "country": "hu" + }, + { + "title": null, + "overview": "Pembunuhan yang mengerikan menggaduhkan Kantor Kejaksaan Chicago ketika salah satu dari mereka dicurigai melakukan kejahatan tersebut dan menyebabkan si tertuduh harus berjuang untuk menjaga keutuhan keluarganya.", + "tagline": "Jangan menduga apa pun.", + "language": "id", + "country": "id" + }, + { + "title": "Presunto innocente", + "overview": "Un terribile omicidio getta nel caos l’ufficio del procuratore di Chicago quando un dipendente è sospettato del crimine. L’accusato dovrà lottare per tenere insieme la propria famiglia.", + "tagline": "Niente è come te lo aspetti.", + "language": "it", + "country": "it" + }, + { + "title": "推定無罪", + "overview": "恐ろしい殺人事件が発生し、シカゴの検察官に容疑がかけられる。当局が大混乱に陥る一方で、容疑者の男は家族を守るために闘っていた。", + "tagline": "推測は、推測に過ぎない。", + "language": "ja", + "country": "jp" + }, + { + "title": "'무죄추정' - Presumed Innocent", + "overview": "끔찍한 살인 사건의 용의자로 동료 중 한 명이 지목되자 시카고 검찰청이 뒤집히고, 용의자는 가족을 지키기 위해 싸울 수밖에 없다.", + "tagline": "아무 것도 확신하지 마라.", + "language": "ko", + "country": "kr" + }, + { + "title": null, + "overview": "Serialas apie Čikagos prokurorą, apkaltintą savo kolegos nužudymu. Kai Rozat „Rusty“ Sabich nėra namuose su žmona Barbara ir vaikais Jadenu ir Kyle'u, jis dirba D.A. pagrindiniu kaltinimo advokatu. Taigi, kai jų kolegė Carolyn Polhemus randama negyva, Horganas paskiria jį į bylą. Pakankamai sunku susidoroti su kolegos netektimi, bet viskas pablogėja, kai varžovai Nico Della Guardia ir Tommy Molto, kurie artėjančiuose rinkimuose kovoja dėl Horgano ir Sabicho darbo vietų, naudojasi jos mirtimi. kaip politinė galimybė. Tuo tarpu bylą apsunkina tai, kad Polhemus kūnas buvo paliktas taip pat, kaip kalinamas žudikas Liamas Reinoldsas, vyras, kurį ji ir Sabichas nuteisė prieš kelerius metus, tariamai paliko savo auką. Tačiau kai įkalčiai pradeda krypti netikėtomis kryptimis, Sabichas atskleidžia tiesą apie savo santykių su mirusiuoju pobūdį, todėl jis tampa pagrindiniu įtariamuoju ir dėl to jo gyvenimas išsklaido.", + "tagline": null, + "language": "lt", + "country": "lt" + }, + { + "title": null, + "overview": "Na een gruwelijke moord is een advocatenkantoor in Chicago in rep en roer als een van hen verdacht wordt van de misdaad. De aangeklaagde vecht om zijn gezin bijeen te houden.", + "tagline": "Ga nergens vanuit.", + "language": "nl", + "country": "nl" + }, + { + "title": null, + "overview": "Et grusomt drap ryster statsadvokatens kontor i Chicagos når en av deres egne blir mistenkt i saken –og den mistenkte kjemper for å holde familien sammen.", + "tagline": "Antagelser er nytteløse.", + "language": "no", + "country": "no" + }, + { + "title": "Uznany za niewinnego", + "overview": "Szokujące zabójstwo stawia na nogi prokuraturę w Chicago, gdy podejrzana staje się jedna z pracujących tam osób, którą następnie czeka walka o rodzinę.", + "tagline": "Nie zakładaj niczego z góry.", + "language": "pl", + "country": "pl" + }, + { + "title": "Presumível Inocente", + "overview": "Um homicídio horrível deixa o Ministério Público de Chicago de pernas para o ar, quando um dos seus membros é suspeito do crime, fazendo com que o acusado tenha de lutar para manter a sua família unida.", + "tagline": "Presumir é um erro.", + "language": "pt", + "country": "pt" + }, + { + "title": "Acima de Qualquer Suspeita", + "overview": "Um assassinato hediondo deixa o escritório da Promotoria de Justiça de Chicago de cabeça para baixo quando um dos funcionários é suspeito do crime, o que deixa o acusado lutando para manter sua família unida.", + "tagline": "Presumir é um erro.", + "language": "pt", + "country": "br" + }, + { + "title": "Presupus nevinovat", + "overview": null, + "tagline": null, + "language": "ro", + "country": "ro" + }, + { + "title": "Презумпция невиновности", + "overview": "Ужасное убийство переворачивает с ног на голову прокуратуру Чикаго, когда становится известно, что в преступлении подозревается один из сотрудников. В результате обвиняемый борется за то, чтобы сохранить семью.", + "tagline": "Всегда требуйте доказательств.", + "language": "ru", + "country": "ru" + }, + { + "title": "Prezumpcia neviny", + "overview": "Príšerná vražda otrasie prokuratúrou v Chicagu, keď sa z jedného zo zamestnancov vykľuje podozrivý, ktorý zrazu musí bojovať, aby udržal vlastnú rodinu pokope.", + "tagline": "Nemyslite si, že niečo viete.", + "language": "sk", + "country": "sk" + }, + { + "title": null, + "overview": "Rusty Sabich je namestnik generalnega državnega tožilca v Chicagu čigar življenje se obrne na glavo, ko je obtožen umora sodelavke, ki je bila njegova ljubica. Med preiskavo se začnejo razkrivati skrivnosti iz njegovega osebnega in poklicnega življenja, ki razkrivajo zapleteno mrežo izdajstva, skritih želja in politične manipulacije. Nazorno so prikazane moralne dileme, s katerimi se sooča Sabich, ko se trudi ne le dokazati svojo nedolžnost, temveč tudi ohraniti svoj zakon in povezanost družine.", + "tagline": "Ničesar ne domnevajte.", + "language": "sl", + "country": "si" + }, + { + "title": "Претпоставља се да је невин", + "overview": "Ужасно убиство поремети Тужилаштво у Чикагу када је неко од њих осумњичен за злочин – остављајући оптуженог да се бори да одржи породицу на окупу.", + "tagline": null, + "language": "sr", + "country": "rs" + }, + { + "title": null, + "overview": "Ett hemskt mord vänder upp och ner på Chicagos åklagarmyndighet när en av deras egna misstänks för brottet, vilket tvingar den anklagade att kämpa för att hålla ihop sin familj.", + "tagline": "Inget är som det verkar.", + "language": "sv", + "country": "se" + }, + { + "title": null, + "overview": "การฆาตกรรมสุดสยองทำให้สำนักงานอัยการชิคาโกต้องตกตะลึง เมื่อหนึ่งในเจ้าหน้าที่เป็นผู้ต้องสงสัยในอาชญากรรมครั้งนี้ ทำให้ผู้ที่ถูกกล่าวหาต้องต่อสู้เพื่อทำให้ครอบครัวของเขาได้อยู่พร้อมหน้ากัน", + "tagline": "อย่าเชื่อทุกสิ่งเพราะความจริงอาจพลิกผัน", + "language": "th", + "country": "th" + }, + { + "title": null, + "overview": "Korkunç bir cinayet, kendi aralarından biri şüpheli hâline gelmesi ile Şikago Savcılığı’nı altüst eder. Sanığın ise ailesini bir arada tutmak için mücadele etmesi gerekir.", + "tagline": null, + "language": "tr", + "country": "tr" + }, + { + "title": "Презумпція невинуватості", + "overview": "Жахливе вбивство сколихує прокуратуру Чикаго, коли її співробітника підозрюють у скоєнні злочину, а сам обвинувачений змушений боротися за збереження своєї сім’ї.", + "tagline": "Жодних припущень.", + "language": "uk", + "country": "ua" + }, + { + "title": "Suy Đoán Vô Tội - Presumed Innocent", + "overview": "Một vụ án mạng kinh hoàng làm rúng động Phòng Công tố Chicago khi một nhân viên bị tình nghi là thủ phạm, khiến cho bị cáo phải đấu tranh để níu giữ gia đình mình.", + "tagline": "Kết quả khó lường.", + "language": "vi", + "country": "vn" + }, + { + "title": null, + "overview": "一桩骇人听闻的谋杀案闹得芝加哥检察官办公室天翻地覆,因为该办公室的一名成员成了这起犯罪的嫌疑人,使其不得不为维系家庭而战。\n\n该剧改编自斯考特·杜罗所著同名小说,讲述芝加哥检察官办公室发生了一起可怕的谋杀案后,检察官之一拉斯蒂·萨比奇(吉伦哈尔)成了犯罪嫌疑人。在被告为维持家庭和婚姻而奋斗的过程中,探索痴迷、性、政治以及爱情的力量和局限性。\n\n内伽饰演拉斯蒂妻子芭芭拉,是名艺术家、画廊老板和母亲,她的生活在丈夫被指控谋杀他的情妇后被颠覆了,她在处理受伤的心和破碎的婚姻时也为家人而战,并与丈夫广为人知的审判作斗争。", + "tagline": null, + "language": "zh", + "country": "cn" + }, + { + "title": "無罪的罪人", + "overview": "一宗可怕的謀殺案使芝加哥檢察官辦公室陷入混亂,因為疑犯是其中一員,被告也不得不為保護家人而奮戰。", + "tagline": "斷不能武斷。", + "language": "zh", + "country": "hk" + }, + { + "title": null, + "overview": "一桩骇人听闻的谋杀案闹得芝加哥检察官办公室天翻地覆,因为该办公室的一名成员成了这起犯罪的嫌疑人,使其不得不为维系家庭而战。", + "tagline": null, + "language": "zh", + "country": "sg" + }, + { + "title": "無罪的罪人", + "overview": "一場可怕的謀殺案顛覆了芝加哥檢察官辦公室,因為其中一名檢察官涉嫌犯罪,而被告只好奮力將家人維繫在一起。", + "tagline": "勿妄下定論。", + "language": "zh", + "country": "tw" + } ] diff --git a/Tests/TraktKitTests/Models/Users/test_get_all_list_comments.json b/Tests/TraktKitTests/Models/Users/test_get_all_list_comments.json index 432b222..b4509cf 100644 --- a/Tests/TraktKitTests/Models/Users/test_get_all_list_comments.json +++ b/Tests/TraktKitTests/Models/Users/test_get_all_list_comments.json @@ -1,24 +1,28 @@ [ - { - "review" : false, - "user_rating" : null, - "replies" : 0, - "id" : 8, - "created_at" : "2011-03-25T22:35:17.000Z", - "user" : { - "username" : "sean", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Sean Rudford", - "ids" : { - "slug" : "sean" - } - }, - "parent_id" : 0, - "updated_at" : "2011-03-25T22:35:17.000Z", - "comment" : "Can't wait to watch everything on this epic list!", - "spoiler" : false, - "likes" : 0 - } + { + "id": 8, + "parent_id": 0, + "created_at": "2011-03-25T22:35:17.000Z", + "updated_at": "2011-03-25T22:35:17.000Z", + "comment": "Can't wait to watch everything on this epic list!", + "spoiler": false, + "review": false, + "replies": 0, + "likes": 0, + "user_stats": { + "rating": null, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "sean", + "private": false, + "name": "Sean Rudford", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "sean" + } + } + } ] diff --git a/Tests/TraktKitTests/Models/Users/test_get_comments_likes.json b/Tests/TraktKitTests/Models/Users/test_get_comments_likes.json index a340806..4817429 100644 --- a/Tests/TraktKitTests/Models/Users/test_get_comments_likes.json +++ b/Tests/TraktKitTests/Models/Users/test_get_comments_likes.json @@ -1,29 +1,32 @@ [ - { - "type" : "comment", - "comment" : { - "review" : false, - "user_rating" : null, - "replies" : 0, - "id" : 190, - "created_at" : "2014-08-04T06:46:01.000Z", - "user" : { - "username" : "sean", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Sean Rudford", - "ids" : { - "slug" : "sean" + { + "liked_at": "2015-03-30T23:18:42.000Z", + "type": "comment", + "comment": { + "id": 190, + "parent_id": 0, + "created_at": "2014-08-04T06:46:01.000Z", + "updated_at": "2014-08-04T06:46:01.000Z", + "comment": "Oh, I wasn't really listening.", + "spoiler": false, + "review": false, + "replies": 0, + "likes": 0, + "user_stats": { + "rating": null, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "sean", + "private": false, + "name": "Sean Rudford", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "sean" + } + } } - }, - "parent_id" : 0, - "updated_at" : "2014-08-04T06:46:01.000Z", - "comment" : "Oh, I wasn't really listening.", - "spoiler" : false, - "likes" : 0 - }, - "liked_at" : "2015-03-30T23:18:42.000Z" - } + } ] - diff --git a/Tests/TraktKitTests/Models/Users/test_get_user_comments.json b/Tests/TraktKitTests/Models/Users/test_get_user_comments.json index bb71530..464497c 100644 --- a/Tests/TraktKitTests/Models/Users/test_get_user_comments.json +++ b/Tests/TraktKitTests/Models/Users/test_get_user_comments.json @@ -1,204 +1,225 @@ [ - { - "type" : "movie", - "movie" : { - "title" : "Batman Begins", - "year" : 2005, - "ids" : { - "tmdb" : 272, - "slug" : "batman-begins-2005", - "trakt" : 1, - "imdb" : "tt0372784" - } - }, - "comment" : { - "review" : false, - "user_rating" : 10, - "replies" : 0, - "id" : 267, - "created_at" : "2015-04-25T00:14:57.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "movie", + "movie": { + "title": "Batman Begins", + "year": 2005, + "ids": { + "trakt": 1, + "slug": "batman-begins-2005", + "imdb": "tt0372784", + "tmdb": 272 + } + }, + "comment": { + "id": 267, + "comment": "Great kickoff to a new Batman trilogy!", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2015-04-25T00:14:57.000Z", + "updated_at": "2015-04-25T00:14:57.000Z", + "replies": 0, + "likes": 0, + "user_stats": { + "rating": 10, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "Great kickoff to a new Batman trilogy!", - "updated_at" : "2015-04-25T00:14:57.000Z", - "likes" : 0 - } - }, - { - "type" : "show", - "show" : { - "title" : "Breaking Bad", - "year" : 2008, - "ids" : { - "tmdb" : 1396, - "slug" : "breaking-bad", - "tvdb" : 81189, - "trakt" : 1, - "imdb" : "tt0903747" - } }, - "comment" : { - "review" : false, - "user_rating" : 10, - "replies" : 0, - "id" : 199, - "created_at" : "2015-02-18T06:02:30.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "show", + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "slug": "breaking-bad", + "tvdb": 81189, + "imdb": "tt0903747", + "tmdb": 1396 + } + }, + "comment": { + "id": 199, + "comment": "Skyler, I AM THE DANGER.", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2015-02-18T06:02:30.000Z", + "updated_at": "2015-02-18T06:02:30.000Z", + "replies": 0, + "likes": 0, + "user_stats": { + "rating": 10, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "Skyler, I AM THE DANGER.", - "updated_at" : "2015-02-18T06:02:30.000Z", - "likes" : 0 - } - }, - { - "season" : { - "number" : 1, - "ids" : { - "trakt" : 3958, - "tvdb" : 274431, - "tmdb" : 60394 - } - }, - "show" : { - "title" : "Gotham", - "year" : 2014, - "ids" : { - "tmdb" : 60708, - "slug" : "gotham", - "tvdb" : 274431, - "trakt" : 869, - "imdb" : "tt3749900" - } }, - "type" : "season", - "comment" : { - "review" : false, - "user_rating" : 8, - "replies" : 0, - "id" : 220, - "created_at" : "2015-04-21T06:53:25.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "season", + "season": { + "number": 1, + "ids": { + "trakt": 3958, + "tvdb": 274431, + "tmdb": 60394 + } + }, + "show": { + "title": "Gotham", + "year": 2014, + "ids": { + "trakt": 869, + "slug": "gotham", + "tvdb": 274431, + "imdb": "tt3749900", + "tmdb": 60708 + } + }, + "comment": { + "id": 220, + "comment": "Kicking off season 1 for a new Batman show.", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2015-04-21T06:53:25.000Z", + "updated_at": "2015-04-21T06:53:25.000Z", + "replies": 0, + "likes": 0, + "user_stats": { + "rating": 8, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "Kicking off season 1 for a new Batman show.", - "updated_at" : "2015-04-21T06:53:25.000Z", - "likes" : 0 - } - }, - { - "show" : { - "title" : "Gotham", - "year" : 2014, - "ids" : { - "tmdb" : 60708, - "slug" : "gotham", - "tvdb" : 274431, - "trakt" : 869, - "imdb" : "tt3749900" - } }, - "type" : "episode", - "comment" : { - "review" : false, - "user_rating" : 7, - "replies" : 1, - "id" : 229, - "created_at" : "2015-04-21T15:42:31.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "episode", + "episode": { + "season": 1, + "number": 1, + "title": "Jim Gordon", + "ids": { + "trakt": 63958, + "tvdb": 4768720, + "imdb": "tt3216414", + "tmdb": 975968 + } + }, + "show": { + "title": "Gotham", + "year": 2014, + "ids": { + "trakt": 869, + "slug": "gotham", + "tvdb": 274431, + "imdb": "tt3749900", + "tmdb": 60708 + } + }, + "comment": { + "id": 229, + "comment": "Is this the OC?", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2015-04-21T15:42:31.000Z", + "updated_at": "2015-04-21T15:42:31.000Z", + "replies": 1, + "likes": 0, + "user_stats": { + "rating": 7, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "Is this the OC?", - "updated_at" : "2015-04-21T15:42:31.000Z", - "likes" : 0 }, - "episode" : { - "number" : 1, - "season" : 1, - "title" : "Jim Gordon", - "ids" : { - "tmdb" : 975968, - "tvdb" : 4768720, - "trakt" : 63958, - "imdb" : "tt3216414" - } - } - }, - { - "type" : "list", - "comment" : { - "review" : false, - "user_rating" : null, - "replies" : 0, - "id" : 268, - "created_at" : "2014-12-08T17:34:51.000Z", - "user" : { - "username" : "justin", - "private" : false, - "vip" : true, - "vip_ep" : false, - "name" : "Justin N.", - "ids" : { - "slug" : "justin" + { + "type": "list", + "list": { + "name": "Star Wars", + "description": "The complete Star Wars saga!", + "privacy": "public", + "share_link": "https://trakt.tv/lists/51", + "display_numbers": false, + "allow_comments": true, + "updated_at": "2015-04-22T22:01:39.000Z", + "item_count": 8, + "comment_count": 0, + "likes": 0, + "ids": { + "trakt": 51, + "slug": "star-wars" + } + }, + "comment": { + "id": 268, + "comment": "May the 4th be with you!", + "spoiler": false, + "review": false, + "parent_id": 0, + "created_at": "2014-12-08T17:34:51.000Z", + "updated_at": "2014-12-08T17:34:51.000Z", + "replies": 0, + "likes": 0, + "user_stats": { + "rating": null, + "play_count": 1, + "completed_count": 1 + }, + "user": { + "username": "justin", + "private": false, + "name": "Justin N.", + "vip": true, + "vip_ep": false, + "ids": { + "slug": "justin" + } + } } - }, - "parent_id" : 0, - "spoiler" : false, - "comment" : "May the 4th be with you!", - "updated_at" : "2014-12-08T17:34:51.000Z", - "likes" : 0 - }, - "list" : { - "ids" : { - "trakt" : 51, - "slug" : "star-wars" - }, - "display_numbers" : false, - "privacy" : "public", - "allow_comments" : true, - "comment_count" : 0, - "item_count" : 8, - "description" : "The complete Star Wars saga!", - "updated_at" : "2015-04-22T22:01:39.000Z", - "name" : "Star Wars", - "likes" : 0 } - } ] diff --git a/Tests/TraktKitTests/ShowsTests.swift b/Tests/TraktKitTests/ShowsTests.swift index 3f723f1..07bf8dd 100644 --- a/Tests/TraktKitTests/ShowsTests.swift +++ b/Tests/TraktKitTests/ShowsTests.swift @@ -381,7 +381,7 @@ final class ShowsTests: TraktTestCase { let expectation = XCTestExpectation(description: "Get show translations") traktManager.getShowTranslations(showID: "game-of-thrones", language: "es") { result in if case .success(let translations) = result { - XCTAssertEqual(translations.count, 3) + XCTAssertEqual(translations.count, 40) expectation.fulfill() } } @@ -398,12 +398,12 @@ final class ShowsTests: TraktTestCase { // MARK: - Comments func test_get_show_comments() throws { - try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones/comments", result: .success(jsonData(named: "test_get_show_comments"))) + try mock(.GET, "https://api.trakt.tv/shows/presumed-innocent/comments", result: .success(jsonData(named: "test_get_show_comments"))) let expectation = XCTestExpectation(description: "Get show comments") - traktManager.getShowComments(showID: "game-of-thrones") { result in + traktManager.getShowComments(showID: "presumed-innocent") { result in if case .success(let comments, _, _) = result { - XCTAssertEqual(comments.count, 1) + XCTAssertEqual(comments.count, 10) expectation.fulfill() } } @@ -416,6 +416,23 @@ final class ShowsTests: TraktTestCase { } } + func test_get_show_comments_async() async throws { + try mock(.GET, "https://api.trakt.tv/shows/presumed-innocent/comments/newest", result: .success(jsonData(named: "test_get_show_comments")), headers: [.page(1), .pageCount(4)]) + + let pagedObject = try await traktManager.show(id: "presumed-innocent") + .comments(sort: "newest") + .perform() + let (watchedShows, page, total) = (pagedObject.object, pagedObject.currentPage, pagedObject.pageCount) + + XCTAssertEqual(watchedShows.count, 10) + XCTAssertEqual(page, 1) + XCTAssertEqual(total, 4) + + let firstComment = try XCTUnwrap(watchedShows.first) + XCTAssertEqual(firstComment.comment, "I did NOT expect that ending. Wow! What a show!") + XCTAssertEqual(firstComment.parentId, 0) + } + // MARK: - Lists func test_get_lists_containing_show() throws { From d37329f36312c6f3fb31e1b99d83342d4e8e3edd Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 22 Feb 2025 09:43:55 -0500 Subject: [PATCH 11/38] Pass TraktManager to Route instead of using injection context, add movie endpoints I experimented with using a dependency injection context so I can set the TraktManager once and have access to the instance from Route without having to pass the TraktManager through each `Resource` class, but with Swift concurrency and tests I found this to be not too safe. You would expect that the `traktManager` instance you created a resource with would perform the request so I removed it. I am still working on the architecture for the resource classes. I was going to use static functions within a Resource for functions that didn't need a specific shared id for the requests, such as series id for shows, or a user id for User requests, but have moved to using a separate resource struct so I can pass in the TraktManager to those endpoints. It's better than having `traktManager.resource.endpoint(traktManager` or anything odd like that. --- Common/DependencyContainer.swift | 53 ---- Common/Extensions/URL+Extensions.swift | 39 +++ .../Models/Movies/TraktFavoritedMovie.swift | 16 + Common/Models/Shows/TraktFavoritedShow.swift | 16 + Common/Models/Shows/TraktTrendingShow.swift | 10 - Common/Wrapper/Comments.swift | 2 +- .../Wrapper/Resources/EpisodeResource.swift | 24 +- .../Wrapper/Resources/ExploreResource.swift | 94 ------ Common/Wrapper/Resources/MovieResource.swift | 92 ++++++ Common/Wrapper/Resources/SearchResource.swift | 12 +- Common/Wrapper/Resources/SeasonResource.swift | 15 +- Common/Wrapper/Resources/ShowResource.swift | 292 +++++++++--------- .../Resources/TraktManager+Resources.swift | 37 ++- Common/Wrapper/Resources/UserResource.swift | 33 +- Common/Wrapper/Route.swift | 19 +- Common/Wrapper/TraktManager.swift | 13 +- .../TraktKitExample/AppDelegate.swift | 33 +- .../QRCodeViewController.swift | 2 +- .../SearchResultsViewController.swift | 1 - .../TraktProfileViewController.swift | 2 - .../TraktKitExample/ViewController.swift | 2 - Tests/TraktKitTests/JSONEncodingTests.swift | 2 +- .../Shows/test_get_updated_shows_ids.json | 102 ++++++ Tests/TraktKitTests/MovieTests+Async.swift | 68 ++++ .../NetworkMocking/RequestMocking.swift | 66 ++-- Tests/TraktKitTests/ShowsTests.swift | 20 +- Tests/TraktKitTests/TraktManagerTests.swift | 42 ++- Tests/TraktKitTests/TraktTestCase.swift | 1 - Tests/TraktKitTests/TraktTestSuite.swift | 24 ++ 29 files changed, 671 insertions(+), 461 deletions(-) delete mode 100644 Common/DependencyContainer.swift create mode 100644 Common/Extensions/URL+Extensions.swift create mode 100644 Common/Models/Movies/TraktFavoritedMovie.swift create mode 100644 Common/Models/Shows/TraktFavoritedShow.swift delete mode 100644 Common/Wrapper/Resources/ExploreResource.swift create mode 100644 Tests/TraktKitTests/Models/Shows/test_get_updated_shows_ids.json create mode 100644 Tests/TraktKitTests/MovieTests+Async.swift create mode 100644 Tests/TraktKitTests/TraktTestSuite.swift diff --git a/Common/DependencyContainer.swift b/Common/DependencyContainer.swift deleted file mode 100644 index 28c25e0..0000000 --- a/Common/DependencyContainer.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// DependencyContainer.swift -// TraktKit -// -// Created by Maximilian Litteral on 2/9/25. -// - -import Foundation - -public final class DependencyContainer: @unchecked Sendable { - private static let lock = NSLock() - nonisolated(unsafe) private static var _shared: DependencyContainer? - - private let instanceLock = NSLock() - - public static var shared: DependencyContainer { - lock.withLock { - if _shared == nil { - _shared = DependencyContainer() - } - return _shared! - } - } - - // MARK: - Dependencies - private var _traktClient: TraktManager - - private init() { - self._traktClient = TraktManager.sharedManager - } - - // MARK: - Dependency Access - public var traktClient: TraktManager { - get { instanceLock.withLock { _traktClient } } - set { instanceLock.withLock { _traktClient = newValue } } - } - - // MARK: - Testing Support - public static func reset() { - lock.withLock { - _shared = DependencyContainer() - } - } -} - -@propertyWrapper -public struct InjectedClient { - public var wrappedValue: TraktManager { - DependencyContainer.shared.traktClient - } - - public init() { } -} diff --git a/Common/Extensions/URL+Extensions.swift b/Common/Extensions/URL+Extensions.swift new file mode 100644 index 0000000..a8b1778 --- /dev/null +++ b/Common/Extensions/URL+Extensions.swift @@ -0,0 +1,39 @@ +// +// URL+Extensions.swift +// TraktKit +// +// Created by Maximilian Litteral on 2/22/25. +// + +import Foundation + +extension URL { + func queryDict() -> [String: Any] { + var info: [String: Any] = [String: Any]() + if let queryString = self.query{ + for parameter in queryString.components(separatedBy: "&"){ + let parts = parameter.components(separatedBy: "=") + if parts.count > 1 { + let key = parts[0].removingPercentEncoding + let value = parts[1].removingPercentEncoding + if key != nil && value != nil{ + info[key!] = value + } + } + } + } + return info + } + + /// Compares components, which doesn't require query parameters to be in any particular order + public func compareComponents(_ url: URL) -> Bool { + guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false), + let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } + + return components.scheme == urlComponents.scheme && + components.host == urlComponents.host && + components.path == urlComponents.path && + components.queryItems?.enumerated().compactMap { $0.element.name }.sorted() == urlComponents.queryItems?.enumerated().compactMap { $0.element.name }.sorted() && + components.queryItems?.enumerated().compactMap { $0.element.value }.sorted() == urlComponents.queryItems?.enumerated().compactMap { $0.element.value }.sorted() + } +} diff --git a/Common/Models/Movies/TraktFavoritedMovie.swift b/Common/Models/Movies/TraktFavoritedMovie.swift new file mode 100644 index 0000000..a173e61 --- /dev/null +++ b/Common/Models/Movies/TraktFavoritedMovie.swift @@ -0,0 +1,16 @@ +// +// TraktFavoritedMovie.swift +// TraktKit +// +// Created by Maximilian Litteral on 2/22/25. +// + +public struct TraktFavoritedMovie: TraktObject { + public let userCount: Int + public let movie: TraktMovie + + enum CodingKeys: String, CodingKey { + case userCount = "user_count" + case movie + } +} diff --git a/Common/Models/Shows/TraktFavoritedShow.swift b/Common/Models/Shows/TraktFavoritedShow.swift new file mode 100644 index 0000000..d4f0e46 --- /dev/null +++ b/Common/Models/Shows/TraktFavoritedShow.swift @@ -0,0 +1,16 @@ +// +// TraktFavoritedShow.swift +// TraktKit +// +// Created by Maximilian Litteral on 2/22/25. +// + +public struct TraktFavoritedShow: TraktObject { + public let userCount: Int + public let show: TraktShow + + enum CodingKeys: String, CodingKey { + case userCount = "user_count" + case show + } +} diff --git a/Common/Models/Shows/TraktTrendingShow.swift b/Common/Models/Shows/TraktTrendingShow.swift index 59e903d..6eec167 100644 --- a/Common/Models/Shows/TraktTrendingShow.swift +++ b/Common/Models/Shows/TraktTrendingShow.swift @@ -12,13 +12,3 @@ public struct TraktTrendingShow: TraktObject { public let watchers: Int public let show: TraktShow } - -public struct TraktFavoritedShow: TraktObject { - public let userCount: Int - public let show: TraktShow - - enum CodingKeys: String, CodingKey { - case userCount = "user_count" - case show - } -} diff --git a/Common/Wrapper/Comments.swift b/Common/Wrapper/Comments.swift index 8ec4081..85723ce 100644 --- a/Common/Wrapper/Comments.swift +++ b/Common/Wrapper/Comments.swift @@ -48,7 +48,7 @@ extension TraktManager { withQuery: [:], isAuthorized: true, withHTTPMethod: .PUT) - request.httpBody = try jsonEncoder.encode(body) + request.httpBody = try Self.jsonEncoder.encode(body) return performRequest(request: request, completion: completion) } diff --git a/Common/Wrapper/Resources/EpisodeResource.swift b/Common/Wrapper/Resources/EpisodeResource.swift index 239dc1d..b1da2b6 100644 --- a/Common/Wrapper/Resources/EpisodeResource.swift +++ b/Common/Wrapper/Resources/EpisodeResource.swift @@ -13,12 +13,14 @@ public struct EpisodeResource { public let seasonNumber: Int public let episodeNumber: Int private let path: String - - init(showId: CustomStringConvertible, seasonNumber: Int, episodeNumber: Int) { + private let traktManager: TraktManager + + internal init(showId: CustomStringConvertible, seasonNumber: Int, episodeNumber: Int, traktManager: TraktManager) { self.showId = showId self.seasonNumber = seasonNumber self.episodeNumber = episodeNumber self.path = "shows/\(showId)/seasons/\(seasonNumber)/episodes/\(episodeNumber)" + self.traktManager = traktManager } /** @@ -27,7 +29,7 @@ public struct EpisodeResource { **Note**: If the `first_aired` is unknown, it will be set to `null`. */ public func summary() -> Route { - Route(path: path, method: .GET) + Route(path: path, method: .GET, traktManager: traktManager) } /** @@ -40,7 +42,7 @@ public struct EpisodeResource { if let language { path += "/\(language)" } - return Route(path: path, method: .GET) + return Route(path: path, method: .GET, traktManager: traktManager) } /** @@ -49,7 +51,7 @@ public struct EpisodeResource { 📄 Pagination */ public func comments() -> Route<[Comment]> { - Route(path: path + "/comments", method: .GET) + Route(path: path + "/comments", method: .GET, traktManager: traktManager) } /** @@ -58,14 +60,14 @@ public struct EpisodeResource { 📄 Pagination */ public func containingLists() -> Route<[TraktList]> { - Route(path: path + "/lists", method: .GET) + Route(path: path + "/lists", method: .GET, traktManager: traktManager) } /** Returns rating (between 0 and 10) and distribution for an episode. */ public func ratings() -> Route { - Route(path: path + "/ratings", method: .GET) + Route(path: path + "/ratings", method: .GET, traktManager: traktManager) } /** @@ -82,14 +84,14 @@ public struct EpisodeResource { ✨ Extended Info */ public func people() -> Route> { - Route(path: path + "/comments", method: .GET) + Route(path: path + "/comments", method: .GET, traktManager: traktManager) } /** Returns lots of episode stats. */ public func stats() -> Route { - Route(path: path + "/stats", method: .GET) + Route(path: path + "/stats", method: .GET, traktManager: traktManager) } /** @@ -97,7 +99,7 @@ public struct EpisodeResource { ✨ Extended Info */ public func usersWatching() -> Route<[User]> { - Route(path: path + "/watching", method: .GET) + Route(path: path + "/watching", method: .GET, traktManager: traktManager) } /** @@ -105,7 +107,7 @@ public struct EpisodeResource { ✨ Extended Info */ public func videos() -> Route<[TraktVideo]> { - Route(path: path + "/videos", method: .GET) + Route(path: path + "/videos", method: .GET, traktManager: traktManager) } } diff --git a/Common/Wrapper/Resources/ExploreResource.swift b/Common/Wrapper/Resources/ExploreResource.swift deleted file mode 100644 index ca67fc6..0000000 --- a/Common/Wrapper/Resources/ExploreResource.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// ExploreResource.swift -// TraktKit -// -// Created by Maxamilian Litteral on 6/14/21. -// Copyright © 2021 Maximilian Litteral. All rights reserved. -// - -import Foundation - -public struct ExploreResource: Sendable { - - // MARK: - Properties - - public let trending = Trending() - public let popular = Popular() - public let recommended = Recommended() - public let played = Played() - public let watched = Watched() - public let collected = Collected() - public let anticipated = Anticipated() - - // MARK: - Routes - - public struct Trending: Sendable { - public func shows() -> Route> { - Route(path: "shows/trending", method: .GET) - } - - public func movies() -> Route<[TraktTrendingMovie]> { - Route(path: "movies/trending", method: .GET) - } - } - - public struct Popular: Sendable { - public func shows() -> Route<[TraktShow]> { - Route(path: "shows/popular", method: .GET) - } - - public func movies() -> Route<[TraktMovie]> { - Route(path: "movies/popular", method: .GET) - } - } - - public struct Recommended: Sendable { - public func shows() -> Route<[TraktTrendingShow]> { - Route(path: "shows/recommended", method: .GET) - } - - public func movies() -> Route<[TraktTrendingMovie]> { - Route(path: "movies/recommended", method: .GET) - } - } - - public struct Played: Sendable { - public func shows() -> Route<[TraktMostShow]> { - Route(path: "shows/played", method: .GET) - } - - public func movies() -> Route<[TraktMostMovie]> { - Route(path: "movies/played", method: .GET) - } - } - - public struct Watched: Sendable { - public func shows() -> Route<[TraktMostShow]> { - Route(path: "shows/watched", method: .GET) - } - - public func movies() -> Route<[TraktMostMovie]> { - Route(path: "movies/watched", method: .GET) - } - } - - public struct Collected: Sendable { - public func shows() -> Route<[TraktTrendingShow]> { - Route(path: "shows/collected", method: .GET) - } - - public func movies() -> Route<[TraktTrendingMovie]> { - Route(path: "movies/collected", method: .GET) - } - } - - public struct Anticipated: Sendable { - public func shows() -> Route<[TraktAnticipatedShow]> { - Route(path: "shows/anticipated", method: .GET) - } - - public func movies() -> Route<[TraktAnticipatedMovie]> { - Route(path: "movies/anticipated", method: .GET) - } - } -} diff --git a/Common/Wrapper/Resources/MovieResource.swift b/Common/Wrapper/Resources/MovieResource.swift index e57d75d..3579b8b 100644 --- a/Common/Wrapper/Resources/MovieResource.swift +++ b/Common/Wrapper/Resources/MovieResource.swift @@ -9,6 +9,98 @@ import Foundation extension TraktManager { + /// Endpoints for movies in general + public struct MoviesResource { + private let traktManager: TraktManager + + internal init(traktManager: TraktManager) { + self.traktManager = traktManager + } + + /** + Returns all movies being watched right now. Movies with the most users are returned first. + */ + public func trending() -> Route> { + Route(path: "movies/trending", method: .GET, traktManager: traktManager) + } + + /** + Returns the most popular movies. Popularity is calculated using the rating percentage and the number of ratings. + */ + public func popular() -> Route> { + Route(path: "movies/popular", method: .GET, traktManager: traktManager) + } + + /** + Returns the most favorited movies in the specified time `period`, defaulting to `weekly`. All stats are relative to the specific time `period`. + */ + public func favorited(period: Period? = nil) -> Route> { + Route(paths: ["movies/favorited", period], method: .GET, traktManager: traktManager) + } + + /** + Returns the most played (a single user can watch multiple times) movies in the specified time `period`, defaulting to `weekly`. All stats are relative to the specific time `period`. + */ + public func played(period: Period? = nil) -> Route> { + Route(paths: ["movies/played", period], method: .GET, traktManager: traktManager) + } + + /** + Returns the most watched (unique users) movies in the specified time `period`, defaulting to `weekly`. All stats are relative to the specific time `period`. + */ + public func watched(period: Period? = nil) -> Route> { + Route(paths: ["movies/watched", period], method: .GET, traktManager: traktManager) + } + + /** + Returns the most collected (unique users) movies in the specified time `period`, defaulting to `weekly`. All stats are relative to the specific time `period`. + */ + public func collected(period: Period? = nil) -> Route> { + Route(paths: ["movies/collected", period], method: .GET, traktManager: traktManager) + } + + /** + Returns the most anticipated movies based on the number of lists a movie appears on. + */ + public func anticipated() -> Route> { + Route(path: "movies/anticipated", method: .GET, traktManager: traktManager) + } + + /** + Returns the top 10 grossing movies in the U.S. box office last weekend. Updated every Monday morning. + */ + public func boxOffice() -> Route<[TraktBoxOfficeMovie]> { + Route(path: "movies/boxoffice", method: .GET, traktManager: traktManager) + } + + /** + Returns all movies updated since the specified date. We recommended storing the date you can be efficient using this method moving forward. + + 📄 Pagination + + > important: The `startDate` is only accurate to the hour, for caching purposes. Please drop the minutes and seconds from your timestamp to help optimize our cached data. For example, use `2021-07-17T12:00:00Z` and not `2021-07-17T12:23:34Z` + + > note: .The `startDate` can only be a maximum of 30 days in the past. + */ + public func recentlyUpdated(since startDate: Date) async throws -> Route> { + let formattedDate = startDate.dateString(withFormat: "yyyy-MM-dd'T'HH:mm:ss") + return Route(path: "movies/updates/\(formattedDate)", method: .GET, traktManager: traktManager) + } + + /** + Returns all movie Trakt IDs updated since the specified UTC date and time. We recommended storing the X-Start-Date header you can be efficient using this method moving forward. By default, 10 results are returned. You can send a limit to get up to 100 results per page. + + 📄 Pagination + + > important: The `startDate` is only accurate to the hour, for caching purposes. Please drop the minutes and seconds from your timestamp to help optimize our cached data. For example, use `2021-07-17T12:00:00Z` and not `2021-07-17T12:23:34Z` + + > note: .The `startDate` can only be a maximum of 30 days in the past. + */ + public func recentlyUpdatedIds(since startDate: Date) async throws -> Route> { + let formattedDate = startDate.dateString(withFormat: "yyyy-MM-dd'T'HH:mm:ss") + return Route(path: "movies/updates/id/\(formattedDate)", method: .GET, traktManager: traktManager) + } + } public struct MovieResource { diff --git a/Common/Wrapper/Resources/SearchResource.swift b/Common/Wrapper/Resources/SearchResource.swift index a15e057..59943e9 100644 --- a/Common/Wrapper/Resources/SearchResource.swift +++ b/Common/Wrapper/Resources/SearchResource.swift @@ -8,7 +8,13 @@ import Foundation public struct SearchResource { - + + private let traktManager: TraktManager + + internal init(traktManager: TraktManager) { + self.traktManager = traktManager + } + // MARK: - Actions public func search( @@ -16,10 +22,10 @@ public struct SearchResource { types: [SearchType]// = [.movie, .show, .episode, .person, .list] ) -> Route<[TraktSearchResult]> { let searchTypes = types.map { $0.rawValue }.joined(separator: ",") - return Route(path: "search/\(searchTypes)", method: .GET).query(query) + return Route(path: "search/\(searchTypes)", method: .GET, traktManager: traktManager).query(query) } public func lookup(_ id: LookupType) -> Route<[TraktSearchResult]> { - Route(path: "search/\(id.name)/\(id.id)", method: .GET) + Route(path: "search/\(id.name)/\(id.id)", method: .GET, traktManager: traktManager) } } diff --git a/Common/Wrapper/Resources/SeasonResource.swift b/Common/Wrapper/Resources/SeasonResource.swift index f1bcc8d..262c445 100644 --- a/Common/Wrapper/Resources/SeasonResource.swift +++ b/Common/Wrapper/Resources/SeasonResource.swift @@ -12,11 +12,13 @@ public struct SeasonResource { public let showId: CustomStringConvertible public let seasonNumber: Int private let path: String + private let traktManager: TraktManager - init(showId: CustomStringConvertible, seasonNumber: Int) { + internal init(showId: CustomStringConvertible, seasonNumber: Int, traktManager: TraktManager) { self.showId = showId self.seasonNumber = seasonNumber self.path = "shows/\(showId)/seasons/\(seasonNumber)" + self.traktManager = traktManager } // MARK: - Methods @@ -27,7 +29,7 @@ public struct SeasonResource { ✨ Extended Info */ public func info() -> Route { - Route(path: "\(path)/info", method: .GET) + Route(path: "\(path)/info", method: .GET, traktManager: traktManager) } /** @@ -42,14 +44,14 @@ public struct SeasonResource { ✨ Extended Info */ public func episodes() -> Route<[TraktEpisode]> { - Route(path: "\(path)", method: .GET) + Route(path: "\(path)", method: .GET, traktManager: traktManager) } /** Returns all translations for an season, including language and translated values for title and overview. */ public func translations(language: String) -> Route<[TraktSeasonTranslation]> { - Route(path: "\(path)/translations/\(language)", method: .GET) + Route(path: "\(path)/translations/\(language)", method: .GET, traktManager: traktManager) } /** @@ -60,7 +62,7 @@ public struct SeasonResource { 🔓 OAuth Optional 📄 Pagination 😁 Emojis */ public func comments() -> Route<[Comment]> { - Route(path: "\(path)/comments", method: .GET) + Route(path: "\(path)/comments", method: .GET, traktManager: traktManager) } // MARK: - Resources @@ -69,7 +71,8 @@ public struct SeasonResource { EpisodeResource( showId: showId, seasonNumber: seasonNumber, - episodeNumber: number + episodeNumber: number, + traktManager: traktManager ) } } diff --git a/Common/Wrapper/Resources/ShowResource.swift b/Common/Wrapper/Resources/ShowResource.swift index 691d2d3..fba3418 100644 --- a/Common/Wrapper/Resources/ShowResource.swift +++ b/Common/Wrapper/Resources/ShowResource.swift @@ -8,162 +8,176 @@ import Foundation -public struct ShowResource { +extension TraktManager { + /// Endpoints for shows in general + public struct ShowsResource { + private let traktManager: TraktManager + + internal init(traktManager: TraktManager) { + self.traktManager = traktManager + } + + /** + Returns all shows being watched right now. Shows with the most users are returned first. + */ + public func trending() -> Route> { + Route(path: "shows/trending", method: .GET, traktManager: traktManager) + } + + /** + Returns the most popular shows. Popularity is calculated using the rating percentage and the number of ratings. + */ + public func popular() -> Route> { + Route(path: "shows/popular", method: .GET, traktManager: traktManager) + } + + /** + Returns the most favorited shows in the specified time `period`, defaulting to `weekly`. All stats are relative to the specific time `period`. + */ + public func favorited(period: Period? = nil) -> Route> { + Route(paths: ["shows/favorited", period], method: .GET, traktManager: traktManager) + } + + /** + Returns the most played (a single user can watch multiple episodes multiple times) shows in the specified time `period`, defaulting to `weekly`. All stats are relative to the specific time `period`. + */ + public func played(period: Period? = nil) -> Route> { + Route(paths: ["shows/played", period], method: .GET, traktManager: traktManager) + } + + /** + Returns the most watched (unique users) shows in the specified time `period`, defaulting to `weekly`. All stats are relative to the specific time `period`. + */ + public func watched(period: Period? = nil) -> Route> { + Route(paths: ["shows/watched", period], method: .GET, traktManager: traktManager) + } + + /** + Returns the most collected (unique users) shows in the specified time `period`, defaulting to `weekly`. All stats are relative to the specific time `period`. + */ + public func collected(period: Period? = nil) -> Route> { + Route(paths: ["shows/collected", period], method: .GET, traktManager: traktManager) + } + + /** + Returns the most anticipated shows based on the number of lists a show appears on. + */ + public func anticipated() -> Route> { + Route(path: "shows/anticipated", method: .GET, traktManager: traktManager) + } + + /** + Returns all shows updated since the specified date. We recommended storing the date you can be efficient using this method moving forward. + + 📄 Pagination + + > important: The `startDate` is only accurate to the hour, for caching purposes. Please drop the minutes and seconds from your timestamp to help optimize our cached data. For example, use `2021-07-17T12:00:00Z` and not `2021-07-17T12:23:34Z` + + > note: .The `startDate` can only be a maximum of 30 days in the past. + */ + public func recentlyUpdated(since startDate: Date) async throws -> Route> { + let formattedDate = startDate.dateString(withFormat: "yyyy-MM-dd'T'HH:mm:ss") + return Route(path: "shows/updates/\(formattedDate)", method: .GET, traktManager: traktManager) + } + + /** + Returns all show Trakt IDs updated since the specified UTC date and time. We recommended storing the X-Start-Date header you can be efficient using this method moving forward. By default, 10 results are returned. You can send a limit to get up to 100 results per page. + + 📄 Pagination + + > important: The `startDate` is only accurate to the hour, for caching purposes. Please drop the minutes and seconds from your timestamp to help optimize our cached data. For example, use `2021-07-17T12:00:00Z` and not `2021-07-17T12:23:34Z` + + > note: .The `startDate` can only be a maximum of 30 days in the past. + */ + public func recentlyUpdatedIds(since startDate: Date) async throws -> Route> { + let formattedDate = startDate.dateString(withFormat: "yyyy-MM-dd'T'HH:mm:ss") + return Route(path: "shows/updates/id/\(formattedDate)", method: .GET, traktManager: traktManager) + } + } + + /// Endpoints for a specific series + public struct ShowResource { + + // MARK: - Properties + + /// Trakt ID, Trakt slug, or IMDB ID + internal let id: CustomStringConvertible + + private let traktManager: TraktManager + + // MARK: - Lifecycle + + internal init(id: CustomStringConvertible, traktManager: TraktManager) { + self.id = id + self.traktManager = traktManager + } - // MARK: - Static (Non-specific show endpoints) + // MARK: - Methods - /** - Returns all shows being watched right now. Shows with the most users are returned first. - */ - public static func trending() -> Route> { - Route(path: "shows/trending", method: .GET) - } - - /** - Returns the most popular shows. Popularity is calculated using the rating percentage and the number of ratings. - */ - public static func popular() -> Route> { - Route(path: "shows/trending", method: .GET) - } - - /** - Returns the most favorited shows in the specified time `period`, defaulting to `weekly`. All stats are relative to the specific time `period`. - */ - public static func favorited(period: Period? = nil) -> Route> { - Route(paths: ["shows/favorited", period], method: .GET) - } - - /** - Returns the most played (a single user can watch multiple episodes multiple times) shows in the specified time `period`, defaulting to `weekly`. All stats are relative to the specific time `period`. - */ - public static func played(period: Period? = nil) -> Route> { - Route(paths: ["shows/played", period], method: .GET) - } - - /** - Returns the most watched (unique users) shows in the specified time `period`, defaulting to `weekly`. All stats are relative to the specific time `period`. - */ - public static func watched(period: Period? = nil) -> Route> { - Route(paths: ["shows/watched", period], method: .GET) - } + /** + Returns a single shows's details. If you request extended info, the `airs` object is relative to the show's country. You can use the `day`, `time`, and `timezone` to construct your own date then convert it to whatever timezone your user is in. + */ + public func summary() -> Route { + Route(path: "shows/\(id)", method: .GET, traktManager: traktManager) + } - /** - Returns the most collected (unique users) shows in the specified time `period`, defaulting to `weekly`. All stats are relative to the specific time `period`. - */ - public static func collected(period: Period? = nil) -> Route> { - Route(paths: ["shows/collected", period], method: .GET) - } + /** + Returns all title aliases for a show. Includes country where name is different. + */ + public func aliases() -> Route<[Alias]> { + Route(path: "shows/\(id)/aliases", method: .GET, traktManager: traktManager) + } - /** - Returns the most anticipated shows based on the number of lists a show appears on. - */ - public static func anticipated() -> Route> { - Route(path: "shows/anticipated", method: .GET) - } + /** + Returns all content certifications for a show, including the country. + */ + public func certifications() -> Route { + Route(path: "shows/\(id)/certifications", method: .GET, traktManager: traktManager) + } - /** - Returns all shows updated since the specified date. We recommended storing the date you can be efficient using this method moving forward. + /** + Returns all translations for a show, including language and translated values for title and overview. - 📄 Pagination + - parameter language: 2 character language code Example: `es` + */ + public func translations(language: String? = nil) -> Route<[TraktShowTranslation]> { + Route(paths: ["shows/\(id)/translations", language], method: .GET, traktManager: traktManager) + } - > important: The `startDate` is only accurate to the hour, for caching purposes. Please drop the minutes and seconds from your timestamp to help optimize our cached data. For example, use `2021-07-17T12:00:00Z` and not `2021-07-17T12:23:34Z` + /** + Returns all top level comments for a show. By default, the `newest` comments are returned first. Other sorting options include `oldest`, most `likes`, most `replies`, `highest` rated, `lowest` rated, most `plays`, and highest `watched` percentage. - > note: .The `startDate` can only be a maximum of 30 days in the past. - */ - public static func recentlyUpdated(since startDate: Date) async throws -> Route> { - let formattedDate = startDate.dateString(withFormat: "yyyy-MM-dd'T'HH:mm:ss") - return Route(path: "shows/updates/\(formattedDate)", method: .GET) - } + 🔓 OAuth Optional 📄 Pagination 😁 Emojis - /** - Returns all show Trakt IDs updated since the specified UTC date and time. We recommended storing the X-Start-Date header you can be efficient using this method moving forward. By default, 10 results are returned. You can send a limit to get up to 100 results per page. + > note: If you send OAuth, comments from blocked users will be automatically filtered out. - 📄 Pagination + - parameter sort: how to sort Example: `newest`. + - parameter authenticate: comments from blocked users will be automatically filtered out if `true`. + */ + public func comments(sort: String? = nil, authenticate: Bool = false) -> Route> { + Route(paths: ["shows/\(id)/comments", sort], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) + } - > important: The `startDate` is only accurate to the hour, for caching purposes. Please drop the minutes and seconds from your timestamp to help optimize our cached data. For example, use `2021-07-17T12:00:00Z` and not `2021-07-17T12:23:34Z` + /** + Returns all seasons for a show including the number of episodes in each season. - > note: .The `startDate` can only be a maximum of 30 days in the past. - */ - public static func recentlyUpdatedIds(since startDate: Date) async throws -> Route> { - let formattedDate = startDate.dateString(withFormat: "yyyy-MM-dd'T'HH:mm:ss") - return Route(path: "shows/updates/id\(formattedDate)", method: .GET) - } + **Episodes** - // MARK: - Properties + If you add `?extended=episodes` to the URL, it will return all episodes for all seasons. - /// Trakt ID, Trakt slug, or IMDB ID - public let id: CustomStringConvertible + > note: This returns a lot of data, so please only use this extended parameter if you actually need it! - // MARK: - Lifecycle + ✨ Extended Info + */ + public func seasons() -> Route<[TraktSeason]> { + Route(path: "shows/\(id)/seasons", method: .GET, traktManager: traktManager) + } - public init(id: CustomStringConvertible) { - self.id = id - } + // MARK: - Resources - // MARK: - Methods - - /** - Returns a single shows's details. If you request extended info, the `airs` object is relative to the show's country. You can use the `day`, `time`, and `timezone` to construct your own date then convert it to whatever timezone your user is in. - */ - public func summary() -> Route { - Route(path: "shows/\(id)", method: .GET) - } - - /** - Returns all title aliases for a show. Includes country where name is different. - */ - public func aliases() -> Route<[Alias]> { - Route(path: "shows/\(id)/aliases", method: .GET) + public func season(_ number: Int) -> SeasonResource { + SeasonResource(showId: id, seasonNumber: number, traktManager: traktManager) + } } - /** - Returns all content certifications for a show, including the country. - */ - public func certifications() -> Route { - Route(path: "shows/\(id)/certifications", method: .GET) - } - - /** - Returns all translations for a show, including language and translated values for title and overview. - - - parameter language: 2 character language code Example: `es` - */ - public func translations(language: String? = nil) -> Route<[TraktShowTranslation]> { - Route(paths: ["shows/\(id)/translations", language], method: .GET) - } - - /** - Returns all top level comments for a show. By default, the `newest` comments are returned first. Other sorting options include `oldest`, most `likes`, most `replies`, `highest` rated, `lowest` rated, most `plays`, and highest `watched` percentage. - - 🔓 OAuth Optional 📄 Pagination 😁 Emojis - - > note: If you send OAuth, comments from blocked users will be automatically filtered out. - - - parameter sort: how to sort Example: `newest`. - - parameter authenticate: comments from blocked users will be automatically filtered out if `true`. - */ - public func comments(sort: String? = nil, authenticate: Bool = false) -> Route> { - Route(paths: ["shows/\(id)/comments", sort], method: .GET, requiresAuthentication: authenticate) - } - - /** - Returns all seasons for a show including the number of episodes in each season. - - **Episodes** - - If you add `?extended=episodes` to the URL, it will return all episodes for all seasons. - - > note: This returns a lot of data, so please only use this extended parameter if you actually need it! - - ✨ Extended Info - */ - public func seasons() -> Route<[TraktSeason]> { - Route(path: "shows/\(id)/seasons", method: .GET) - } - - // MARK: - Resources - - public func season(_ number: Int) -> SeasonResource { - SeasonResource(showId: id, seasonNumber: number) - } } diff --git a/Common/Wrapper/Resources/TraktManager+Resources.swift b/Common/Wrapper/Resources/TraktManager+Resources.swift index afb85cf..8c844a1 100644 --- a/Common/Wrapper/Resources/TraktManager+Resources.swift +++ b/Common/Wrapper/Resources/TraktManager+Resources.swift @@ -10,31 +10,44 @@ import Foundation extension TraktManager { - public func shows() -> ShowResource.Type { - ShowResource.self + // MARK: - Search + + public func search() -> SearchResource { + SearchResource(traktManager: self) + } + + // MARK: - Movies + + public var movies: MoviesResource { + MoviesResource(traktManager: self) + } + + // MARK: - TV + + public var shows: ShowsResource { + ShowsResource(traktManager: self) } + /// - parameter id: Trakt ID, Trakt slug, or IMDB ID public func show(id: CustomStringConvertible) -> ShowResource { - ShowResource(id: id) + ShowResource(id: id, traktManager: self) } public func season(showId: CustomStringConvertible, season: Int) -> SeasonResource { - SeasonResource(showId: showId, seasonNumber: season) + SeasonResource(showId: showId, seasonNumber: season, traktManager: self) } public func episode(showId: CustomStringConvertible, season: Int, episode: Int) -> EpisodeResource { - EpisodeResource(showId: showId, seasonNumber: season, episodeNumber: episode) + EpisodeResource(showId: showId, seasonNumber: season, episodeNumber: episode, traktManager: self) } - + + // MARK: - User + public func currentUser() -> CurrentUserResource { - CurrentUserResource() + CurrentUserResource(traktManager: self) } public func user(_ username: String) -> UsersResource { - UsersResource(username: username) - } - - public func search() -> SearchResource { - SearchResource() + UsersResource(username: username, traktManager: self) } } diff --git a/Common/Wrapper/Resources/UserResource.swift b/Common/Wrapper/Resources/UserResource.swift index b1fd446..16233e7 100644 --- a/Common/Wrapper/Resources/UserResource.swift +++ b/Common/Wrapper/Resources/UserResource.swift @@ -10,22 +10,29 @@ import Foundation extension TraktManager { /// Resource for authenticated user public struct CurrentUserResource { + + private let traktManager: TraktManager + + internal init(traktManager: TraktManager) { + self.traktManager = traktManager + } + // MARK: - Methods public func settings() -> Route { - Route(path: "users/settings", method: .GET, requiresAuthentication: true) + Route(path: "users/settings", method: .GET, requiresAuthentication: true, traktManager: traktManager) } // MARK: Following Requests /// List a user's pending following requests that they're waiting for the other user's to approve. public func getPendingFollowingRequests() -> Route<[FollowRequest]> { - Route(path: "users/requests/following", method: .GET, requiresAuthentication: true) + Route(path: "users/requests/following", method: .GET, requiresAuthentication: true, traktManager: traktManager) } /// List a user's pending follow requests so they can either approve or deny them. public func getFollowerRequests() -> Route<[FollowRequest]> { - Route(path: "users/requests", method: .GET, requiresAuthentication: true) + Route(path: "users/requests", method: .GET, requiresAuthentication: true, traktManager: traktManager) } /** @@ -34,7 +41,7 @@ extension TraktManager { 🔒 OAuth Required */ public func approveFollowRequest(id: Int) -> Route { - Route(path: "users/requests/\(id)", method: .POST, requiresAuthentication: true) + Route(path: "users/requests/\(id)", method: .POST, requiresAuthentication: true, traktManager: traktManager) } /** @@ -43,7 +50,7 @@ extension TraktManager { 🔒 OAuth Required */ public func denyFollowRequest(id: Int) -> EmptyRoute { - EmptyRoute(path: "users/requests/\(id)", method: .DELETE, requiresAuthentication: true) + EmptyRoute(path: "users/requests/\(id)", method: .DELETE, requiresAuthentication: true, traktManager: traktManager) } /** @@ -55,16 +62,18 @@ extension TraktManager { */ public func savedFilters(for section: String? = nil) -> Route<[SavedFilter]> { let path = ["users/saved_filters", section].compactMap { $0 }.joined(separator: "/") - return Route(path: path, method: .POST, requiresAuthentication: true) + return Route(path: path, method: .POST, requiresAuthentication: true, traktManager: traktManager) } } /// Resource for /Users/id public struct UsersResource { public let username: String - - public init(username: String) { + private let traktManager: TraktManager + + internal init(username: String, traktManager: TraktManager) { self.username = username + self.traktManager = traktManager } // MARK: - Methods @@ -72,15 +81,11 @@ extension TraktManager { // MARK: Settings public func lists() -> Route<[TraktList]> { - Route(path: "users/\(username)/lists", method: .GET) + Route(path: "users/\(username)/lists", method: .GET, traktManager: traktManager) } public func itemsOnList(_ listId: String, type: ListItemType? = nil) -> Route<[TraktListItem]> { - if let type = type { - return Route(path: "users/\(username)/lists/\(listId)/items/\(type.rawValue)", method: .GET) - } else { - return Route(path: "users/\(username)/lists/\(listId)/items", method: .GET) - } + Route(paths: ["users/\(username)/lists/\(listId)/items", type?.rawValue], method: .GET, traktManager: traktManager) } } } diff --git a/Common/Wrapper/Route.swift b/Common/Wrapper/Route.swift index ac9e7b0..d253c1a 100644 --- a/Common/Wrapper/Route.swift +++ b/Common/Wrapper/Route.swift @@ -13,10 +13,11 @@ public struct Route: Sendable { // MARK: - Properties private let resultType: T.Type + private let traktManager: TraktManager - public var path: String - public let method: Method - public let requiresAuthentication: Bool + internal var path: String + internal let method: Method + internal let requiresAuthentication: Bool private var extended = [ExtendedType]() private var page: Int? @@ -28,18 +29,20 @@ public struct Route: Sendable { // MARK: - Lifecycle - public init(path: String, method: Method, requiresAuthentication: Bool = false, resultType: T.Type = T.self) { + public init(path: String, method: Method, requiresAuthentication: Bool = false, resultType: T.Type = T.self, traktManager: TraktManager) { self.path = path self.method = method self.requiresAuthentication = requiresAuthentication self.resultType = resultType + self.traktManager = traktManager } - public init(paths: [CustomStringConvertible?], method: Method, requiresAuthentication: Bool = false, resultType: T.Type = T.self) { + public init(paths: [CustomStringConvertible?], method: Method, requiresAuthentication: Bool = false, resultType: T.Type = T.self, traktManager: TraktManager) { self.path = paths.compactMap { $0?.description }.joined(separator: "/") self.method = method self.requiresAuthentication = requiresAuthentication self.resultType = resultType + self.traktManager = traktManager } // MARK: - Actions @@ -89,7 +92,6 @@ public struct Route: Sendable { // MARK: - Perform public func perform() async throws -> T { - @InjectedClient var traktManager let request = try makeRequest(traktManager: traktManager) return try await traktManager.perform(request: request) } @@ -140,19 +142,20 @@ public struct EmptyRoute: Sendable { internal var path: String internal let method: Method internal let requiresAuthentication: Bool + private let traktManager: TraktManager // MARK: - Lifecycle - public init(path: String, method: Method, requiresAuthentication: Bool = false) { + public init(path: String, method: Method, requiresAuthentication: Bool = false, traktManager: TraktManager) { self.path = path self.method = method self.requiresAuthentication = requiresAuthentication + self.traktManager = traktManager } // MARK: - Perform public func perform() async throws { - @InjectedClient var traktManager let request = try traktManager.mutableRequest( forPath: path, withQuery: [:], diff --git a/Common/Wrapper/TraktManager.swift b/Common/Wrapper/TraktManager.swift index ca0d997..0705958 100644 --- a/Common/Wrapper/TraktManager.swift +++ b/Common/Wrapper/TraktManager.swift @@ -14,11 +14,6 @@ public extension Notification.Name { } public final class TraktManager: Sendable { -//public final class TraktManager: @unchecked Sendable { - - // TODO List: - // 1. Create a limit object, double check every paginated API call is marked as paginated - // 2. Call completion with custom error when creating request fails // MARK: - Types @@ -86,7 +81,7 @@ public final class TraktManager: Sendable { private let redirectURI: String private let apiHost: String - internal let jsonEncoder: JSONEncoder = { + internal static let jsonEncoder: JSONEncoder = { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 return encoder @@ -94,12 +89,8 @@ public final class TraktManager: Sendable { let session: URLSession - public let explore = ExploreResource() - // MARK: Public - public static let sharedManager = TraktManager(clientId: "", clientSecret: "", redirectURI: "") - public var isSignedIn: Bool { get { return accessToken != nil @@ -297,7 +288,7 @@ public final class TraktManager: Sendable { } do { - request.httpBody = try jsonEncoder.encode(body) + request.httpBody = try Self.jsonEncoder.encode(body) } catch { return nil } diff --git a/Example/TraktKitExample/TraktKitExample/AppDelegate.swift b/Example/TraktKitExample/TraktKitExample/AppDelegate.swift index a1d6191..863c990 100644 --- a/Example/TraktKitExample/TraktKitExample/AppDelegate.swift +++ b/Example/TraktKitExample/TraktKitExample/AppDelegate.swift @@ -13,6 +13,12 @@ extension Notification.Name { static let TraktSignedIn = Notification.Name(rawValue: "TraktSignedIn") } +let traktManager = TraktManager( + clientId: Constants.clientId, + clientSecret: Constants.clientSecret, + redirectURI: Constants.redirectURI +) + @main class AppDelegate: UIResponder, UIApplicationDelegate { @@ -25,19 +31,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Get keys from https://trakt.tv/oauth/applications } - let traktManager = TraktManager( - clientId: Constants.clientId, - clientSecret: Constants.clientSecret, - redirectURI: Constants.redirectURI - ) - var window: UIWindow? // MARK: - Lifecycle func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. - DependencyContainer.shared.traktClient = traktManager return true } @@ -62,23 +61,3 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } } - -extension URL { - func queryDict() -> [String: Any] { - var info: [String: Any] = [String: Any]() - if let queryString = self.query{ - for parameter in queryString.components(separatedBy: "&"){ - let parts = parameter.components(separatedBy: "=") - if parts.count > 1 { - let key = parts[0].removingPercentEncoding - let value = parts[1].removingPercentEncoding - if key != nil && value != nil{ - info[key!] = value - } - } - } - } - return info - } -} - diff --git a/Example/TraktKitExample/TraktKitExample/QRCodeViewController.swift b/Example/TraktKitExample/TraktKitExample/QRCodeViewController.swift index 682fd75..8e4db26 100644 --- a/Example/TraktKitExample/TraktKitExample/QRCodeViewController.swift +++ b/Example/TraktKitExample/TraktKitExample/QRCodeViewController.swift @@ -30,7 +30,7 @@ final class QRCodeViewController: UIViewController { private func start() { - TraktManager.sharedManager.getTokenFromDevice(code: self.data) { result in + traktManager.getTokenFromDevice(code: self.data) { result in switch result { case .fail(let progress): print(progress) diff --git a/Example/TraktKitExample/TraktKitExample/SearchResultsViewController.swift b/Example/TraktKitExample/TraktKitExample/SearchResultsViewController.swift index e2f262f..705ea4c 100644 --- a/Example/TraktKitExample/TraktKitExample/SearchResultsViewController.swift +++ b/Example/TraktKitExample/TraktKitExample/SearchResultsViewController.swift @@ -12,7 +12,6 @@ import TraktKit final class SearchResultsViewController: UITableViewController { // MARK: - Properties - @InjectedClient var traktManager private var shows: [TraktShow] = [] { didSet { diff --git a/Example/TraktKitExample/TraktKitExample/TraktProfileViewController.swift b/Example/TraktKitExample/TraktKitExample/TraktProfileViewController.swift index 8af25ca..f2cb34c 100644 --- a/Example/TraktKitExample/TraktKitExample/TraktProfileViewController.swift +++ b/Example/TraktKitExample/TraktKitExample/TraktProfileViewController.swift @@ -13,8 +13,6 @@ final class TraktProfileViewController: UIViewController { // MARK: - Properties - @InjectedClient var traktManager - private let stackView = UIStackView() // MARK: - Lifecycle diff --git a/Example/TraktKitExample/TraktKitExample/ViewController.swift b/Example/TraktKitExample/TraktKitExample/ViewController.swift index 2525234..18824de 100644 --- a/Example/TraktKitExample/TraktKitExample/ViewController.swift +++ b/Example/TraktKitExample/TraktKitExample/ViewController.swift @@ -15,8 +15,6 @@ final class ViewController: UIViewController { // MARK: - Properties - @InjectedClient var traktManager - private let stackView = UIStackView() private var cancellables: Set = [] diff --git a/Tests/TraktKitTests/JSONEncodingTests.swift b/Tests/TraktKitTests/JSONEncodingTests.swift index b9a4667..6919e0b 100644 --- a/Tests/TraktKitTests/JSONEncodingTests.swift +++ b/Tests/TraktKitTests/JSONEncodingTests.swift @@ -12,7 +12,7 @@ import Foundation class JSONEncodingTests: XCTestCase { - let jsonEncoder = TraktManager.sharedManager.jsonEncoder + let jsonEncoder = TraktManager.jsonEncoder func testEncodeShowIds() { let expectation: RawJSON = [ diff --git a/Tests/TraktKitTests/Models/Shows/test_get_updated_shows_ids.json b/Tests/TraktKitTests/Models/Shows/test_get_updated_shows_ids.json new file mode 100644 index 0000000..c318b3b --- /dev/null +++ b/Tests/TraktKitTests/Models/Shows/test_get_updated_shows_ids.json @@ -0,0 +1,102 @@ +[ + 163302, + 141682, + 75637, + 137146, + 94613, + 42226, + 67906, + 81603, + 23127, + 180078, + 134744, + 78042, + 99549, + 101654, + 111152, + 76671, + 41255, + 144176, + 67900, + 111213, + 100663, + 75726, + 61260, + 80748, + 129956, + 38193, + 81906, + 106006, + 168241, + 164611, + 157261, + 116479, + 143856, + 147412, + 158323, + 130803, + 158563, + 117446, + 101639, + 69280, + 71290, + 116062, + 144753, + 67830, + 63565, + 69478, + 80053, + 96483, + 78893, + 130908, + 67773, + 116954, + 81406, + 122373, + 38484, + 124061, + 62128, + 79998, + 143775, + 148102, + 80388, + 93710, + 100429, + 77700, + 19623, + 178721, + 65855, + 100845, + 62327, + 98820, + 43377, + 166750, + 116158, + 143322, + 63922, + 103059, + 118896, + 123430, + 180780, + 124129, + 79975, + 104239, + 97100, + 81057, + 94342, + 61706, + 98265, + 69597, + 103925, + 61887, + 78471, + 163918, + 190248, + 126001, + 69694, + 82201, + 66245, + 82028, + 64187, + 123253 +] diff --git a/Tests/TraktKitTests/MovieTests+Async.swift b/Tests/TraktKitTests/MovieTests+Async.swift new file mode 100644 index 0000000..76993f9 --- /dev/null +++ b/Tests/TraktKitTests/MovieTests+Async.swift @@ -0,0 +1,68 @@ +// +// MovieTests+Async.swift +// TraktKit +// +// Created by Maximilian Litteral on 2/22/25. +// + +import Testing + +extension TraktTestSuite { + @Suite(.serialized) + struct MovieTestSuite { + @Test func getTrendingMovies() async throws { + try mock(.GET, "https://api.trakt.tv/movies/trending?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_trending_movies")), headers: [.page(1), .pageCount(10)]) + + let result = try await traktManager.movies + .trending() + .extend(.Min) + .page(1) + .limit(10) + .perform() + let (movies, currentPage, pageCount) = (result.object, result.currentPage, result.pageCount) + #expect(movies.count == 2) + #expect(currentPage == 1) + #expect(pageCount == 10) + } + + @Test func getPopularMovies() async throws { + try mock(.GET, "https://api.trakt.tv/movies/popular?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_popular_movies"))) + + let result = try await traktManager.movies + .popular() + .extend(.Min) + .page(1) + .limit(10) + .perform() + let (movies, _, _) = (result.object, result.currentPage, result.pageCount) + #expect(movies.count == 10) + } + + @Test func getMostPlayedMovies() async throws { + try mock(.GET, "https://api.trakt.tv/movies/played/all?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_most_played_movies"))) + + let result = try await traktManager.movies + .played(period: .all) + .extend(.Min) + .page(1) + .limit(10) + .perform() + let (movies, _, _) = (result.object, result.currentPage, result.pageCount) + #expect(movies.count == 10) + } + + @Test func getMostWatchedMovies() async throws { + try mock(.GET, "https://api.trakt.tv/movies/watched/all?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_most_watched_movies"))) + + let result = try await traktManager.movies + .watched(period: .all) + .extend(.Min) + .page(1) + .limit(10) + .perform() + let (movies, _, _) = (result.object, result.currentPage, result.pageCount) + #expect(movies.count == 10) + } + + } +} diff --git a/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift b/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift index ef251c9..27e46fd 100644 --- a/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift +++ b/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift @@ -18,10 +18,39 @@ extension URLSession { } } +extension RequestMocking { + private final class MocksContainer: @unchecked Sendable { + var mocks: [MockedResponse] = [] + } -final class RequestMocking: URLProtocol, @unchecked Sendable { - static nonisolated(unsafe) private var mocks: [MockedResponse] = [] + static private let container = MocksContainer() + static private let lock = NSLock() + + static func add(mock: MockedResponse) { + lock.withLock { + container.mocks.append(mock) + } + } + + static func removeAllMocks() { + lock.withLock { + container.mocks.removeAll() + } + } + + static private func mock(for request: URLRequest) -> MockedResponse? { + return lock.withLock { + container.mocks.first { mock in + guard let url = request.url else { return false } + return mock.url.compareComponents(url) + } + } + } +} + +// MARK: - RequestMocking +final class RequestMocking: URLProtocol, @unchecked Sendable { override class func canInit(with request: URLRequest) -> Bool { return mock(for: request) != nil } @@ -61,39 +90,6 @@ final class RequestMocking: URLProtocol, @unchecked Sendable { override func stopLoading() { } } -// MARK: - Helpers - -extension RequestMocking { - static func add(mock: MockedResponse) { - mocks.append(mock) - } - - static func removeAllMocks() { - mocks.removeAll() - } - - static private func mock(for request: URLRequest) -> MockedResponse? { - mocks.first { mock in - guard let url = request.url else { return false } - return mock.url.compareComponents(url) - } - } -} - -extension URL { - /// Compares components, which doesn't require query parameters to be in any particular order - public func compareComponents(_ url: URL) -> Bool { - guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false), - let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } - - return components.scheme == urlComponents.scheme && - components.host == urlComponents.host && - components.path == urlComponents.path && - components.queryItems?.enumerated().compactMap { $0.element.name }.sorted() == urlComponents.queryItems?.enumerated().compactMap { $0.element.name }.sorted() && - components.queryItems?.enumerated().compactMap { $0.element.value }.sorted() == urlComponents.queryItems?.enumerated().compactMap { $0.element.value }.sorted() - } -} - // MARK: - RequestBlocking /// Block all outgoing requests not caught by `RequestMocking` protocol diff --git a/Tests/TraktKitTests/ShowsTests.swift b/Tests/TraktKitTests/ShowsTests.swift index 07bf8dd..13c2295 100644 --- a/Tests/TraktKitTests/ShowsTests.swift +++ b/Tests/TraktKitTests/ShowsTests.swift @@ -17,7 +17,7 @@ final class ShowsTests: TraktTestCase { func test_get_min_trending_shows_await() async throws { try mock(.GET, "https://api.trakt.tv/shows/trending?extended=min&page=1&limit=10", result: .success(jsonData(named: "TrendingShows_Min")), headers: [.page(1), .pageCount(100)]) - let response = try await traktManager.explore.trending.shows() + let response = try await traktManager.shows.trending() .extend(.Min) .page(1) .limit(10) @@ -154,7 +154,7 @@ final class ShowsTests: TraktTestCase { func test_get_most_watched_shows_async() async throws { try? mock(.GET, "https://api.trakt.tv/shows/watched/weekly?page=1&limit=10", result: .success(jsonData(named: "test_get_most_watched_shows")), headers: [.page(1), .pageCount(8)]) - let pagedObject = try await traktManager.shows() + let pagedObject = try await traktManager.shows .watched(period: .weekly) .page(1) .limit(10) @@ -213,11 +213,11 @@ final class ShowsTests: TraktTestCase { // MARK: - Updates - func test_get_updated_shows() { - try? mock(.GET, "https://api.trakt.tv/shows/updates/2014-09-22?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_updated_shows"))) + func test_get_updated_shows() throws { + try mock(.GET, "https://api.trakt.tv/shows/updates/2014-09-22?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_updated_shows"))) let expectation = XCTestExpectation(description: "Get updated shows") - traktManager.getUpdatedShows(startDate: try! Date.dateFromString("2014-09-22"), pagination: Pagination(page: 1, limit: 10)) { result in + traktManager.getUpdatedShows(startDate: try Date.dateFromString("2014-09-22"), pagination: Pagination(page: 1, limit: 10)) { result in if case .success(let shows, _, _) = result { XCTAssertEqual(shows.count, 2) expectation.fulfill() @@ -232,6 +232,16 @@ final class ShowsTests: TraktTestCase { } } + func test_get_updated_show_ids() async throws { + try mock(.GET, "https://api.trakt.tv/shows/updates/id/2025-01-01T00:00:00", result: .success(jsonData(named: "test_get_updated_shows_ids"))) + + let updatedIds = try await traktManager.shows + .recentlyUpdatedIds(since: try Date.dateFromString("2025-01-01")) + .perform() + XCTAssertEqual(updatedIds.object.count, 100) + XCTAssertEqual(updatedIds.object.first, 163302) + } + // MARK: - Summary func test_get_min_show() { diff --git a/Tests/TraktKitTests/TraktManagerTests.swift b/Tests/TraktKitTests/TraktManagerTests.swift index ff3e4a2..fa73e1f 100644 --- a/Tests/TraktKitTests/TraktManagerTests.swift +++ b/Tests/TraktKitTests/TraktManagerTests.swift @@ -9,31 +9,25 @@ import Foundation import Testing @testable import TraktKit -@Suite -class TraktManagerTests { +extension TraktTestSuite { + @Suite(.serialized) + class TraktManagerTests { + @Test + func pollForAccessTokenInvalidDeviceCode() async throws { + try mock(.GET, "https://api.trakt.tv/oauth/device/token", result: .success(.init()), httpCode: 404) - lazy var traktManager = TraktManager(session: URLSession.mockedResponsesOnly, clientId: "", clientSecret: "", redirectURI: "") + let deviceCodeJSON: [String: Any] = [ + "device_code": "d9c126a7706328d808914cfd1e40274b6e009f684b1aca271b9b3f90b3630d64", + "user_code": "5055CC52", + "verification_url": "https://trakt.tv/activate", + "expires_in": 600, + "interval": 5 + ] + let deviceCode = try JSONDecoder().decode(DeviceCode.self, from: try JSONSerialization.data(withJSONObject: deviceCodeJSON)) - deinit { - RequestMocking.removeAllMocks() - } - - @Test - func pollForAccessTokenInvalidDeviceCode() async throws { - let mock = try RequestMocking.MockedResponse(urlString: "https://api.trakt.tv/oauth/device/token", result: .success(.init()), httpCode: 404) - RequestMocking.add(mock: mock) - - let deviceCodeJSON: [String: Any] = [ - "device_code": "d9c126a7706328d808914cfd1e40274b6e009f684b1aca271b9b3f90b3630d64", - "user_code": "5055CC52", - "verification_url": "https://trakt.tv/activate", - "expires_in": 600, - "interval": 5 - ] - let deviceCode = try JSONDecoder().decode(DeviceCode.self, from: try JSONSerialization.data(withJSONObject: deviceCodeJSON)) - - await #expect(throws: TraktManager.TraktTokenError.invalidDeviceCode, performing: { - try await traktManager.pollForAccessToken(deviceCode: deviceCode) - }) + await #expect(throws: TraktManager.TraktTokenError.invalidDeviceCode, performing: { + try await traktManager.pollForAccessToken(deviceCode: deviceCode) + }) + } } } diff --git a/Tests/TraktKitTests/TraktTestCase.swift b/Tests/TraktKitTests/TraktTestCase.swift index edbba4d..19637f6 100644 --- a/Tests/TraktKitTests/TraktTestCase.swift +++ b/Tests/TraktKitTests/TraktTestCase.swift @@ -12,7 +12,6 @@ class TraktTestCase: XCTestCase { lazy var traktManager = TraktManager(session: URLSession.mockedResponsesOnly, clientId: "", clientSecret: "", redirectURI: "") override func setUp() { - DependencyContainer.shared.traktClient = traktManager } override func tearDown() { diff --git a/Tests/TraktKitTests/TraktTestSuite.swift b/Tests/TraktKitTests/TraktTestSuite.swift new file mode 100644 index 0000000..c8c6fac --- /dev/null +++ b/Tests/TraktKitTests/TraktTestSuite.swift @@ -0,0 +1,24 @@ +// +// TraktTestSuite.swift +// TraktKit +// +// Created by Maximilian Litteral on 2/22/25. +// + +import Testing +import Foundation +@testable import TraktKit + +@Suite +final class TraktTestSuite { + static let traktManager = TraktManager(session: URLSession.mockedResponsesOnly, clientId: "", clientSecret: "", redirectURI: "") + + deinit { + RequestMocking.removeAllMocks() + } + + static func mock(_ method: TraktKit.Method, _ urlString: String, result: Result, httpCode: Int? = nil, headers: [HTTPHeader] = [.contentType, .apiVersion, .apiKey("")]) throws { + let mock = try RequestMocking.MockedResponse(urlString: urlString, result: result, httpCode: httpCode ?? method.expectedResult, headers: headers) + RequestMocking.add(mock: mock) + } +} From 919e1ada86e91646179dd7145d95f14e8adbe3a2 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 22 Feb 2025 11:01:51 -0500 Subject: [PATCH 12/38] Add all Movie endpoints --- Common/Models/People/TraktCastMember.swift | 8 - Common/Models/People/TraktCrewMember.swift | 8 - Common/Models/Structures.swift | 10 + Common/Models/TraktStudio.swift | 20 + Common/Wrapper/Resources/MovieResource.swift | 145 +- .../Resources/TraktManager+Resources.swift | 5 + Common/Wrapper/Route.swift | 7 + .../Models/Movies/test_get_cast_and_crew.json | 7029 ++++++++++++++--- .../Models/Movies/test_get_movie_studios.json | 47 + Tests/TraktKitTests/MovieTests+Async.swift | 31 + Tests/TraktKitTests/MovieTests.swift | 17 +- 11 files changed, 6334 insertions(+), 993 deletions(-) create mode 100644 Common/Models/TraktStudio.swift create mode 100644 Tests/TraktKitTests/Models/Movies/test_get_movie_studios.json diff --git a/Common/Models/People/TraktCastMember.swift b/Common/Models/People/TraktCastMember.swift index 2e4667f..3112f3b 100644 --- a/Common/Models/People/TraktCastMember.swift +++ b/Common/Models/People/TraktCastMember.swift @@ -28,15 +28,7 @@ public struct TVCastMember: TraktObject { /// Cast member for /movies/.../people API public struct MovieCastMember: TraktObject { public let characters: [String] - @available(*, deprecated, renamed: "characters") - public let character: String public let person: Person - - enum CodingKeys: String, CodingKey { - case characters - case character - case person - } } /// Cast member for /people/.../shows API diff --git a/Common/Models/People/TraktCrewMember.swift b/Common/Models/People/TraktCrewMember.swift index 4d8eff5..4f17696 100644 --- a/Common/Models/People/TraktCrewMember.swift +++ b/Common/Models/People/TraktCrewMember.swift @@ -28,15 +28,7 @@ public struct TVCrewMember: TraktObject { /// Cast member for /movies/.../people API public struct MovieCrewMember: TraktObject { public let jobs: [String] - @available(*, deprecated, renamed: "jobs") - public let job: String public let person: Person - - enum CodingKeys: String, CodingKey { - case jobs - case job - case person - } } /// Cast member for /people/.../shows API diff --git a/Common/Models/Structures.swift b/Common/Models/Structures.swift index b6dda4d..d7190f9 100644 --- a/Common/Models/Structures.swift +++ b/Common/Models/Structures.swift @@ -123,6 +123,16 @@ public struct TraktStats: TraktObject { } } +public struct TraktMovieStats: TraktObject { + public let watchers: Int + public let plays: Int + public let collectors: Int + public let comments: Int + public let lists: Int + public let votes: Int + public let favorited: Int +} + // MARK: - Last Activities public struct TraktLastActivities: TraktObject { diff --git a/Common/Models/TraktStudio.swift b/Common/Models/TraktStudio.swift new file mode 100644 index 0000000..a54e4d5 --- /dev/null +++ b/Common/Models/TraktStudio.swift @@ -0,0 +1,20 @@ +// +// TraktStudio.swift +// TraktKit +// +// Created by Maximilian Litteral on 2/22/25. +// + +import Foundation + +public struct TraktStudio: TraktObject { + public let name: String + public let country: String + public let ids: IDS + + public struct IDS: TraktObject { + public let trakt: Int + public let slug: String + public let tmdb: Int + } +} diff --git a/Common/Wrapper/Resources/MovieResource.swift b/Common/Wrapper/Resources/MovieResource.swift index 3579b8b..7a379b6 100644 --- a/Common/Wrapper/Resources/MovieResource.swift +++ b/Common/Wrapper/Resources/MovieResource.swift @@ -102,7 +102,150 @@ extension TraktManager { } } + /// Endpoints for a specific movie public struct MovieResource { - + // MARK: - Properties + + /// Trakt ID, Trakt slug, or IMDB ID + internal let id: String + internal let path: String + + private let traktManager: TraktManager + + // MARK: - Lifecycle + + internal init(id: CustomStringConvertible, traktManager: TraktManager) { + self.id = id.description + self.traktManager = traktManager + self.path = "movies/\(id)" + } + + // MARK: - Methods + + /** + Returns a single movie's details. + + > note: When getting `full` extended info, the `status` field can have a value of `released`, `in production`, `post production`, `planned`, `rumored`, or `canceled`. + */ + public func summary() -> Route { + Route(path: path, method: .GET, traktManager: traktManager) + } + + /** + Returns all title aliases for a movie. Includes country where name is different. + */ + public func aliases() -> Route<[Alias]> { + Route(paths: [path, "aliases"], method: .GET, traktManager: traktManager) + } + + /** + Returns all releases for a movie including country, certification, release date, release type, and note. The release type can be set to unknown, premiere, limited, theatrical, digital, physical, or tv. The note might have optional info such as the film festival name for a premiere release or Blu-ray specs for a physical release. We pull this info from TMDB.' + + - parameter country: 2 character country code Example: `us` + */ + public func releases(country: String? = nil) -> Route<[TraktMovieRelease]> { + Route(paths: [path, "releases", country], method: .GET, traktManager: traktManager) + } + + /** + Returns all translations for a movie, including language and translated values for title, tagline and overview. + + - parameter language: 2 character language code Example: `es` + */ + public func translations(language: String? = nil) -> Route<[TraktMovieTranslation]> { + Route(paths: [path, "translations", language], method: .GET, traktManager: traktManager) + } + + /** + Returns all top level comments for a movie. By default, the `newest` comments are returned first. Other sorting options include `oldest`, most `likes`, most `replies`, `highest` rated, `lowest` rated, most `plays`, and highest `watched` percentage. + + 🔓 OAuth Optional 📄 Pagination 😁 Emojis + + > note: If you send OAuth, comments from blocked users will be automatically filtered out. + + - parameter sort: how to sort Example: `newest`. + - parameter authenticate: comments from blocked users will be automatically filtered out if `true`. + */ + public func comments(sort: String? = nil, authenticate: Bool = false) -> Route> { + Route(paths: [path, "comments", sort], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) + } + + /** + Returns all lists that contain this movie. By default, `personal` lists are returned sorted by the most `popular`. + + - parameter type: Filter for a specific list type. Possible values: `all` , `personal` , `official` , `watchlists` , `favorites` . + - parameter sort: How to sort . Possible values: `popular` , `likes` , `comments` , `items` , `added` , `updated` . + */ + public func containingLists(type: String? = nil, sort: String? = nil) -> Route> { + Route(paths: [path, "lists", type, sort], method: .GET, traktManager: traktManager) + } + + /** + Returns all `cast` and `crew` for a movie. Each `cast` member will have a `characters` array and a standard `person` object.The `crew` object will be broken up by department into production, `art`, `crew`, `costume & make-up`, `directing`, `writing`, `sound`, `camera`, `visual effects`, `lighting`, and `editing` (if there are people for those crew positions). Each of those members will have a `jobs` array and a standard `person` object. + + ✨ Extended Info + */ + public func people() -> Route> { + Route(paths: [path, "people"], method: .GET, traktManager: traktManager) + } + + /** + Returns rating (between 0 and 10) and distribution for a movie. + */ + public func ratings() -> Route { + Route(paths: [path, "ratings"], method: .GET, traktManager: traktManager) + } + + /** + Returns related and similar movies. + + 📄 Pagination ✨ Extended Info + */ + public func relatedMovies() -> Route> { + Route(paths: [path, "related"], method: .GET, traktManager: traktManager) + } + + /** + Returns lots of movie stats. + */ + public func stats() -> Route { + Route(paths: [path, "stats"], method: .GET, traktManager: traktManager) + } + + /** + Returns lots of movie stats. + */ + public func studios() -> Route<[TraktStudio]> { + Route(paths: [path, "studios"], method: .GET, traktManager: traktManager) + } + + /** + Returns all users watching this movie right now. + + ✨ Extended Info + */ + public func usersWatching() -> Route<[User]> { + Route(paths: [path, "watching"], method: .GET, traktManager: traktManager) + } + + /** + Returns all videos including trailers, teasers, clips, and featurettes. + + ✨ Extended Info + */ + public func videos() -> Route<[TraktVideo]> { + Route(paths: [path, "videos"], method: .GET, traktManager: traktManager) + } + + /** + Queue this movie for a full metadata and image refresh. It might take up to 8 hours for the updated metadata to be availabe through the API. + + > note: If this movie is already queued, a 409 HTTP status code will returned. + + 🔥 VIP Only 🔒 OAuth Required + */ + public func refreshMetadata() -> EmptyRoute { + EmptyRoute(paths: [path, "videos"], method: .GET, requiresAuthentication: true, traktManager: traktManager) + } } } diff --git a/Common/Wrapper/Resources/TraktManager+Resources.swift b/Common/Wrapper/Resources/TraktManager+Resources.swift index 8c844a1..e20685f 100644 --- a/Common/Wrapper/Resources/TraktManager+Resources.swift +++ b/Common/Wrapper/Resources/TraktManager+Resources.swift @@ -22,6 +22,11 @@ extension TraktManager { MoviesResource(traktManager: self) } + /// - parameter id: Trakt ID, Trakt slug, or IMDB ID + public func movie(id: CustomStringConvertible) -> MovieResource { + MovieResource(id: id, traktManager: self) + } + // MARK: - TV public var shows: ShowsResource { diff --git a/Common/Wrapper/Route.swift b/Common/Wrapper/Route.swift index d253c1a..7a3b696 100644 --- a/Common/Wrapper/Route.swift +++ b/Common/Wrapper/Route.swift @@ -153,6 +153,13 @@ public struct EmptyRoute: Sendable { self.traktManager = traktManager } + public init(paths: [CustomStringConvertible?], method: Method, requiresAuthentication: Bool = false, traktManager: TraktManager) { + self.path = paths.compactMap { $0?.description }.joined(separator: "/") + self.method = method + self.requiresAuthentication = requiresAuthentication + self.traktManager = traktManager + } + // MARK: - Perform public func perform() async throws { diff --git a/Tests/TraktKitTests/Models/Movies/test_get_cast_and_crew.json b/Tests/TraktKitTests/Models/Movies/test_get_cast_and_crew.json index 0698a15..9160f7f 100644 --- a/Tests/TraktKitTests/Models/Movies/test_get_cast_and_crew.json +++ b/Tests/TraktKitTests/Models/Movies/test_get_cast_and_crew.json @@ -1,10 +1,9 @@ { "cast": [ { - "character": "Tony Stark, Iron Man", + "character": "Tony Stark", "characters": [ - "Tony Stark", - "Iron Man" + "Tony Stark" ], "person": { "name": "Robert Downey Jr.", @@ -13,19 +12,28 @@ "slug": "robert-downey-jr", "imdb": "nm0000375", "tmdb": 3223, - "tvrage": 10038 + "tvrage": null + }, + "social_ids": { + "twitter": "RobertDowneyJr", + "facebook": "robertdowneyjr", + "instagram": "robertdowneyjr", + "wikipedia": null }, - "biography": "Robert John Downey Jr. (born April 4, 1965) is an American actor and producer. Downey made his screen debut in 1970, at the age of five, when he appeared in his father's film Pound, and has worked consistently in film and television ever since. He received two Academy Award nominations for his roles in films Chaplin (1992) and Tropic Thunder (2008).", + "biography": "Robert John Downey, Jr. (born April 4, 1965) is an American actor. His films as a leading actor have grossed over $14 billion worldwide, making him one of the highest-grossing actors of all time. Downey's career has been characterized by some early success, a period of drug-related problems and run-ins with the law, and a surge in popular and commercial success in the 2000s. In 2008, Downey was named by Time magazine as one of the 100 most influential people in the world. From 2013 to 2015, he was listed by Forbes as Hollywood's highest-paid actor.\n\nAt the age of five, Downey made his acting debut in his father Robert Downey Sr.'s film Pound in 1970. He subsequently worked with the Brat Pack in the teen films Weird Science (1985) and Less than Zero (1987). Downey's portrayal of Charlie Chaplin in the 1992 biopic Chaplin received a BAFTA Award. Following a stint at the Corcoran Substance Abuse Treatment Facility on drug charges, he joined the TV series Ally McBeal in 2000, and won a Golden Globe Award for the role. Downey was fired from the show in 2001 in the wake of additional drug charges. He stayed in a court-ordered drug treatment program and has maintained his sobriety since 2003.\n\nDowney made his acting comeback in the 2003 film The Singing Detective, after Mel Gibson paid his insurance bond because completion bond companies would not insure himю He went on to star in the black comedy Kiss Kiss Bang Bang (2005), the thriller Zodiac (2007), and the action comedy Tropic Thunder (2008). Downey gained global recognition for starring as Iron Man in ten films within the Marvel Cinematic Universe, beginning with Iron Man (2008), and leading up to Avengers: Endgame (2019). He has also played Sherlock Holmes in Guy Ritchie's Sherlock Holmes (2009), which earned him his second Golden Globe, and its sequel, Sherlock Holmes: A Game of Shadows (2011). Downey has also taken on dramatic parts in The Judge (2014) and Oppenheimer (2023), winning an Academy Award, a Golden Globe, and a BAFTA Award for his portrayal of Lewis Strauss in the latter.\n\nDescription above from the Wikipedia article Robert Downey Jr., licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": "1965-04-04", "death": null, - "birthplace": "Manhattan, New York City, New York, USA", - "homepage": null + "birthplace": "New York City, New York, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-21T08:06:13.000Z" } }, { - "character": "James \"Rhodey\" Rhodes", + "character": "Rhodey", "characters": [ - "James \"Rhodey\" Rhodes" + "Rhodey" ], "person": { "name": "Terrence Howard", @@ -34,20 +42,28 @@ "slug": "terrence-howard", "imdb": "nm0005024", "tmdb": 18288, - "tvrage": 59344 + "tvrage": null }, - "biography": "Terrence Dashon Howard (born March 11, 1969) is an Academy Award-nominated American actor. Having his first major role in the 1995 film Mr. Holland's Opus, which subsequently led to a number of roles in films and high visibility among African American audiences. Howard broke into the mainstream with a succession of well-reviewed television and film roles between 2004 and 2006. Among his roles in movies includes Ray, Lackawanna Blues, Crash, Four Brothers, Hustle & Flow, Get Rich or Die Tryin', Idlewild, The Brave One. Howard co-starred in Iron Man and reprised the role in the video game adaption. He was replaced in this role in the sequel Iron Man 2, by Academy Award nominee Don Cheadle (his Crash co-star). Howard was born in Chicago, Illinois and raised in Cleveland, Ohio. At 18, he moved to New York City to pursue an acting career. He had auditioned for The Cosby Show and was cast in the role and later, Howard had a principal role in a short-lived CBS sitcom, Tall Hopes. He went on to portray Jackie Jackson in The Jacksons: An American Dream, an ABC miniseries. Three years later, he made his big film break in 1995's Mr. Holland's Opus. He continued with television and movie roles and co-starred as Greg Sparks in the late-1990s television series Sparks. Howard also appeared in The Best Man in 1999, in Ashanti's music video for her 2002 single \"Foolish\", and in Mary J. Blige's video for \"Be Without You\". Howard also made an appearance on the TV series Family Matters. Howard has also worked as a film producer for the movie Pride. In 2008, Howard hosted the PBS' series Independent Lens. Also in 2008, he made his Broadway debut, playing Brick in an all-African-American production of Tennessee Williams' Cat on a Hot Tin Roof, directed by Debbie Allen. Later that year, Howard made a guest appearance in the short film For All Mankind directed by Daniel L. Clifton and in 2009, he starred in the movie Fighting. In 2010, Howard joined the cast of the new Law & Order: Los Angeles installment of the Law & Order franchise, wherein he will play Deputy District Attorney Joe Dekker. Terrence ventured into the music industry with his debut pop album, Shine through It, heavily inspired by popular soul singers such as Marvin Gaye and Curtis Mayfield, was released in September 2008. Howard, who lives outside Philadelphia in Lafayette Hill, He has three children from a previous marriage: two daughters (Aubrey and Heaven) and a son (Hunter).", + "social_ids": { + "twitter": "terrencehoward", + "facebook": "terrencehoward", + "instagram": "theterrencehoward", + "wikipedia": null + }, + "biography": "Terrence Dashon Howard (born March 11, 1969) is an American actor. Having his first major roles in the 1995 films Dead Presidents and Mr. Holland's Opus, Howard broke into the mainstream with a succession of television and cinema roles between 2004 and 2006. He was nominated for the Academy Award for Best Actor for his role in Hustle \u0026 Flow.\n\nHoward has had prominent roles in many other movies, including Winnie Mandela, Ray, Lackawanna Blues, Crash, Four Brothers, Big Momma's House, Get Rich or Die Tryin', Idlewild, Biker Boyz, August Rush, The Brave One, and Prisoners. Howard played James \"Rhodey\" Rhodes in the first Iron Man film. He starred as the lead character Lucious Lyon in the television series Empire. His debut album, Shine Through It, was released in September 2008.\n\nDescription above is from the Wikipedia article Terrence Howard, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": "1969-03-11", "death": null, - "birthplace": "Chicago, Illinois, USA ", - "homepage": null + "birthplace": "Chicago, Illinois, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-13T20:04:33.000Z" } }, { - "character": "Obadiah Stane, Iron Monger", + "character": "Obadiah Stane", "characters": [ - "Obadiah Stane", - "Iron Monger" + "Obadiah Stane" ], "person": { "name": "Jeff Bridges", @@ -56,19 +72,28 @@ "slug": "jeff-bridges", "imdb": "nm0000313", "tmdb": 1229, - "tvrage": 59067 + "tvrage": null + }, + "social_ids": { + "twitter": "TheJeffBridges", + "facebook": "JeffBridgesOfficial", + "instagram": "thejeffbridges", + "wikipedia": null }, - "biography": "American actor Jeff Bridges has starred in over 50 films during his lengthy career. He comes from a family of performers and was introduced to show business by his father Lloyd. Jeff is most famous for his work in the films Big Lebowski (1998), Seabiscuit (2003), Crazy Heart (2009), and most recently True Grit (2010). His accolades include a long list of Academy Award nominations, and one win for Best Actor in 2010.\n\nBridges was born in 1949 and grew up in Los Angeles, California. After high school Jeff traveled to New York to study acting at the famous Herbert Berghof Studio. In the late 1960's Jeff served in the US Coast Guard, and stayed on reserve through the early 1970's to avoid Vietnam. As a child Jeff got a start in acting through his father Lloyd, and even starred in two of his television shows. Jeff achieved major success early in his career when he was nominated for an Academy Award in his first major film, the 1971 drama Last Picture Show. Three years later he starred opposite of Clint Eastwood in Thunderbolt and Lightfoot.\n\nBy 1976 Bridges was casted in the first remake of King Kong, which turned out to be a huge commercial success raking in more than 90 million dollars. Over the next three decades Bridges went on to star in over 50 films and win countless awards for his work. More recently Jeff starred in the 2009 film Crazy Heart, which earned him his first Academy Award for Best Actor.\n\nIn 1977 Jeff married Susan Geston, the two met while filming Ranch Deluxe. Together they have three daughters.\n\nAlong with other entertainment industry leaders, in 1984 Bridges founded the End Hunger Network, a humanitarian effort which aims to eliminate childhood hunger in the US by 2015.\n\nAlong with his charitable work, Jeff is also known for his music and photography.", + "biography": "Jeffrey Leon Bridges (born December 4, 1949) is an American actor, singer, and producer. He comes from a prominent acting family and appeared on the television series Sea Hunt (1958–60), with his father, Lloyd Bridges and brother, Beau Bridges. He has won numerous accolades, including the Academy Award for Best Actor for his role as an alcoholic singer in the 2009 film Crazy Heart.\n\nBridges also earned Academy Award nominations for his roles in The Last Picture Show (1971), Thunderbolt and Lightfoot (1974), Starman (1984), The Contender (2000), True Grit (2010), and Hell or High Water (2016).\n\nDescription above from the Wikipedia article Jeff Bridges, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": "1949-12-04", "death": null, "birthplace": "Los Angeles, California, USA", - "homepage": "http://www.jeffbridges.com" + "homepage": "https://www.jeffbridges.com/", + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-21T08:06:13.000Z" } }, { - "character": "Virginia \"Pepper\" Potts", + "character": "Pepper Potts", "characters": [ - "Virginia \"Pepper\" Potts" + "Pepper Potts" ], "person": { "name": "Gwyneth Paltrow", @@ -77,13 +102,22 @@ "slug": "gwyneth-paltrow", "imdb": "nm0000569", "tmdb": 12052, - "tvrage": 58927 + "tvrage": null + }, + "social_ids": { + "twitter": "GwynethPaltrow", + "facebook": "GwynethPaltrowOfficial", + "instagram": "gwynethpaltrow", + "wikipedia": null }, - "biography": "Paltrow made her acting debut on stage in 1990 and started appearing in films in 1991. She gained early notice for her work in films such as Se7en (1995), Emma (1996), in which she played the title role, and Sliding Doors (1998). She garnered worldwide recognition through her performance in Shakespeare in Love (1998), for which she won the Academy Award for Best Actress, a Golden Globe Award and two Screen Actors Guild Awards, for Outstanding Lead Actress and as a member of the Outstanding Cast. Since then, Paltrow has portrayed supporting as well as lead roles in films such as The Talented Mr. Ripley (1999), Shallow Hal (2001) and Proof (2005), for which she earned a Golden Globe nomination as Best Actress in Motion Picture Drama. In 2008, she appeared in the highest grossing movie of her career, the superhero film Iron Man, and then reprised her role as Pepper Potts in its sequel, Iron Man 2 (2010). Paltrow has been the face of Estée Lauder's Pleasures perfume since 2005.", + "biography": "Gwyneth Kate Paltrow (/ˈpæltroʊ/ PAL-troh; born September 27, 1972) is an American actress and businesswoman. The daughter of filmmaker Bruce Paltrow and actress Blythe Danner, she established herself as a leading lady, appearing in mainly mid-budget and period films during the 1990s and early 2000s, before transitioning to blockbusters and franchises. Her accolades include an Academy Award, a Golden Globe Award, and a Primetime Emmy Award.\n\nPaltrow gained notice for her early work in films such as Seven (1995), Emma (1996), Sliding Doors (1998), and A Perfect Murder (1998). She garnered wider acclaim for her role as Viola de Lesseps in the historical romance Shakespeare in Love (1998), which earned her the Academy Award for Best Actress. This was followed by roles in The Talented Mr. Ripley (1999), The Royal Tenenbaums (2001), and Shallow Hal (2001). She made her West End debut in the David Auburn play Proof (2003), earning a Laurence Olivier Award for Best Actress nomination, and reprised the role in the 2005 film of the same name.\n\nAfter becoming a parent in 2004, Paltrow reduced her acting workload by making intermittent appearances in films such as Two Lovers (2008), Country Strong (2010), and Contagion (2011). Paltrow's career revived through her portrayal of Pepper Potts in the Marvel Cinematic Universe from Iron Man (2008) to Avengers: Endgame (2019). On television, she had a recurring guest role as Holly Holliday on the Fox musical television series Glee (2010–2011), for which she received the Primetime Emmy Award for Outstanding Guest Actress in a Comedy Series. After starring in the Netflix series The Politician (2019–2020), she took a break from acting.\n\nIn 2005 Paltrow became a \"face\" of Estée Lauder Companies; she was previously the face of the American fashion brand Coach. She is the founder and CEO of the lifestyle company Goop, which has been criticised for promoting pseudoscience, and has written several cookbooks. She received a Grammy Award nomination for Best Spoken Word Album for Children for the Brown Bear and Friends (2009). She hosted the documentary series The Goop Lab for Netflix in 2020.\n\nDescription above from the Wikipedia article Gwyneth Paltrow, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": "1972-09-27", "death": null, "birthplace": "Los Angeles, California, USA", - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2025-02-13T20:04:33.000Z" } }, { @@ -98,13 +132,22 @@ "slug": "leslie-bibb", "imdb": "nm0004753", "tmdb": 57451, - "tvrage": 35954 + "tvrage": null + }, + "social_ids": { + "twitter": "mslesliebibb", + "facebook": null, + "instagram": "mslesliebibb", + "wikipedia": null }, - "biography": "Leslie Louise Bibb (born November 17, 1974) is an American actress and former fashion model. She transitioned into film and television in late 1990s. She appeared in television shows such as Home Improvement (1996), before she appeared in her first film, the comedy Private Parts (1997), which was followed by her first show The Big Easy. She received a role in This Space Between Us (1999).\n\nHer role as Brooke McQueen on the WB Network comedy–drama series Popular (1999–2001) brought her to the attention of a wider audience, and received a Teen Choice Award for Television Choice Actress. During the series, she also gained recognition for her roles in the thriller The Skulls (2000) and in the comedy See Spot Run (2001). She had a recurring role in the medical–drama show ER (2002–2003).\n\nBibb was cast as Carley Bobby, in the comedy Talladega Nights: The Ballad of Ricky Bobby (2006). She had a minor supporting role in the hit action film Iron Man (2008) and appeared in the crime-thriller Law Abiding Citizen (2009). She reprised her role in the sequel to Iron Man, Iron Man 2 (2010). Recently, she joined the cast of the upcoming family–comedy Zookeeper (2011) as Miranda Davis.\n\nDescription above from the Wikipedia article  Leslie Bibb, licensed under CC-BY-SA, full list of contributors on Wikipedia.", - "birthday": "1974-11-17", + "biography": "Leslie Louise Bibb (born November 17, 1973) is an American actress and former fashion model. She transitioned into film and television in late 1990s. She appeared in television shows such as Home Improvement (1996), before she appeared in her first film, the comedy Private Parts (1997), which was followed by her first show The Big Easy. She received a role in This Space Between Us (1999).\n\nHer role as Brooke McQueen on the WB Network comedy–drama series Popular (1999–2001) brought her to the attention of a wider audience, and received a Teen Choice Award for Television Choice Actress. During the series, she also gained recognition for her roles in the thriller The Skulls (2000) and in the comedy See Spot Run (2001). She had a recurring role in the medical–drama show ER (2002–2003), Crossing Jordan, and on the sitcoms The League and American Housewife. She had a starring role on the drama GCB, the sitcom Burning Love and the fantasy series Jupiter's Legacy as well as God's Favorite Idiot.\n\nBibb was cast as Carley Bobby, in the comedy Talladega Nights: The Ballad of Ricky Bobby (2006). She had a minor supporting role in the hit action film Iron Man (2008) and appeared in the crime-thriller Law Abiding Citizen (2009). She reprised her role in the sequel Iron Man 2 (2010). She appeared in the family–comedy Zookeeper (2011) as Miranda Davis. She also appeared in Confessions of a Shopaholic, Law Abiding Citizen, A Good Old-Fashioned Orgy, Movie 43, No Good Deed, To the Bone, and Tag, among others.", + "birthday": "1973-11-17", "death": null, "birthplace": "Bismarck, North Dakota, USA", - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2025-02-13T20:04:33.000Z" } }, { @@ -119,13 +162,22 @@ "slug": "shaun-toub", "imdb": "nm0869467", "tmdb": 17857, - "tvrage": 22694 + "tvrage": null + }, + "social_ids": { + "twitter": "shauntoub", + "facebook": "ShaunToubOfficial", + "instagram": "shauntoub", + "wikipedia": null }, "biography": "Shaun Toub is an Iranian-American film and television actor. He is perhaps best known for his role as Farhad in the 2004 movie Crash, as Rahim Khan in the movie The Kite Runner, and as Yinsen in the film adaptation of the Iron Man comic book series.\n\nToub, who is of Persian Jewish background, was born in Tehran, Iran and raised in Manchester, England (his family left Iran before the 1979 revolution). At the age of fourteen, he moved to Switzerland and after a two year stay, he crossed the Atlantic to Nashua, New Hampshire to finish his last year of high school. His high school yearbook notes: \"The funniest guy in school and the most likely to succeed in the entertainment world.\" After two years of college in Massachusetts, Shaun transferred to USC.\n\nShaun is active in the Iranian Jewish community. Through various charity events and public speaking engagements, he inspires the community to embrace the arts, as the arts enhance everyday life. He has been a recipient of the Sephard award at the Los Angeles Sephardic Film Festival. Toub currently resides in Los Angeles.", - "birthday": "1963-01-01", + "birthday": "1958-02-15", "death": null, - "birthplace": "Tehran - Iran", - "homepage": null + "birthplace": "Tehran, Iran", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-01-13T08:10:25.000Z" } }, { @@ -142,17 +194,26 @@ "tmdb": 57452, "tvrage": null }, - "biography": "Faran Tahir is actor.", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Faran Haroon Tahir (Urdu: فاران ہارون طاہر) is an American actor.\n\nTahir made his film debut as Nathoo in Disney's 1994 film The Jungle Book. He went on to star in a variety of roles, such as Raza in Iron Man (2008), Captain Robau in Star Trek (2009), and President Patel in Elysium (2013). In 2016, he played the title role of Othello in a production by the Shakespeare Theatre Company in Washington, D.C.\n\nDescription above from the Wikipedia article Faran Tahir, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": "1963-02-16", "death": null, "birthplace": "Los Angeles, California, USA", - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-13T08:07:18.000Z" } }, { - "character": "Phil Coulson", + "character": "Agent Coulson", "characters": [ - "Phil Coulson" + "Agent Coulson" ], "person": { "name": "Clark Gregg", @@ -161,13 +222,22 @@ "slug": "clark-gregg", "imdb": "nm0163988", "tmdb": 9048, - "tvrage": 28033 + "tvrage": null + }, + "social_ids": { + "twitter": "clarkgregg", + "facebook": "clarkgreggofficial", + "instagram": "clarkgregg", + "wikipedia": null }, - "biography": "From Wikipedia, the free encyclopedia\n\nRobert Clark Gregg (born April 2, 1962) is an American actor, screenwriter and director. He co-starred as Christine Campbell's ex-husband Richard in the CBS sitcom The New Adventures of Old Christine, which debuted in March 2006 and concluded in May 2010. He is best known for playing Agent Phil Coulson in the Marvel Cinematic Universe.", + "biography": "Robert Clark Gregg Jr. (born April 2, 1962) is an American actor, director, and screenwriter. He is best known for portraying Phil Coulson in films and television series set in the Marvel Cinematic Universe from 2008 to 2021. Gregg also voiced Coulson in the animated television series Ultimate Spider-Man (2012–2017) and the video games Lego Marvel Super Heroes (2013), Marvel Heroes (2013), and Lego Marvel's Avengers (2016).\n\nGregg is also known for his role as FBI Special Agent Mike Casper on the NBC political drama series The West Wing (2001–2004) and as Richard, the ex-husband of Christine Campbell, in the CBS sitcom The New Adventures of Old Christine (2006–2010).\n\nHe wrote the horror film What Lies Beneath (2000) and wrote and directed the black comedy Choke (2008) and the comedy-drama Trust Me (2013). He appeared in the films The Adventures of Sebastian Cole (1998), One Hour Photo (2002), We Were Soldiers (2002), In Good Company (2004), When a Stranger Calls (2006), 500 Days of Summer (2009), Much Ado About Nothing (2012), The To Do List (2013), Labour Day (2013), Live by Night (2016), and Being the Ricardos (2021).\n\nDescription above from the Wikipedia article Clark Gregg, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": "1962-04-02", "death": null, - "birthplace": "Boston - Massachusetts - USA", - "homepage": null + "birthplace": "Boston, Massachusetts, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-20T21:48:35.000Z" } }, { @@ -182,13 +252,22 @@ "slug": "bill-smitrovich", "imdb": "nm0810488", "tmdb": 17200, - "tvrage": 15300 + "tvrage": null + }, + "social_ids": { + "twitter": "BillSmitrovich", + "facebook": null, + "instagram": null, + "wikipedia": null }, - "biography": "Bill Smitrovich (born June 16, 1947) is an American actor.", + "biography": "William Stanley Zmitrowicz Jr. (born May 16, 1947), known professionally as Bill Smitrovich, is an American actor.", "birthday": "1947-05-16", "death": null, "birthplace": "Bridgeport, Connecticut, USA", - "homepage": "http://www.billsmitrovich.com/" + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-12-01T08:07:32.000Z" } }, { @@ -203,19 +282,28 @@ "slug": "sayed-badreya", "imdb": "nm0046223", "tmdb": 173810, - "tvrage": 8046 + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null }, "biography": "Egyptian-born film-maker and actor Sayed Badreya realized a childhood dream by winning roles in major Hollywood films such as The Insider, Three Kings, and Independence Day. Growing up in poverty in Port Said, Sayed Badreya's dreams of movie stardom looked as bleak as the prospect of peace in the Middle East. From the Six Day War in '67 through the Yom Kippur War in '73, his only escape from the world he knew was the movie theater, where films transported him to a magical land. But it was here that he determined he was destined to be a part of that magic. After attending New York University film school, and then moving to Hollywood, Sayed first worked in the film industry as an assistant to actor/director Anthony Perkins, and then with director James Cameron on True Lies. His mission - to make movies that told the Arabic- American story, since it had yet to be told - led to the creation of his own production company, Zoom In Focus. Under this banner, he directed and produced the documentary, Saving Egyptian Film Classics as well as The Interrogation, which won Best Creative Short Film at New York International Film Festival. He also produced and starred in Hesham Issawi's short, T for Terrorist, which was awarded Best Short Film at the Boston International Film Festival and the San Francisco World Film Festival. In 2007, he played his first leading role in the English language motion picture American East, a film that he also co-wrote. 2008 was Sayed Badreya's breakout year. He captivated audiences as Abu Bakaar, the villainous arms dealer who kidnaps Tony Stark (Robert Downey Jr.) in the summer's blockbuster Iron Man. Also that year, Sayed played the comedic Palestinian cab driver opposite Adam Sandler in You Don't Mess with the Zohan. This summer, Sayed can be seen in Paramount Pictures' feature film El traspatio aka Backyard, directed by Oscar-nominated Carlos Carrera in which he plays a serial killer opposite Ana de la Reguera. Sayed can also be seen this summer in Movie 43, where he plays opposite Halle Berry. Additional forthcoming films include The Three Stooges, his fifth film with the Farrelly brothers; The Dictator, playing Sascha Baron Cohen's father as the original Dictator, and Just Like a Woman, with Oscar nominated director Rachid Bouchareb. Also, Sayed is going to a new frontier in the new video game Uncharted 3, playing Ramses the Great Pirate Captain. Most recently he completed his second leading role in the New York independent feature, Cargo, about human traffickers, directed by Yan Vizinberg. And he just finished co-starring opposite Oscar-nominated actress Melissa Leo in film The Space Between, directed by Travis Fine. Sayed has also worked as an actor, Arabic dialect coach, and Islamic technical advisor on Path to 9/11, a $40 million mini-series about the events leading up to 9/11 produced by ABC/Touchstone. Sayed's efforts to bring attention to Arab-Americans in the motion picture industry have received much coverage over the years on radio, television, and in major publications around the world, such as The New York Times, GQ, NPR, ABC's \"Politically Incorrect\" with Bill Maher, BBC's \"Panorama,\" CNN, \"Fox Report with Shepard Smith\", The Hollywood Reporter and Egypt Today.", "birthday": "1957-05-01", "death": null, "birthplace": "Port Said, Egypt", - "homepage": "http://www.sayedbadreya.com/" + "homepage": "http://www.sayedbadreya.com/", + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-01-26T08:08:54.000Z" } }, { - "character": "J.A.R.V.I.S. (voice)", + "character": "Jarvis (voice)", "characters": [ - "J.A.R.V.I.S. (voice)" + "Jarvis (voice)" ], "person": { "name": "Paul Bettany", @@ -224,19 +312,28 @@ "slug": "paul-bettany", "imdb": "nm0079273", "tmdb": 6162, - "tvrage": 59102 + "tvrage": null + }, + "social_ids": { + "twitter": "Paul_Bettany", + "facebook": "PaulBettanyOfficial", + "instagram": "paulbettany", + "wikipedia": null }, - "biography": "Paul Bettany was born into a theatre family. His father, Thane Bettany, is still an actor but his mother, Anne Kettle, has retired from acting. His maternal grandmother, Olga Gwynne, was a successful actress, while his maternal grandfather, Lesley Kettle, was a musician and promoter. He was brought up in North West London and, after the age of 9, in Hertfordshire (Brookmans Park). Immediately after finishing at Drama Centre, he went into the West End to join the cast of \"An Inspector Calls\", though when asked to go on tour with this play, he chose to stay in England.", + "biography": "Paul Bettany (born 27 May 1971) is an English actor. He is best known for his roles as J.A.R.V.I.S. and Vision in the Marvel Cinematic Universe, including the Disney+ miniseries WandaVision (2021), for which he garnered a Primetime Emmy Award nomination.\n\nBettany first gained popularity for appearing in the films Gangster No. 1 (2000), A Knight's Tale (2001), and A Beautiful Mind (2001). He was nominated for a BAFTA Award for playing Stephen Maturin in the film Master and Commander: The Far Side of the World (2003). Other films in which Bettany has appeared include Dogville (2003), Wimbledon (2004), The Da Vinci Code (2006), The Tourist (2010), Margin Call (2011), Legend (2015), and Solo: A Star Wars Story (2018). He made his directorial debut with the film Shelter (2014), which he also wrote and co-produced.\n\nIn television and theatre, Bettany has portrayed Ian Campbell, 11th Duke of Argyll, in the series A Very British Scandal and Andy Warhol in the play The Collaboration in the West End, which is set to transfer to Broadway.\n\nDescription above is from the Wikipedia article Paul Bettany, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": "1971-05-27", "death": null, - "birthplace": "Harlesden, London, England, UK ", - "homepage": null + "birthplace": "London, England, UK", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-13T20:04:33.000Z" } }, { - "character": "Harold \"Happy\" Hogan", + "character": "Hogan", "characters": [ - "Harold \"Happy\" Hogan" + "Hogan" ], "person": { "name": "Jon Favreau", @@ -245,13 +342,22 @@ "slug": "jon-favreau", "imdb": "nm0269463", "tmdb": 15277, - "tvrage": 29076 + "tvrage": null + }, + "social_ids": { + "twitter": "jon_favreau", + "facebook": "jonfavreau", + "instagram": "jonfavreau", + "wikipedia": null }, - "biography": "Jonathan Favreau (/ˈfævroʊ/; born October 19, 1966) is an American actor, director, producer, and screenwriter.\n\nAs an actor, he is known for roles in films such as \"Rudy\" (1993), \"Swingers\" (1996) (which he also wrote), \"Very Bad Things\" (1998), \"Daredevil\" (2003), \"The Break-Up\" (2006), \"Couples Retreat\" (2009), and \"Chef\" (2014) (which he also wrote and directed).\n\nHe has additionally directed the films \"Elf\" (2003), \"Zathura: A Space Adventure\" (2005), \"Iron Man\" (2008), \"Iron Man 2\" (2010), \"Cowboys & Aliens\" (2011), \"The Jungle Book\" (2016), and \"The Lion King\" (2019) and served as an executive producer on \"The Avengers\" (2012), \"Iron Man 3\" (2013), \"Avengers: Age of Ultron\" (2015), \"Avengers: Infinity War\" (2018) and \"Avengers: Endgame\" (2019). Favreau also portrays Happy Hogan in the Marvel Cinematic Universe and played Pete Becker during season three of the television sitcom \"Friends\". He produces films under his banner, Fairview Entertainment. The company has been credited as co-producers in most of Favreau's directorial ventures.\n\nDescription above from the Wikipedia article Jon Favreau, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "biography": "Jonathan Kolia Favreau (/ˈfævroʊ/ FAV-roh; born October 19, 1966) is an American filmmaker and actor. As an actor, Favreau has appeared in films such as Rudy (1993), PCU (1994), Swingers (1996), Very Bad Things (1998), Deep Impact (1998), The Replacements (2000), Daredevil (2003), The Break-Up (2006), Four Christmases (2008), Couples Retreat (2009), I Love You, Man (2009), People Like Us (2012), The Wolf of Wall Street (2013), and Chef (2014).\n\nAs a filmmaker, Favreau has been significantly involved with the Marvel Cinematic Universe. He directed, produced, and appeared as Happy Hogan in the films Iron Man (2008) and Iron Man 2 (2010). He also served as an executive producer for and/or appeared as the character in the films The Avengers (2012), Iron Man 3 (2013), Avengers: Age of Ultron (2015), Spider-Man: Homecoming (2017), Avengers: Infinity War (2018), Avengers: Endgame (2019), Spider-Man: Far From Home (2019), Spider-Man: No Way Home (2021), and Deadpool \u0026 Wolverine (2024).\n\nHe has also directed the films Elf (2003), Zathura: A Space Adventure (2005), Cowboys \u0026 Aliens (2011), Chef (2014), The Jungle Book (2016), The Lion King (2019), and The Mandalorian \u0026 Grogu (2026). Recently, Favreau has been known for his work on the Star Wars franchise with Dave Filoni, creating the Disney+ original series The Mandalorian (2019–present), which Filoni helped develop, with both serving as executive producers. Alongside Filoni, he serves as an executive producer on all of the show's spin-off series, including The Book of Boba Fett, Ahsoka, and the upcoming Skeleton Crew. He produces films under his production company banner, Fairview Entertainment, and also presented the variety series Dinner for Five and the cooking series The Chef Show.\n\nDescription above from the Wikipedia article Jon Favreau, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": "1966-10-19", "death": null, - "birthplace": "Queens, New York, USA", - "homepage": null + "birthplace": "Queens, New York City, New York, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-13T20:04:33.000Z" } }, { @@ -266,13 +372,22 @@ "slug": "peter-billingsley", "imdb": "nm0082526", "tmdb": 12708, - "tvrage": 73749 + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": "officialpeterb", + "wikipedia": null }, - "biography": "From Wikipedia, the free encyclopedia.  \n\nPeter Billingsley (born 16 April 1971), also known as Peter Michaelsen and Peter Billingsley-Michaelsen, is an American actor, director, and producer best known for his role as Ralphie in the 1983 movie A Christmas Story. He began his career as an infant, in television commercials.\n\nDescription above from the Wikipedia article Peter Billingsley licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "biography": "Peter Billingsley (born April 16, 1971), also known as Peter Michaelsen and Peter Billingsley-Michaelsen, is an American actor, director, and producer. His acting roles include Ralphie Parker in the 1983 movie A Christmas Story, Jack Simmons in The Dirt Bike Kid, Billy in Death Valley, and as Messy Marvin in Hershey's chocolate syrup commercials during the 1980s. He began his career as an infant in television commercials.\n\nDescription above from the Wikipedia article Peter Billingsley, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": "1971-04-16", "death": null, - "birthplace": "New York City, New York, US", - "homepage": null + "birthplace": "New York City, New York, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-12-21T08:10:50.000Z" } }, { @@ -287,13 +402,22 @@ "slug": "tim-guinee", "imdb": "nm0347375", "tmdb": 40275, - "tvrage": 27953 + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null }, "biography": "Tim Guinee (born November 18, 1962) is an American stage, television, and film actor.", "birthday": "1962-11-18", "death": null, "birthplace": "Los Angeles, California, USA", - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-12-04T08:08:39.000Z" } }, { @@ -308,13 +432,22 @@ "slug": "will-lyman", "imdb": "nm0528164", "tmdb": 163671, - "tvrage": 33553 + "tvrage": null }, - "biography": "", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "William Lyman (born May 20, 1948) is an American actor. Being known for his polished, resonant voice, Lyman has narrated the PBS series Frontline since its second season in 1984 and played William Tell in the action/adventure television series Crossbow.", "birthday": "1948-05-20", "death": null, - "birthplace": null, - "homepage": null + "birthplace": "Burlington, Vermont, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-13T08:06:53.000Z" } }, { @@ -329,13 +462,22 @@ "slug": "tom-morello", "imdb": "nm0603780", "tmdb": 78299, - "tvrage": 210054 + "tvrage": null }, - "biography": "Tom Morello was born on May 30, 1964 in New York city. He has become an influential guitarist due to his work with Rage Against The Machine in the 1990s. Tom got his Social Studies degree from Harvard and went to L.A to start a band. He briefly played guitarist for a band named Lock Up with fellow Rage member Zack De La Rocha. IMDb Mini Biography By: Anonymous", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Tom Morello was born on May 30, 1964 in New York city. He has become an influential guitarist due to his work with Rage Against The Machine in the 1990s. Tom got his Social Studies degree from Harvard and went to L.A to start a band. He briefly played guitarist for a band named Lock Up with fellow Rage member Zack De La Rocha. IMDb Mini Biography.", "birthday": "1964-05-30", "death": null, "birthplace": "New York City, New York, USA", - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-16T08:07:12.000Z" } }, { @@ -352,11 +494,20 @@ "tmdb": 54809, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": "Tehran, Iran", - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-12-28T08:08:42.000Z" } }, { @@ -373,11 +524,20 @@ "tmdb": 944830, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": "dastankhalili", + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-05-28T08:08:15.000Z" } }, { @@ -386,19 +546,28 @@ "Guard" ], "person": { - "name": "Ido Ezra", + "name": "Ido Mor", "ids": { "trakt": 16285, - "slug": "ido-ezra", + "slug": "ido-mor", "imdb": "nm2789241", "tmdb": 1209417, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-09-20T08:03:01.000Z" } }, { @@ -415,11 +584,20 @@ "tmdb": 95698, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, - "birthplace": null, - "homepage": null + "birthplace": "Las Vegas, Nevada, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-01-20T08:06:47.000Z" } }, { @@ -436,11 +614,20 @@ "tmdb": 1209418, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": "USA", - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-09-20T08:03:01.000Z" } }, { @@ -457,11 +644,20 @@ "tmdb": 62037, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "crew", + "gender": "female", + "updated_at": "2025-02-21T08:07:16.000Z" } }, { @@ -476,13 +672,22 @@ "slug": "ahmed-ahmed", "imdb": "nm0014135", "tmdb": 183439, - "tvrage": 36506 + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null }, "biography": "", "birthday": "1970-06-27", "death": null, "birthplace": "Helwan, Egypt", - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-05-30T08:10:00.000Z" } }, { @@ -499,11 +704,20 @@ "tmdb": 109669, "tvrage": null }, - "biography": "Born and raised in Kabul, Afghanistan, Fahim Fazli came to the United States as a refugee in his teens. He enjoyed a privileged childhood until the Russians invaded Afghanistan. As a young adult he supported the resistance and when he and his remaining family saw the opportunity they fled to Pakistan and then eventually to the United States. Fahim lives in Orange County, California. Fahim has written a memoir, Fahim Speaks, to be released in early 2012.", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "We are all born with the ability to dream. Somehow between childhood and adulthood people lose that ability. Fahim Fazli is \"A man of two worlds\" Afghanistan, the country of his birth, and the United States, the nation he adopted and learned to love. Fahim is also a man who escaped oppression, found his dream profession, and then Played it all forward by returning to Afghanistan as an interpreter with the U.S. Marines from 2009-2010. He came to the United States as a refugee in his teens. He enjoyed a privileged childhood until the Soviet Union invaded Afghanistan. As a young adult he supported the resistance and when he and his remaining family saw the opportunity they fled to Pakistan and then eventually to the United States. He moved to California with dreams of an acting career. Fahim wrote a memoir, Fahim Speaks, that was released in early 2012. \"Fahim Speaks\" received the 1st place for a biography from the Military Writers Society of America.\n\n- IMDb Mini Biography", "birthday": "1966-05-30", "death": null, "birthplace": "Kabul, Afghanistan", - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-18T08:07:21.000Z" } }, { @@ -520,11 +734,20 @@ "tmdb": 104669, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-04-25T08:08:18.000Z" } }, { @@ -541,11 +764,20 @@ "tmdb": 1209419, "tvrage": null }, - "biography": "After serving for five years in the British Royal Navy as a Mine Clearance Diver, Tim came from England to Hollywood in 1992 to pursue his dream of a career as a stuntman. For the last 25 years, Tim has worked on most of the big budget action movies, and has been fortunate to have played a key role, stunt-wise in many of them. He has crashed, slid, jumped, and turned over cars, (“Mr & Mrs Smith”, “Dukes of Hazzard”, “Man of the House”, Fast & Furious 4), performed high falls and skydives; he has been strapped to an ejection seat during freefall (“Stealth” & Iron Man), BASE jumped from a moving car, (“xXx”), BASE jumped into Downtown L.A. (“Along Came Polly”), BASE jumped live into the 2004 Superbowl at Houston Reliant Stadium (in front of 80,000 people), flown his wingsuit over L.A. and landed in Paramount studios, (“World Stunt Awards”), Spent hours, days, & weeks underwater (“Sphere & “The Hunley” \"Man of Steel), been involved in extensive fight & weapons based work, with the finest fight choreographers in the business, (“300”), as well as all of the “nuts & bolts” of stunt work; falls, wire-work, crashes, fire burns, etc.\n\nDoubles: Brian Van Holt, Thomas Hayden Church, Daniel Bernhardt, Brian Thomson, Rupert Everett, Rhys Ifans, Dash Mihok.\n\nAwards & Achievements:\n\n2002 World Stunt Awards Nominee, Best Specialty Stunt: Swordfish\n\n2003 World Stunt Awards Winner, Best Specialty Stunt: xXx\n\n2003 World Stunt Awards Nominee, Best Overall Stunt: xXx\n\n2005 World Stunt Awards Nominee, Best Specialty Stunt: Along Came Polly\n\n2008 Screen Actors Guild Awards Nominee, Outstanding Performance By A Stunt Ensemble: 300\n\n2008 World Stunt Awards Winner, Best Fight: 300\n\n2009 Screen Actors Guild Awards Nominee, Outstanding Performance By A Stunt Ensemble\n\n2010 World Stunt Awards Nominee, Best Fire Stunt: Gamer\n\n2010 World Stunt Awards Nominee, Best Work With A Vehicle: Fast & Furious 4\n\n2001 World Stunt Awards Nominee, Best Water Work: Perfect Storm", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "After serving for five years in the British Royal Navy as a Mine Clearance Diver, Tim came from England to Hollywood in 1992 to pursue his dream of a career as a stuntman. For the last 25 years, Tim has worked on most of the big budget action movies, and has been fortunate to have played a key role, stunt-wise in many of them. He has crashed, slid, jumped, and turned over cars, (“Mr \u0026 Mrs Smith”, “Dukes of Hazzard”, “Man of the House”, Fast \u0026 Furious 4), performed high falls and skydives; he has been strapped to an ejection seat during freefall (“Stealth” \u0026 Iron Man), BASE jumped from a moving car, (“xXx”), BASE jumped into Downtown L.A. (“Along Came Polly”), BASE jumped live into the 2004 Superbowl at Houston Reliant Stadium (in front of 80,000 people), flown his wingsuit over L.A. and landed in Paramount studios, (“World Stunt Awards”), Spent hours, days, \u0026 weeks underwater (“Sphere \u0026 “The Hunley” \"Man of Steel), been involved in extensive fight \u0026 weapons based work, with the finest fight choreographers in the business, (“300”), as well as all of the “nuts \u0026 bolts” of stunt work; falls, wire-work, crashes, fire burns, etc.\n\nDoubles: Brian Van Holt, Thomas Hayden Church, Daniel Bernhardt, Brian Thomson, Rupert Everett, Rhys Ifans, Dash Mihok.\n\nAwards \u0026 Achievements:\n\n2002 World Stunt Awards Nominee, Best Specialty Stunt: Swordfish\n\n2003 World Stunt Awards Winner, Best Specialty Stunt: xXx\n\n2003 World Stunt Awards Nominee, Best Overall Stunt: xXx\n\n2005 World Stunt Awards Nominee, Best Specialty Stunt: Along Came Polly\n\n2008 Screen Actors Guild Awards Nominee, Outstanding Performance By A Stunt Ensemble: 300\n\n2008 World Stunt Awards Winner, Best Fight: 300\n\n2009 Screen Actors Guild Awards Nominee, Outstanding Performance By A Stunt Ensemble\n\n2010 World Stunt Awards Nominee, Best Fire Stunt: Gamer\n\n2010 World Stunt Awards Nominee, Best Work With A Vehicle: Fast \u0026 Furious 4\n\n2001 World Stunt Awards Nominee, Best Water Work: Perfect Storm", "birthday": null, "death": null, "birthplace": null, - "homepage": "http://www.timrigby.com/" + "homepage": "http://www.timrigby.com/", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-16T08:11:43.000Z" } }, { @@ -562,11 +794,20 @@ "tmdb": 195442, "tvrage": null }, - "biography": "", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Russell Richardson is an American film actor.", "birthday": null, "death": null, - "birthplace": null, - "homepage": null + "birthplace": "USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-09-25T08:02:27.000Z" } }, { @@ -581,13 +822,22 @@ "slug": "nazanin-boniadi", "imdb": "nm2258164", "tmdb": 142213, - "tvrage": 186060 + "tvrage": null + }, + "social_ids": { + "twitter": "NazaninBoniadi", + "facebook": "OfficialNazaninBoniadi", + "instagram": "nazaninboniadi", + "wikipedia": null }, "biography": "Nazanin Boniadi was born in Tehran, Iran, at the height of the Iranian Revolution; her parents relocated to London shortly thereafter. She performed violin and ballet as a young girl.\n\nShe attended a private high school and later moved to the United States where she earned a bachelor's degree, with honors, in biological sciences from the University of California, Irvine. At UCI, she won the Chang Pin-Chun Undergraduate Research Award for molecular research involving cancer treatment and heart transplant rejection. She was also Assistant Editor-in-Chief of MedTimes, UCI's undergraduate medical newspaper.\n\nNazanin Boniadi is rapidly making her mark in both film and television. She co-starred as CIA analyst Fara Sherazi on seasons three and four of the Emmy and Golden Globe award-winning drama Homeland (2011), for which she shared a 2015 SAG Award nomination in the Outstanding Performance by an Ensemble in a Drama Series category. Boniadi appeared in the 2016 MGM-Paramount remake of Ben-Hur. Directed by Timur Bekmambetov, the film stars Ms. Boniadi in the female lead role of Esther opposite Jack Huston, Morgan Freeman and Toby Kebbell. She will next appear in a leading role opposite Armie Hammer and Dev Patel in Anthony Maras's Hotel Mumbai.\n\nAmong her many television credits, Boniadi portrayed Nora, a relatively longstanding love interest to Neil Patrick Harris's Barney Stinson, in seasons six and seven of How I Met Your Mother (2005). She also appeared as the notorious Adnan Salif in season three of Shonda Rhimes' hit political drama Scandal (2012). She will next star alongside J.K. Simmons in the original Starz series Counterpart (2017), created by Justin Marks and Executive Produced by Morten Tyldum.\n\nOn film, Boniadi appeared as Amira Ahmed in Jon Favreau's Iron Man (2008) and portrayed a young mother, Elaine, in Paul Haggis' The Next Three Days (2010). She also has several independent features to her credit.\n\nBorn in Tehran at the height of the Iranian Revolution, Boniadi's parents relocated to London, England, shortly thereafter, where she was raised with an emphasis on education. While she was involved in theatre early in life, Boniadi later decided she wanted to become a physician. She moved to the United States at the age of 19 to attend the University of California, Irvine, where she received her Bachelor's Degree, with Honors (Dean's Academic Achievement and Service Award) in Biological Sciences, and won the \"Chang Pin Chun\" Undergraduate Research Award for her work in heart-transplant rejection and cancer research.\n\nSwitching gears to pursue her first love, Boniadi then decided to study acting, which included training in Contemporary Drama at the Royal Academy of Dramatic Arts in London under the supervision of dramaturge Lloyd Trott.\n\nBoniadi is fluent in both English and Persian. She is a dedicated human rights activist. Boniadi served as a spokesperson for Amnesty International USA (AIUSA) 2009-2015, and continues to partner with the non-profit as an AIUSA Artist of Conscience.", "birthday": "1980-05-22", "death": null, "birthplace": "Tehran, Iran", - "homepage": "https://nazaninboniadi.com" + "homepage": "https://nazaninboniadi.com", + "known_for_department": "acting", + "gender": "female", + "updated_at": "2025-01-22T08:10:31.000Z" } }, { @@ -604,11 +854,20 @@ "tmdb": 1209702, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-09-01T08:02:02.000Z" } }, { @@ -625,11 +884,20 @@ "tmdb": 1209703, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-09-01T08:02:02.000Z" } }, { @@ -638,19 +906,28 @@ "Woman at Craps Table" ], "person": { - "name": "Stacy Stas", + "name": "Stacy Stas Hurst", "ids": { "trakt": 16343, - "slug": "stacy-stas", + "slug": "stacy-stas-hurst-16343", "imdb": "nm1680105", "tmdb": 183037, "tvrage": null }, - "biography": "Stacy Stas is an actress.", - "birthday": null, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Stacy Stas Hurst is an actress.", + "birthday": "1982-06-17", "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2024-06-17T08:01:32.000Z" } }, { @@ -667,11 +944,20 @@ "tmdb": 1209704, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2024-09-27T08:06:33.000Z" } }, { @@ -688,11 +974,20 @@ "tmdb": 214951, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2023-04-21T09:09:14.000Z" } }, { @@ -709,11 +1004,20 @@ "tmdb": 205362, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-10-20T08:06:35.000Z" } }, { @@ -730,11 +1034,20 @@ "tmdb": 203468, "tvrage": null }, + "social_ids": { + "twitter": "jimcramer", + "facebook": "JimCramerica", + "instagram": "jimcramer", + "wikipedia": null + }, "biography": "Jim Cramer is an American television personality, former hedge fund manager, and best-selling author, best known for hosting CNBC's television show _Mad Money_.", "birthday": "1955-02-10", "death": null, "birthplace": "Wyndmoor, Pennsylvania, USA", - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-07-27T08:07:31.000Z" } }, { @@ -751,11 +1064,20 @@ "tmdb": 939869, "tvrage": null }, - "biography": "", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Donna Evans is a stuntwoman and stunt double. She is the sister of Debbie Evans, who is also a stuntwoman.", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "crew", + "gender": "female", + "updated_at": "2025-02-08T08:07:52.000Z" } }, { @@ -772,11 +1094,20 @@ "tmdb": 1209705, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-03-22T08:08:17.000Z" } }, { @@ -793,11 +1124,20 @@ "tmdb": 1209706, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2024-09-21T08:02:30.000Z" } }, { @@ -814,11 +1154,20 @@ "tmdb": 1209707, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", - "birthday": null, + "birthday": "2001-06-01", "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2024-10-31T08:12:00.000Z" } }, { @@ -835,11 +1184,20 @@ "tmdb": 1209708, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-01-14T08:16:42.000Z" } }, { @@ -856,11 +1214,20 @@ "tmdb": 1209709, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": "calliecroughwell", + "wikipedia": null + }, "biography": "", "birthday": "1995-12-20", "death": null, - "birthplace": "California, - USA", - "homepage": null + "birthplace": "California, USA", + "homepage": "https://www.istunt.com/callie-croughwell-3853", + "known_for_department": "crew", + "gender": "female", + "updated_at": "2024-10-30T08:12:16.000Z" } }, { @@ -877,11 +1244,20 @@ "tmdb": 1209710, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-08-31T08:04:34.000Z" } }, { @@ -898,11 +1274,20 @@ "tmdb": 206423, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", - "birthday": null, + "birthday": "1973-03-25", "death": null, - "birthplace": null, - "homepage": null + "birthplace": "Iran", + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2024-01-30T23:49:06.000Z" } }, { @@ -919,11 +1304,20 @@ "tmdb": 133121, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": "1957-07-07", "death": null, "birthplace": "Norwalk, Connecticut, USA", - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-09-20T08:02:19.000Z" } }, { @@ -940,11 +1334,20 @@ "tmdb": 181895, "tvrage": null }, + "social_ids": { + "twitter": "adamharrington7", + "facebook": "adam.harrington.144", + "instagram": "adamharrington", + "wikipedia": null + }, "biography": "Adam Harrington is an American film and television actor and stand-up comedian.", "birthday": null, "death": null, "birthplace": "Wellesley Hills, Massachusetts, USA", - "homepage": "https://www.theadamharrington.com" + "homepage": "https://www.theadamharrington.com", + "known_for_department": "acting", + "gender": "male", + "updated_at": "2023-11-28T14:19:50.000Z" } }, { @@ -961,11 +1364,20 @@ "tmdb": 62843, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2024-11-27T08:25:46.000Z" } }, { @@ -982,11 +1394,20 @@ "tmdb": 204606, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-09-25T08:02:29.000Z" } }, { @@ -1003,11 +1424,20 @@ "tmdb": 210842, "tvrage": null }, - "biography": "Ricki Lander is an actress and producer.", - "birthday": null, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Ricki Noel Lander (born December 14, 1979) is an American actress, model, designer, and entrepreneur.", + "birthday": "1979-12-14", "death": null, - "birthplace": null, - "homepage": null + "birthplace": "Salt Lake City, Utah, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2025-01-26T08:08:54.000Z" } }, { @@ -1024,11 +1454,20 @@ "tmdb": 205720, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": "Columbus, Georgia, USA", - "homepage": "https://sunorstars.com/our-team/jeannine-kaspar/" + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2024-09-20T08:02:26.000Z" } }, { @@ -1045,11 +1484,20 @@ "tmdb": 1005698, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2024-09-25T08:03:31.000Z" } }, { @@ -1064,13 +1512,22 @@ "slug": "stan-lee", "imdb": "nm0498278", "tmdb": 7624, - "tvrage": 37350 + "tvrage": null + }, + "social_ids": { + "twitter": "TheRealStanLee", + "facebook": "realstanlee", + "instagram": "therealstanlee", + "wikipedia": null }, - "biography": "Stan Lee (born Stanley Martin Lieber, December 28, 1922 – November 12, 2018)  was an American comic book writer, editor, actor, producer, publisher, television personality, and the former president and chairman of Marvel Comics.\n\nIn collaboration with several artists, most notably Jack Kirby and Steve Ditko, he co-created Spider-Man, the Fantastic Four, the X-Men, the Avengers, Iron Man, the Hulk, Thor, Daredevil, Doctor Strange, and many other fictional characters, introducing complex, naturalistic characters and a thoroughly shared universe into superhero comic books. In addition, he headed the first major successful challenge to the industry's censorship organization, the Comics Code Authority, and forced it to reform its policies. Lee subsequently led the expansion of Marvel Comics from a small division of a publishing house to a large multimedia corporation.\n\nHe was inducted into the comic book industry's Will Eisner Comic Book Hall of Fame in 1994 and the Jack Kirby Hall of Fame in 1995.", + "biography": "Stan Lee (born Stanley Martin Lieber /ˈliːbər/; December 28, 1922–November 12, 2018) was an American comic book writer, editor, publisher, and producer. He rose through the ranks of a family-run business called Timely Comics, which later became Marvel Comics. He was Marvel's primary creative leader for two decades, expanding it from a small publishing house division to a multimedia corporation that dominated the comics and film industries.\n\nIn collaboration with others at Marvel—particularly co-writers and artists Jack Kirby and Steve Ditko—he co-created iconic characters, including Spider-Man, the X-Men, Iron Man, Thor, the Hulk, Ant-Man, the Wasp, the Fantastic Four, Black Panther, Daredevil, Doctor Strange, the Scarlet Witch, and Black Widow. These and other characters' introductions in the 1960s pioneered a more naturalistic approach in superhero comics. In the 1970s, Lee challenged the restrictions of the Comics Code Authority, indirectly leading to changes in its policies. In the 1980s, he pursued the development of Marvel properties in other media, with mixed results.\n\nFollowing his retirement from Marvel in the 1990s, Lee remained a public figurehead for the company. He frequently made cameo appearances in films and television shows based on Marvel properties, on which he received an executive producer credit, which allowed him to become the person with the highest-grossing film total ever. He continued independent creative ventures until his death, aged 95, in 2018. Lee was inducted into the comic book industry's Will Eisner Award Hall of Fame in 1994 and the Jack Kirby Hall of Fame in 1995. He received the NEA's National Medal of Arts in 2008.\n\nDescription above from the Wikipedia article Stan Lee, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": "1922-12-28", "death": "2018-11-12", "birthplace": "New York City, New York, USA", - "homepage": "http://therealstanlee.com/" + "homepage": "https://therealstanlee.com/", + "known_for_department": "writing", + "gender": "male", + "updated_at": "2025-02-15T08:09:58.000Z" } }, { @@ -1087,11 +1544,20 @@ "tmdb": 1209711, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-08-31T08:04:34.000Z" } }, { @@ -1108,11 +1574,20 @@ "tmdb": 90721, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2022-09-03T08:04:11.000Z" } }, { @@ -1127,13 +1602,22 @@ "slug": "lana-kinnear", "imdb": "nm0455698", "tmdb": 169681, - "tvrage": 49259 + "tvrage": null + }, + "social_ids": { + "twitter": "REALLANASTAR", + "facebook": "wowlana", + "instagram": "misslanaashleigh", + "wikipedia": null }, "biography": "Lana Kinnear is an American film and television actress and professional wrestler.", "birthday": "1976-07-29", "death": null, "birthplace": "Burbank, California, USA", - "homepage": "http://absolutelana.com" + "homepage": "http://absolutelana.com", + "known_for_department": "acting", + "gender": "female", + "updated_at": "2025-02-17T08:06:37.000Z" } }, { @@ -1150,11 +1634,20 @@ "tmdb": 1209712, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2022-05-23T08:09:38.000Z" } }, { @@ -1171,11 +1664,20 @@ "tmdb": 1209713, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2024-09-27T08:06:33.000Z" } }, { @@ -1190,13 +1692,22 @@ "slug": "gabrielle-tuite", "imdb": "nm0876266", "tmdb": 169642, - "tvrage": 58572 + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null }, - "biography": "Gabrielle Tuite is an American actress and model best known for her five years on CBS's \"The Price is Right\". She has appeared on the covers of international magazines Maxim, Stuff, Muscle & Fitness, Iron Man, People and Men's Health.", + "biography": "Gabrielle Tuite is an American actress and model best known for her five years on CBS's \"The Price is Right\". She has appeared on the covers of international magazines Maxim, Stuff, Muscle \u0026amp; Fitness, Iron Man, People and Men's Health.", "birthday": "1977-12-03", "death": null, "birthplace": "Brooklyn, New York, USA", - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2024-07-16T08:01:36.000Z" } }, { @@ -1213,11 +1724,20 @@ "tmdb": 27031, "tvrage": null }, - "biography": "", - "birthday": null, + "social_ids": { + "twitter": "thetimgriffin", + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Tim Griffin is an American film and television actor. his more notable television roles are as Adam Hassler on Wayward Pines, Ron Kellaher on Aquarius, Ronny O'Malley on Grey's Anatomy, Seth Newman on Covert Affairs, and Detective Augie Blando on Prime Suspect.\n\nOften dubbed \"\"the actor who is in everything\", he has had an extensive career in television and film. Some of his film credits include Cloverfield, Leatherheads, The Men Who Stare at Goats, A Better Life, American Sniper, Super 8, Abduction, and Central Intelligence.", + "birthday": "1969-01-01", "death": null, "birthplace": "Chicago, Illinois, USA", - "homepage": "http://timgriffin.org/timgriffin.org/TIMGRIFFIN.ORG.html" + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-06-27T08:09:15.000Z" } }, { @@ -1232,13 +1752,22 @@ "slug": "joshua-harto", "imdb": "nm0367176", "tmdb": 34544, - "tvrage": 233807 + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null }, "biography": "", "birthday": "1979-01-09", "death": null, - "birthplace": null, - "homepage": null + "birthplace": "Huntington, West Virginia, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-01-31T08:10:54.000Z" } }, { @@ -1251,15 +1780,24 @@ "ids": { "trakt": 15575, "slug": "micah-a-hauptman", - "imdb": null, + "imdb": "nm1275916", "tmdb": 150669, "tvrage": null }, - "biography": "", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Micah Hauptman was born on December 26, 1973 in Philadelphia, Pennsylvania, USA. He is an actor and producer, known for Everest (2015), Homeland (2011) and Parker (2013).", "birthday": "1973-12-26", "death": null, - "birthplace": null, - "homepage": null + "birthplace": "Philadelphia, Pennsylvania, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-02T08:10:17.000Z" } }, { @@ -1274,98 +1812,22 @@ "slug": "james-bethea", "imdb": "nm1084018", "tmdb": 1209714, - "tvrage": 83621 + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null }, "biography": "James A. Bethea Jr. is an American television writer, producer and occasional performer.", "birthday": "1965-01-14", "death": null, "birthplace": "Harlem, New York City, New York, USA", - "homepage": null - } - }, - { - "character": "Nick Fury (uncredited), Nick Fury", - "characters": [ - "Nick Fury (uncredited)", - "Nick Fury" - ], - "person": { - "name": "Samuel L. Jackson", - "ids": { - "trakt": 9486, - "slug": "samuel-l-jackson", - "imdb": "nm0000168", - "tmdb": 2231, - "tvrage": 55720 - }, - "biography": "An American film and television actor and film producer. After Jackson became involved with the Civil Rights Movement, he moved on to acting in theater at Morehouse College, and then films. He had several small roles such as in the film Goodfellas, Def by Temptation, before meeting his mentor, Morgan Freeman, and the director Spike Lee. After gaining critical acclaim for his role in Jungle Fever in 1991, he appeared in films such as Patriot Games, Amos & Andrew, True Romance and Jurassic Park. In 1994 he was cast as Jules Winnfield in Pulp Fiction, and his performance received several award nominations and critical acclaim.\n\nJackson has since appeared in over 100 films including Die Hard with a Vengeance, The 51st State, Jackie Brown, Unbreakable, The Incredibles, Black Snake Moan, Shaft, Snakes on a Plane, as well as the Star Wars prequel trilogy and small roles in Quentin Tarantino's Kill Bill Vol. 2 and Inglourious Basterds. He played Nick Fury in Iron Man and Iron Man 2, Thor, the first two of a nine-film commitment as the character for the Marvel Cinematic Universe franchise. Jackson's many roles have made him one of the highest grossing actors at the box office. Jackson has won multiple awards throughout his career and has been portrayed in various forms of media including films, television series, and songs. In 1980, Jackson married LaTanya Richardson, with whom he has one daughter, Zoe.\n\nDescription above from the Wikipedia article Samuel L. Jackson, licensed under CC-BY-SA, full list of contributors on Wikipedia.", - "birthday": "1948-12-21", - "death": null, - "birthplace": "Washington, D.C., USA", - "homepage": null - } - }, - { - "character": "Photographer (uncredited)", - "characters": [ - "Photographer (uncredited)" - ], - "person": { - "name": "Jeffrey Ashkin", - "ids": { - "trakt": 15578, - "slug": "jeffrey-ashkin", - "imdb": "nm1644068", - "tmdb": 1209715, - "tvrage": null - }, - "biography": "", - "birthday": "1983-11-19", - "death": null, - "birthplace": "Brooklyn - New York - USA", - "homepage": null - } - }, - { - "character": "Georgio (uncredited)", - "characters": [ - "Georgio (uncredited)" - ], - "person": { - "name": "Russell Bobbitt", - "ids": { - "trakt": 15582, - "slug": "russell-bobbitt", - "imdb": "nm0090309", - "tmdb": 1004624, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "Fireman's Wife (uncredited)", - "characters": [ - "Fireman's Wife (uncredited)" - ], - "person": { - "name": "Vianessa Castaños", - "ids": { - "trakt": 15583, - "slug": "vianessa-castanos", - "imdb": "nm3095343", - "tmdb": 984619, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-09-27T08:06:33.000Z" } }, { @@ -1382,95 +1844,80 @@ "tmdb": 1209716, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "Mike Cochrane is Actor and Stunts.", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-09-19T08:43:33.000Z" } }, { - "character": "Dubai Beauty (uncredited)", + "character": "Reporter (uncredited)", "characters": [ - "Dubai Beauty (uncredited)" + "Reporter (uncredited)" ], "person": { - "name": "Crystal Marie Denha", + "name": "Flavia Manes Rossi", "ids": { - "trakt": 15586, - "slug": "crystal-marie-denha", - "imdb": "nm2407968", - "tmdb": 1209717, + "trakt": 15598, + "slug": "flavia-manes-rossi", + "imdb": "nm1098431", + "tmdb": 1089759, "tvrage": null }, - "biography": "", - "birthday": "1984-02-08", - "death": null, - "birthplace": "Detroit, Michigan, USA", - "homepage": null - } - }, - { - "character": "Dubai Girl (uncredited)", - "characters": [ - "Dubai Girl (uncredited)" - ], - "person": { - "name": "Mellany Gandara", - "ids": { - "trakt": 15588, - "slug": "mellany-gandara", - "imdb": "nm2081556", - "tmdb": 970218, - "tvrage": null + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2022-11-18T08:03:51.000Z" } }, { - "character": "House wife at Award Ceremony (uncredited)", + "character": "Nick Fury (uncredited)", "characters": [ - "House wife at Award Ceremony (uncredited)" + "Nick Fury (uncredited)" ], "person": { - "name": "Halla", + "name": "Samuel L. Jackson", "ids": { - "trakt": 15589, - "slug": "halla", - "imdb": "nm2955994", - "tmdb": 1209718, + "trakt": 9486, + "slug": "samuel-l-jackson", + "imdb": "nm0000168", + "tmdb": 2231, "tvrage": null }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "Insurgent (uncredited)", - "characters": [ - "Insurgent (uncredited)" - ], - "person": { - "name": "Rodrick Hersh", - "ids": { - "trakt": 15590, - "slug": "rodrick-hersh", - "imdb": "nm2555437", - "tmdb": 1202546, - "tvrage": null + "social_ids": { + "twitter": "SamuelLJackson", + "facebook": "samuelljackson", + "instagram": "samuelljackson", + "wikipedia": null }, - "biography": "", - "birthday": null, + "biography": "Samuel Leroy Jackson (born December 21, 1948) is an American actor and producer. One of the most widely recognized actors of his generation, the films in which he has appeared have collectively grossed over $27 billion worldwide, making him the second highest-grossing actor of all time. The Academy of Motion Picture Arts and Sciences gave him an Academy Honorary Award in 2022 as \"A cultural icon whose dynamic work has resonated across genres and generations and audiences worldwide\".\n\nJackson started his career on stage making his professional theatre debut in Mother Courage and her Children in 1980 at The Public Theatre. From 1981 to 1983 he originated the role of Private Louis Henderson in A Soldier's Play Off-Broadway. He also originated the role of Boy Willie in August Wilson's The Piano Lesson in 1987 at the Yale Repertory Theatre. He returned to the play in the 2022 Broadway revival playing Doaker Charles. Jackson early film roles include Coming to America (1988), Goodfellas (1990), Patriot Games (1992), Juice (1992), True Romance (1993), and Jurassic Park (1993), Menace II Society (1993), and Fresh (1994). His collaborations with Spike Lee led to greater prominence with films such as School Daze (1988), Do the Right Thing (1989), Mo' Better Blues (1990), Jungle Fever (1991), Oldboy (2013), and Chi-Raq (2015).\n\nJackson's breakout role was in Quentin Tarantino's Pulp Fiction (1994) which earned him a BAFTA Award win and a nomination for the Academy Award for Best Supporting Actor. He further collaborated with Tarantino, acting in Jackie Brown (1997), Django Unchained (2012), and The Hateful Eight (2015). He's known for having appeared in a number of big-budget films, including Die Hard with a Vengeance (1995), A Time to Kill (1996), The Long Kiss Goodnight (1996), The Negotiator (1997), Deep Blue Sea (1999), Unbreakable (2000), Shaft (2000) and its reboot (2019), XXX (2002), S.W.A.T. (2003), Coach Carter (2005), Snakes on a Plane (2006), Kingsman: The Secret Service (2014), Kong: Skull Island (2017), and Glass (2019).\n\nHe also gained widespread recognition as the Jedi Mace Windu in the Star Wars prequel trilogy (1999–2005), later voicing the role in the animated film Star Wars: The Clone Wars (2008) and the video game Lego Star Wars: The Clone Wars (2011). With his permission, his likeness was used for the Ultimate version of the Marvel Comics character Nick Fury; he subsequently played Fury in 11 Marvel Cinematic Universe films, beginning with a cameo appearance in Iron Man (2008), as well as guest-starring in the television series Agents of S.H.I.E.L.D. He will reprise this role in the upcoming Disney+ series Secret Invasion, which is set to premiere on June 21, 2023. Jackson has provided his voice for several animated films, documentaries, television series, and video games, including Lucius Best / Frozone in the Pixar films The Incredibles (2004) and Incredibles 2 (2018).", + "birthday": "1948-12-21", "death": null, - "birthplace": null, - "homepage": null + "birthplace": "Washington, D.C., USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-22T08:11:44.000Z" } }, { @@ -1481,764 +1928,5113 @@ "person": { "name": "Kristin J. Hooper", "ids": { - "trakt": 15592, - "slug": "kristin-j-hooper", + "trakt": 3136924, + "slug": "kristin-j-hooper-8808815a-ce4b-4009-9316-61707c9ace33", "imdb": "nm3923280", - "tmdb": 1209719, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "Dubai Waiter (uncredited)", - "characters": [ - "Dubai Waiter (uncredited)" - ], - "person": { - "name": "Chris Jalandoni", - "ids": { - "trakt": 15593, - "slug": "chris-jalandoni", - "imdb": "nm1599104", - "tmdb": 1209720, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "Party Guest (uncredited)", - "characters": [ - "Party Guest (uncredited)" - ], - "person": { - "name": "Stephen Janousek", - "ids": { - "trakt": 15594, - "slug": "stephen-janousek", - "imdb": "nm2999525", - "tmdb": 1209721, + "tmdb": 3955051, "tvrage": null }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "Dancer in Ballroom (uncredited)", - "characters": [ - "Dancer in Ballroom (uncredited)" - ], - "person": { - "name": "Laura Liguori", - "ids": { - "trakt": 15597, - "slug": "laura-liguori", - "imdb": "nm2266584", - "tmdb": 1209722, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "Reporter (uncredited)", - "characters": [ - "Reporter (uncredited)" - ], - "person": { - "name": "Flavia Manes Rossi", - "ids": { - "trakt": 15598, - "slug": "flavia-manes-rossi", - "imdb": "nm1098431", - "tmdb": 1089759, - "tvrage": null + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null - } - }, - { - "character": "Village Dad (uncredited)", - "characters": [ - "Village Dad (uncredited)" - ], - "person": { - "name": "Anthony Martins", - "ids": { - "trakt": 15601, - "slug": "anthony-martins", - "imdb": "nm1215266", - "tmdb": 1096679, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "Reporter (uncredited)", - "characters": [ - "Reporter (uncredited)" - ], - "person": { - "name": "Robert McMurrer", - "ids": { - "trakt": 15602, - "slug": "robert-mcmurrer", - "imdb": "nm2258412", - "tmdb": 1209723, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "Airforce Officer (uncredited)", - "characters": [ - "Airforce Officer (uncredited)" - ], - "person": { - "name": "James M. Myers", - "ids": { - "trakt": 15604, - "slug": "james-m-myers", - "imdb": "nm2653588", - "tmdb": 1209724, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "Dubai Beauty #1 (uncredited)", - "characters": [ - "Dubai Beauty #1 (uncredited)" - ], - "person": { - "name": "America Olivo", - "ids": { - "trakt": 16432, - "slug": "america-olivo", - "imdb": "nm1760388", - "tmdb": 78434, - "tvrage": 31732 - }, - "biography": "From Wikipedia, the free encyclopedia.\n\nAmerica Olivo (a.k.a. America Campbell; born January 5, 1978) is an actress and singer most notable for her membership in the Spanish- & English-language band Soluna. Born in Los Angeles, she has multiple citizenships: US, Canadian, and Italian. Born to father Nello Olivo and mother Danica d'Hondt (Miss Canada 1959). She is currently appearing on Broadway as Arachne in Spider-Man: Turn Off the Dark.", - "birthday": "1978-01-05", - "death": null, - "birthplace": "Van Nuys, California, USA", - "homepage": "https://www.americaolivo.com/" - } - }, - { - "character": "Staff Sergeant (uncredited)", - "characters": [ - "Staff Sergeant (uncredited)" - ], - "person": { - "name": "Sylvette Ortiz", - "ids": { - "trakt": 15610, - "slug": "sylvette-ortiz", - "imdb": "nm3066181", - "tmdb": 1209725, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "Journalist (uncredited)", - "characters": [ - "Journalist (uncredited)" - ], - "person": { - "name": "Brett Padelford", - "ids": { - "trakt": 15611, - "slug": "brett-padelford", - "imdb": "nm2703513", - "tmdb": 1209726, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "Voice (uncredited)", - "characters": [ - "Voice (uncredited)" - ], - "person": { - "name": "Ajani Perkins", - "ids": { - "trakt": 15612, - "slug": "ajani-perkins", - "imdb": "nm3567687", - "tmdb": 1209727, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": "Oakland, California, USA", - "homepage": null - } - }, - { - "character": "Reporter (uncredited)", - "characters": [ - "Reporter (uncredited)" - ], - "person": { - "name": "Chris Reid", - "ids": { - "trakt": 15613, - "slug": "chris-reid", - "imdb": "nm1308942", - "tmdb": 1209728, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "News Cameraman (uncredited)", - "characters": [ - "News Cameraman (uncredited)" - ], - "person": { - "name": "Toi Rose", - "ids": { - "trakt": 15614, - "slug": "toi-rose", - "imdb": "nm3649434", - "tmdb": 1209729, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "Rooftop Fireman (uncredited)", - "characters": [ - "Rooftop Fireman (uncredited)" - ], - "person": { - "name": "George F. Watson", - "ids": { - "trakt": 15616, - "slug": "george-f-watson", - "imdb": "nm1222064", - "tmdb": 1209730, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "Whiplash One (voice) (uncredited)", - "characters": [ - "Whiplash One (voice) (uncredited)" - ], - "person": { - "name": "David Zyler", - "ids": { - "trakt": 15617, - "slug": "david-zyler", - "imdb": "nm0469487", - "tmdb": 1209731, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "Reporter (uncredited)", - "characters": [ - "Reporter (uncredited)" - ], - "person": { - "name": "Nick W. Nicholson", - "ids": { - "trakt": 509352, - "slug": "nick-w-nicholson", - "imdb": "nm4414026", - "tmdb": 1429470, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "Waiter, Reporter (uncredited)", - "characters": [ - "Waiter", - "Reporter (uncredited)" - ], - "person": { - "name": "Elijah Samuel Quesada", - "ids": { - "trakt": 1259394, - "slug": "elijah-samuel-quesada", - "imdb": "nm6392685", - "tmdb": 2114972, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null - } - }, - { - "character": "", - "characters": [], - "person": { - "name": "Garrett Noel", - "ids": { - "trakt": 1791089, - "slug": "garrett-noel", - "imdb": null, - "tmdb": 2604526, - "tvrage": null - }, - "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2023-03-10T08:07:11.000Z" } } ], "crew": { - "production": [ + "crew": [ + { + "job": "Stunt Double", + "jobs": [ + "Stunt Double" + ], + "person": { + "name": "Vince Deadrick Jr.", + "ids": { + "trakt": 1128, + "slug": "vince-deadrick-jr", + "imdb": "nm0212604", + "tmdb": 12879, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": "1954-09-14", + "death": null, + "birthplace": "USA", + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-12T08:08:22.000Z" + } + }, + { + "job": "Stunt Double", + "jobs": [ + "Stunt Double" + ], + "person": { + "name": "Loyd Catlett", + "ids": { + "trakt": 1713, + "slug": "loyd-catlett", + "imdb": "nm0146178", + "tmdb": 2277, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": "1953-05-14", + "death": null, + "birthplace": "Lubbock, Texas, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-12-13T08:05:51.000Z" + } + }, + { + "job": "Executive Music Producer", + "jobs": [ + "Executive Music Producer" + ], + "person": { + "name": "Hans Zimmer", + "ids": { + "trakt": 4615, + "slug": "hans-zimmer", + "imdb": "nm0001877", + "tmdb": 947, + "tvrage": null + }, + "social_ids": { + "twitter": "HansZimmer", + "facebook": "hanszimmer", + "instagram": "hanszimmer", + "wikipedia": null + }, + "biography": "Hans Florian Zimmer (German pronunciation: [ˈhans ˈfloːʁi̯aːn ˈtsɪmɐ]; born 12 September 1957) is a German film score composer and music producer. He has won two Oscars, four Grammys, and has been nominated for three Emmys and a Tony. Zimmer was also named on the list of Top 100 Living Geniuses, published by The Daily Telegraph in 2007.\n\nHis works are notable for integrating electronic music sounds with traditional orchestral arrangements. Since the 1980s, Zimmer has composed music for over 150 films. He has won two Academy Awards for Best Original Score for The Lion King (1994) and for Dune (2021). His works include Gladiator, The Last Samurai, the Pirates of the Caribbean series, The Dark Knight trilogy, Inception, Man of Steel, Interstellar, Dunkirk, No Time to Die, and the Dune series.\n\nZimmer spent the early part of his career in the United Kingdom before moving to the United States. He is the head of the film music division at DreamWorks Pictures and DreamWorks Animation studios and works with other composers through the company that he founded, Remote Control Productions, formerly known as Media Ventures. His studio in Santa Monica, California, has an extensive range of computer equipment and keyboards, allowing demo versions of film scores to be created quickly.\n\nZimmer has collaborated on multiple projects with directors including Christopher Nolan, Ridley Scott, Ron Howard, Gore Verbinski, Michael Bay, Guy Ritchie, Denis Villeneuve, and Tony Scott.\n\nDescription above from the Wikipedia article Hans Zimmer, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1957-09-12", + "death": null, + "birthplace": "Frankfurt am Main, Germany", + "homepage": "https://hans-zimmer.com", + "known_for_department": "sound", + "gender": "male", + "updated_at": "2025-02-19T08:07:16.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Thomas Rosales Jr.", + "ids": { + "trakt": 9701, + "slug": "thomas-rosales-jr", + "imdb": "nm0740951", + "tmdb": 43010, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Thomas Rosales Jr. (born 3 February 1948) is an American stunt man who has appeared in more than one hundred and fifty movies. His first known appearance as a stuntman was in Battle for the Planet of the Apes in 1973. Rosales is arguably one of Hollywood's most recognizable stunt performers due to speaking roles, including ones where a film's protagonist wounds or kills him. His filmography includes RoboCop 2, The Crow, Tremors 2: Aftershocks, Universal Soldier, Predator 2, L. A. Confidential, Police Academy 2: Their First Assignment, U. S. Marshals, Deep Impact, The Running Man, The Hunter, Beverly Hills Cop III, Jurassic Park: The Lost World, Speed, and NCIS.", + "birthday": "1948-02-03", + "death": null, + "birthplace": "El Paso, Texas, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-16T08:06:56.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Chris Palermo", + "ids": { + "trakt": 11094, + "slug": "chris-palermo", + "imdb": "nm0657548", + "tmdb": 35546, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Chris has more than twenty years experience in all aspects of stunt work, specializing in vehicles.\n\nHis Awards \u0026 Achievements:\n\n2009 California Rally Champion G2\n\n2009 Rally America Southwest Champion G2\n\nCalifornia Rally Series: Rookie Of The Year\n\n4 Nominations: World Stunt Awards: Swordfish, Avengers, 22 Jumpstreet, Live By Night\n\n2 Wins: Sag Award, Best Stunt Ensemble, Star Trek 2010, Jason Bourne 2017\n\n6 Nominations: Sag Award, Best Stunt Ensemble 2008, 2009, 2010, 2013, 2016, 2017", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "http://www.palermoproductions.com/", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-12T08:07:41.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Mic Rodgers", + "ids": { + "trakt": 11711, + "slug": "mic-rodgers", + "imdb": "nm0734747", + "tmdb": 18889, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Mic's Experience: 40 years SAG Stunt Work, 30 years DGA 2nd Unit Dir; Mel Gibson's personal stunt double (1984-nov 1997).\n\nAwards \u0026 Achievements:\n\nAcademy Of Motion Picture Arts \u0026 Sciences Award For Technical Achievement\n\nWorld Taurus Stunt Awards- Best 2Nd Unit Dir/Stunt Coord - \"The Fast And The Furious\"\n\nWorld Taurus Stunt Awards- Best 2Nd Unit Dir/Stunt Coord - \"Fast And Furious 4\"", + "birthday": "1954-01-01", + "death": null, + "birthplace": null, + "homepage": "http://www.brandxstunts.org/members/mrodgers", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-22T12:46:34.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Max Daniels", + "ids": { + "trakt": 12140, + "slug": "max-daniels", + "imdb": "nm0200034", + "tmdb": 175600, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-01T08:08:14.000Z" + } + }, + { + "job": "Special Effects Coordinator", + "jobs": [ + "Special Effects Coordinator" + ], + "person": { + "name": "Daniel Sudick", + "ids": { + "trakt": 12238, + "slug": "daniel-sudick", + "imdb": "nm0837203", + "tmdb": 15356, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Dan Sudick is a special effects supervisor.\n\nSudick has been nominated for 13 Academy Awards for Best Visual Effects for Master and Commander: The Far Side of the World, War of the Worlds, Iron Man, Iron Man 2, The Avengers, Iron Man 3, Captain America: The Winter Soldier, Guardians of the Galaxy Vol. 2, Avengers: Infinity War, Avengers: Endgame, Free Guy, Spider-Man: No Way Home, and Black Panther: Wakanda Forever.", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "visual effects", + "gender": "male", + "updated_at": "2025-02-19T08:05:24.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Craig Stecyk", + "ids": { + "trakt": 13519, + "slug": "craig-stecyk", + "imdb": "nm0824310", + "tmdb": 20072, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "art", + "gender": "male", + "updated_at": "2024-09-23T08:01:47.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Freddie Hice", + "ids": { + "trakt": 13842, + "slug": "freddie-hice", + "imdb": "nm0382556", + "tmdb": 16618, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-06T08:27:13.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Eddie J. Fernandez", + "ids": { + "trakt": 14428, + "slug": "eddie-j-fernandez", + "imdb": "nm0272961", + "tmdb": 18300, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Eddie J. Fernandez is American award-winning stunt performer, martial artist, actor and stunt coordinator of over 300 films and television shows.", + "birthday": null, + "death": null, + "birthplace": "Chicago, Illinois, USA", + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-12T08:05:18.000Z" + } + }, + { + "job": "Stunt Coordinator", + "jobs": [ + "Stunt Coordinator" + ], + "person": { + "name": "Tim Rigby", + "ids": { + "trakt": 15499, + "slug": "tim-rigby", + "imdb": "nm0726601", + "tmdb": 1209419, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "After serving for five years in the British Royal Navy as a Mine Clearance Diver, Tim came from England to Hollywood in 1992 to pursue his dream of a career as a stuntman. For the last 25 years, Tim has worked on most of the big budget action movies, and has been fortunate to have played a key role, stunt-wise in many of them. He has crashed, slid, jumped, and turned over cars, (“Mr \u0026 Mrs Smith”, “Dukes of Hazzard”, “Man of the House”, Fast \u0026 Furious 4), performed high falls and skydives; he has been strapped to an ejection seat during freefall (“Stealth” \u0026 Iron Man), BASE jumped from a moving car, (“xXx”), BASE jumped into Downtown L.A. (“Along Came Polly”), BASE jumped live into the 2004 Superbowl at Houston Reliant Stadium (in front of 80,000 people), flown his wingsuit over L.A. and landed in Paramount studios, (“World Stunt Awards”), Spent hours, days, \u0026 weeks underwater (“Sphere \u0026 “The Hunley” \"Man of Steel), been involved in extensive fight \u0026 weapons based work, with the finest fight choreographers in the business, (“300”), as well as all of the “nuts \u0026 bolts” of stunt work; falls, wire-work, crashes, fire burns, etc.\n\nDoubles: Brian Van Holt, Thomas Hayden Church, Daniel Bernhardt, Brian Thomson, Rupert Everett, Rhys Ifans, Dash Mihok.\n\nAwards \u0026 Achievements:\n\n2002 World Stunt Awards Nominee, Best Specialty Stunt: Swordfish\n\n2003 World Stunt Awards Winner, Best Specialty Stunt: xXx\n\n2003 World Stunt Awards Nominee, Best Overall Stunt: xXx\n\n2005 World Stunt Awards Nominee, Best Specialty Stunt: Along Came Polly\n\n2008 Screen Actors Guild Awards Nominee, Outstanding Performance By A Stunt Ensemble: 300\n\n2008 World Stunt Awards Winner, Best Fight: 300\n\n2009 Screen Actors Guild Awards Nominee, Outstanding Performance By A Stunt Ensemble\n\n2010 World Stunt Awards Nominee, Best Fire Stunt: Gamer\n\n2010 World Stunt Awards Nominee, Best Work With A Vehicle: Fast \u0026 Furious 4\n\n2001 World Stunt Awards Nominee, Best Water Work: Perfect Storm", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "http://www.timrigby.com/", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-16T08:11:43.000Z" + } + }, + { + "job": "Stunt Double", + "jobs": [ + "Stunt Double" + ], + "person": { + "name": "Donna Evans", + "ids": { + "trakt": 15526, + "slug": "donna-evans", + "imdb": "nm0262719", + "tmdb": 939869, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Donna Evans is a stuntwoman and stunt double. She is the sister of Debbie Evans, who is also a stuntwoman.", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "female", + "updated_at": "2025-02-08T08:07:52.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Anthony Martins", + "ids": { + "trakt": 15601, + "slug": "anthony-martins", + "imdb": "nm1215266", + "tmdb": 1096679, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-08-16T08:09:09.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Jeff Dashnaw", + "ids": { + "trakt": 17310, + "slug": "jeff-dashnaw", + "imdb": "nm0202002", + "tmdb": 23285, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Jeffrey James Dashnaw is an American award-winning stunt coordinator and stunt performer with over 35 years experience in the stunt business. He is married to fellow stunt coordinator/stunt performer Tracy Keehn-Dashnaw; they have 5 children: their sons J.J., Chad, and Jake are also stunt coordinators/stunt performers. Their son Nick was a stunt performer until his death in 2016.", + "birthday": "1956-04-01", + "death": null, + "birthplace": "Los Angeles, California, USA", + "homepage": "http://www.brandxstunts.org/jdashnaw", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-14T08:01:16.000Z" + } + }, + { + "job": "Additional Music", + "jobs": [ + "Additional Music" + ], + "person": { + "name": "Ryeland Allison", + "ids": { + "trakt": 24367, + "slug": "ryeland-allison", + "imdb": "nm0021485", + "tmdb": 30871, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "sound", + "gender": null, + "updated_at": "2024-10-25T08:09:50.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Jason Rodriguez", + "ids": { + "trakt": 31019, + "slug": "jason-rodriguez", + "imdb": "nm0718264", + "tmdb": 75617, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Doubles: Ron Livingston, Paul Rudd.\n\nAwards \u0026 Achievements:\n\nNational Collegiate Rodeo Assoc. Top 20, Calf Roping/Team Roping\n\nBest Director 168 Film Festival - \"Day Of Reckoning\"\n\nBest Dramatic Short, International Family Film Festival Hollywood - \"Day Of Reckoning\" - Director\n\nBest Narrative, San Antonio Independent Christian Film Festival - Director\n\nTaurus Stunt Award Winner \"Best Fire” (2002)\n\nTaurus Stunt Award Nominee \"Best Work With Animal\"", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "http://www.brandxstunts.org/members/jrodriguez", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-01T08:10:03.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Mario Roberts", + "ids": { + "trakt": 32176, + "slug": "mario-roberts", + "imdb": "nm0731390", + "tmdb": 166543, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Mario Roberts (born April 4, 1952) is a stuntman and actor.", + "birthday": "1952-04-04", + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-07T08:06:49.000Z" + } + }, + { + "job": "Additional Music", + "jobs": [ + "Additional Music" + ], + "person": { + "name": "Atli Örvarsson", + "ids": { + "trakt": 38322, + "slug": "atli-orvarsson", + "imdb": "nm0651414", + "tmdb": 72268, + "tvrage": null + }, + "social_ids": { + "twitter": "AtliOrvarsson", + "facebook": null, + "instagram": "atliorvarsson", + "wikipedia": null + }, + "biography": "", + "birthday": "1970-07-07", + "death": null, + "birthplace": "Akureyri, Iceland", + "homepage": "https://www.atliorvarsson.com/", + "known_for_department": "sound", + "gender": "male", + "updated_at": "2025-02-01T08:07:50.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Geo Corvera", + "ids": { + "trakt": 38654, + "slug": "geo-corvera-38654", + "imdb": "nm1559220", + "tmdb": 53254, + "tvrage": null + }, + "social_ids": { + "twitter": "geocorvera", + "facebook": null, + "instagram": "1latingeo", + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-12-23T08:08:54.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "J.J. Perry", + "ids": { + "trakt": 45222, + "slug": "j-j-perry", + "imdb": "nm0675102", + "tmdb": 131532, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "J.J. Perry (born October 25, 1967) is a stunt performer, martial artist, film director, film producer, stunt coordinator and film actor who has made several uncredited appearances on 24.", + "birthday": "1967-10-25", + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-19T08:08:45.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Clay Cullen", + "ids": { + "trakt": 45895, + "slug": "clay-cullen", + "imdb": "nm1247999", + "tmdb": 60536, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Working in the stunt buisness since 1996.\n\nDoubles: Charlie Hunnam, Frank Grillo, Kim Coats, Martin Henderson, Peter Facinelli, Robert Carradine.\n\nAwards \u0026 Achievements:\n\n— Red Bull Watercraft Surf Challenge Champion (Ranked One Of Top Surf Riders Worldwide)\n\n— Pro Jet Ski Rookie Of The Year\n\n— Taurus Award For Best Specialty Stunt (2004; Boat Work Itailian Job)\n\n— Inductee Internatinal Jet Ski Boating Association Hall Of Fame (2008)", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "http://www.brandxstunts.org/members/ccullen", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-03T08:05:20.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Rosine 'Ace' Hatem", + "ids": { + "trakt": 47292, + "slug": "rosine-ace-hatem", + "imdb": "nm0368819", + "tmdb": 62568, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Rosine \"Ace\" Hatem (born August 5, 1960) is a stunt performer and actress.", + "birthday": "1960-08-05", + "death": null, + "birthplace": "Methuen, Massachusetts, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2024-11-23T08:11:09.000Z" + } + }, + { + "job": "Post Production Supervisor", + "jobs": [ + "Post Production Supervisor" + ], + "person": { + "name": "Luminita Docan", + "ids": { + "trakt": 48380, + "slug": "luminita-docan", + "imdb": "nm3029176", + "tmdb": 1265249, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2024-06-10T08:07:56.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Mark Kubr", + "ids": { + "trakt": 48450, + "slug": "mark-kubr", + "imdb": "nm0473578", + "tmdb": 193946, + "tvrage": null + }, + "social_ids": { + "twitter": "markkubr", + "facebook": null, + "instagram": "markkubr", + "wikipedia": null + }, + "biography": "Raised by Czech parents in America, Mark Kubr was taught to be disciplined in arts, culture, athletics and religion. Kubr's unique world-view gives him a talent for maintaining lasting friendships with people from diverse cultures.\n\nHis career began just out of high school. While bagging groceries at the local market, an actress neighbor told Mark he had what it takes for entertainment and drove him to her agency in Los Angeles. Kubr signed with the agent and immediately began working as a print model and commercial actor, booking campaigns for Marlboro, Versace, Gucci and Ralph Lauren traveling the world. He always brought his cameras with him to build his photography portfolio. His photography has been published in Vogue, Cosmopolitan and Bride magazines.\n\nHe continued modeling and acting while pursuing his education after being awarded a full athletic scholarship. His acting and athletic ability was a perfect fit for stuntwork. Kubr is Mickey Rourke's stunt double.", + "birthday": null, + "death": null, + "birthplace": "USA", + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-07T08:11:13.000Z" + } + }, + { + "job": "Stunt Coordinator", + "jobs": [ + "Stunt Coordinator" + ], + "person": { + "name": "Keith Woulard", + "ids": { + "trakt": 54113, + "slug": "keith-woulard", + "imdb": "nm0941887", + "tmdb": 92486, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Keith Woulard is a Stunts, Actor, Second Unit Director and Assistant Director.", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-21T08:01:23.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Gene Hartline", + "ids": { + "trakt": 54663, + "slug": "gene-hartline", + "imdb": "nm0366905", + "tmdb": 184792, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-01T08:07:14.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Freddy Bouciegues", + "ids": { + "trakt": 55961, + "slug": "freddy-bouciegues", + "imdb": "nm1569291", + "tmdb": 179861, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-19T08:09:02.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Bob Brown", + "ids": { + "trakt": 56074, + "slug": "bob-brown", + "imdb": "nm0113136", + "tmdb": 146352, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Robert 'Bob' Francis Brown is an American stunt coordinator, stunt performer, stunt double, second unit director, and world champion high diver. He has doubled for Kevin Costner, Nicolas Cage, Kevin Spacey, Arnold Schwarzenegger, Jim Carrey, Sam Neill, Robert Patrick, and Brent Spiner.\n\nBrown received three Taurus World Stunt Award nominations: in 2002, in the category Best Driving for his work on the action thriller Swordfish, shared with Scott Rogers, Gilbert Combs, Brett A. Jones, and Mike Justus, in 2005, in the category Best Specialty Stunt for his work on the action drama Flight of the Phoenix, and in 2008, in the category Best High Work on the thriller The Number 23.\n\nIn 2011, he won a Taurus World Stunt Award in the category Best High Work on the science fiction prequel Predators, shared with Jeremy Fitzgerald. Brown was also part of the stunt team which received Screen Actors Guild Award nominations in the category Outstanding Performance by a Stunt Ensemble in a Motion Picture for his work on the comic adaptation Iron Man in 2009 and on the science fiction sequel Transformers: Revenge of the Fallen in 2010.\n\nHe has worked as a stunt double for Brent Spiner in the series Star Trek: The Next Generation - pilot episode \"Encounter at Farpoint\" (1987), for Robert Patrick in Terminator 2: Judgment Day (1991), for Arnold Schwarzenegger in Last Action Hero (1993), for Jim Carrey in The Mask (1994) and The Cable Guy (1996), for Kevin Spacey in Se7en (1995), for William Baldwin in Fair Game (1995), for Kevin Costner in Waterworld (1995), for Nicolas Cage in Face/Off (1997), for Sam Neill in Jurassic Park III (2001), for Rory Cochrane in the television series CSI: Miami (2003), and for Gregory Sims in NCIS: Los Angeles (2009).\n\nHe was a member of the United States High Diving Team (1985-1991). His diving awards \u0026 achievements include Mediterranean Cup High Diving Champion (1996) and World High Diving Champion (1991, 1989, 1988). He was also a National \u0026 World Age Group Trampoline Champion (1974).\n\nHe has been married to stuntwoman Marta Merrifield since 1996 and they have 3 children.", + "birthday": "1959-01-01", + "death": null, + "birthplace": null, + "homepage": "http://bobbrownaction.com/", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-08T08:16:39.000Z" + } + }, + { + "job": "Additional Music", + "jobs": [ + "Additional Music" + ], + "person": { + "name": "Bobby Tahouri", + "ids": { + "trakt": 70383, + "slug": "bobby-tahouri", + "imdb": "nm1692616", + "tmdb": 124715, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "sound", + "gender": "male", + "updated_at": "2024-09-29T08:09:18.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Richard Bucher", + "ids": { + "trakt": 89284, + "slug": "richard-bucher", + "imdb": "nm0118150", + "tmdb": 1130026, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-31T08:10:29.000Z" + } + }, + { + "job": "Choreographer", + "jobs": [ + "Choreographer" + ], + "person": { + "name": "Alison Faulk", + "ids": { + "trakt": 93988, + "slug": "alison-faulk", + "imdb": "nm0269035", + "tmdb": 101687, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Alison grew up in South Florida with musician parents. She found her love of dance at an early age and it hasn’t let up. She moved to Los Angeles to study on a Joe Tremaine Scholarship and learn about as many dance styles as she could. This lead to a successful dance career in film, television, and with artists such as Janet Jackson, Britney Spears, Missy Elliot and Miley Cyrus. She is a proud member of the all female hip-hop crew The Beat Freaks, as well as the Groovaloos. Some of her choreography credits include P!NK, Madonna, Jennifer Lopez and Ricky Martin. She has choreographed episodes of Mindy Project, Lip Sync Battle, and The Sing Off. She loves film and to date has worked on 10 features. Along with her partners Teresa and Luke, she choreographed both Magic Mike films and collaborated with Channing Tatum on the creation of Magic Mike Live.", + "birthday": "1977-01-08", + "death": null, + "birthplace": "Pembroke Pines, Florida, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2023-12-29T00:42:30.000Z" + } + }, + { + "job": "Additional Music", + "jobs": [ + "Additional Music" + ], + "person": { + "name": "Lorne Balfe", + "ids": { + "trakt": 119208, + "slug": "lorne-balfe-7bd543fb-13a3-4040-a49b-e3aba4da7fff", + "imdb": "nm1154632", + "tmdb": 929145, + "tvrage": null + }, + "social_ids": { + "twitter": "Lornebalfe", + "facebook": "lornebalfemusic", + "instagram": "lornebalfe", + "wikipedia": null + }, + "biography": "Lorne Balfe (born 23 February 1976) is a Scottish composer and record producer of film, television, and video game scores.\n\nA veteran of Hans Zimmer's Remote Control Productions, Balfe's scoring credits include the films Megamind, Penguins of Madagascar, Home, Terminator Genisys, 13 Hours: The Secret Soldiers of Benghazi, The Lego Batman Movie, Mission: Impossible – Fallout, and its sequel. Mission: Impossible – Dead Reckoning Part One, Bad Boys for Life, and its sequel Bad Boys: Ride or Die, Black Widow, Black Adam, Dungeons \u0026 Dragons: Honour Among Thieves, Gran Turismo, and Beverly Hills Cop: Axel F, as well as the video games Assassin's Creed: Revelations, Assassin's Creed III, Crysis 2, Skylanders, and Call of Duty: Modern Warfare 2. He has also scored the television series The Bible, Marcella, The Crown, and Genius, the latter for which he earned a nomination for a Primetime Emmy Award for Outstanding Original Main Title Theme Music. He also collaborates with the directors Michael Bay, Chris McKay, Christopher McQuarrie, Adil El Arbi, and Bilall Fallah, and Mikael Håfström.\n\nHe composed the new fanfare for Skydance Productions, transcribed as There’s a World, There’s a Moon. Balfe also composed the Annapurna Pictures deep note opening logo.\n\nBalfe was born in Inverness, Scotland. He went to Fettes College in Edinburgh, where he had a music scholarship.\n\nDescription above from the Wikipedia article Lorne Balfe, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1976-02-23", + "death": null, + "birthplace": "Inverness, Scotland, UK", + "homepage": "http://lornebalfe.com", + "known_for_department": "sound", + "gender": "male", + "updated_at": "2025-02-22T08:12:50.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Jon Braver", + "ids": { + "trakt": 136969, + "slug": "jon-braver", + "imdb": "nm0106004", + "tmdb": 183933, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-11-02T10:25:06.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Matt Leonard", + "ids": { + "trakt": 136971, + "slug": "matt-leonard", + "imdb": "nm0502706", + "tmdb": 1205742, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Matt Leonard is a stunt performer and actor. He played defensive tackle for the Jacksonville Jaguars from 2003-2005.", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "http://www.brandxstunts.org/members/mattleonard", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-12-07T08:02:14.000Z" + } + }, + { + "job": "Stunt Coordinator", + "jobs": [ + "Stunt Coordinator" + ], + "person": { + "name": "Thomas Robinson Harper", + "ids": { + "trakt": 143902, + "slug": "thomas-robinson-harper", + "imdb": "nm1329724", + "tmdb": 1339453, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "http://www.brandxstunts.org/members/tharper", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-20T08:08:08.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Luke LaFontaine", + "ids": { + "trakt": 149689, + "slug": "luke-lafontaine", + "imdb": "nm0480971", + "tmdb": 225626, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "acting", + "gender": null, + "updated_at": "2025-01-28T08:11:21.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Brian Simpson", + "ids": { + "trakt": 150454, + "slug": "brian-simpson", + "imdb": "nm0800923", + "tmdb": 180838, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Brian Simpson was born in Pittsburgh, Pennsylvania, the youngest of 6 children. He went to Upper St. Clair HS for two years, then moved to California in 1981 to finish high school at South Pasadena HS. He played Jr. college football before getting into the film industry. He has been a member of the International Stunt Association for the past 10 years. IMDb Mini Biography By: Brian Simpson", + "birthday": "1965-08-18", + "death": null, + "birthplace": "Pittsburgh, Pennsylvania, USA", + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-20T08:06:48.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Tim Trella", + "ids": { + "trakt": 168242, + "slug": "tim-trella", + "imdb": "nm0871839", + "tmdb": 200402, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "http://www.brandxstunts.org/members/ttrella", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-10-31T08:07:04.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Theo Kypri", + "ids": { + "trakt": 178330, + "slug": "theo-kypri", + "imdb": "nm0477465", + "tmdb": 202955, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Former 3x National Champion and 8th in the World trampolinist turned stunt professional Theo Kypri has worked on some of Hollywood's biggest grossing movies and has stunt doubled some of the biggest stars in the world. Now as stunt coordinator, action designer, and second unit action director, he brings realistic, original, and creative action sequences to both the big and small screens.", + "birthday": null, + "death": null, + "birthplace": "London, England, UK", + "homepage": "https://www.istunt.com/theo-kypri", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-11T08:05:23.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Tom Elliott", + "ids": { + "trakt": 257169, + "slug": "tom-elliott", + "imdb": "nm0254657", + "tmdb": 1099845, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-19T08:09:12.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Gilbert Rosales", + "ids": { + "trakt": 299791, + "slug": "gilbert-rosales", + "imdb": "nm0740962", + "tmdb": 179887, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Gilbert Rosales was born on November 11, 1969 in El Paso, Texas, USA. He is an actor, known for Iron Man (2008), Starship Troopers (1997) and Falling Down (1993).", + "birthday": "1969-11-11", + "death": null, + "birthplace": "El Paso, Texas, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-01-26T08:10:15.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Daniel Arrias", + "ids": { + "trakt": 381903, + "slug": "daniel-arrias", + "imdb": "nm1765074", + "tmdb": 1340940, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Daniel Jon Arrias (born 2 April, 1967) is a stuntman, stunt actor, and stunt coordinator who performed stunts as stunt double for Zachary Quinto in Star Trek.", + "birthday": "1967-04-02", + "death": null, + "birthplace": "Los Angeles, California, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-01-20T08:06:47.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Ray Siegle", + "ids": { + "trakt": 381904, + "slug": "ray-siegle", + "imdb": "nm0797075", + "tmdb": 1340941, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-05-28T08:07:16.000Z" + } + }, + { + "job": "Stunt Double", + "jobs": [ + "Stunt Double" + ], + "person": { + "name": "Greg Fitzpatrick", + "ids": { + "trakt": 385354, + "slug": "greg-fitzpatrick", + "imdb": "nm0280525", + "tmdb": 1347144, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": "New Brunswick, New Jersey, USA", + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-11-25T08:08:50.000Z" + } + }, + { + "job": "Stunt Double", + "jobs": [ + "Stunt Double" + ], + "person": { + "name": "Richard Cetrone", + "ids": { + "trakt": 412263, + "slug": "richard-cetrone", + "imdb": "nm0149150", + "tmdb": 12371, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": "1961-07-13", + "death": null, + "birthplace": "Pittsburgh, Pennsylvania, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-01-20T08:06:47.000Z" + } + }, + { + "job": "Special Effects", + "jobs": [ + "Special Effects" + ], + "person": { + "name": "Stan Winston", + "ids": { + "trakt": 412281, + "slug": "stan-winston", + "imdb": "nm0935644", + "tmdb": 60261, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "From Wikipedia, the free encyclopedia.\n\nStanley Winston (April 7, 1946 – June 15, 2008) was an American visual effects supervisor, makeup artist, and film director. He was best known for his work in the Terminator series, the Jurassic Park series, Aliens, the Predator series, Iron Man, Edward Scissorhands, Avatar and Enthiran. He won four Academy Awards for his work.\n\nWinston, a frequent collaborator with director James Cameron, owned several effects studios, including Stan Winston Digital. The established areas of expertise for Winston were in makeup, puppets and practical effects, but he had recently expanded his studio to encompass digital effects as well.\n\nDescription above from the Wikipedia article Stan Winston, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1946-04-07", + "death": "2008-06-15", + "birthplace": "Arlington, Virginia, USA", + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-06T15:15:56.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Kevin Scott", + "ids": { + "trakt": 413731, + "slug": "kevin-scott-9060556d-9cf2-4858-8e0b-30b8606648b3", + "imdb": "nm0779447", + "tmdb": 1012973, + "tvrage": null + }, + "social_ids": { + "twitter": "", + "facebook": "1175322341", + "instagram": "", + "wikipedia": null + }, + "biography": "Kevin Scott is an American Stunt Coordinator, Second Unit Director and Stunts Performer.", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "http://www.kevincscott.com", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-20T08:05:42.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Brian Brown", + "ids": { + "trakt": 420074, + "slug": "brian-brown-c3bc31e8-6cb0-47e4-a96d-be74095ffe8b", + "imdb": "nm0113156", + "tmdb": 1204219, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Brian Brown was born on March 15, 1963 in Waco, Texas, USA. He is an actor, known for The Ballad of Buster Scruggs (2018), Iron Man (2008) and Cowboys \u0026 Aliens (2011).", + "birthday": "1963-03-15", + "death": null, + "birthplace": "Waco, Texas, USA", + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-06-21T08:08:05.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Joe Bucaro III", + "ids": { + "trakt": 421202, + "slug": "joe-bucaro-iii", + "imdb": "nm0117851", + "tmdb": 51302, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Joe grew up in Park Ridge and Wheaton, Illinois, for 27 years. Ever since watching Evel Knievel live at the old Chicago amphitheater jump 10 buses at age 8 and seeing Hooper (1978) in the theaters as an impressionable 13-year-old boy, he knew he wanted to become a Hollywood Stuntman.\n\nAfter many odd jobs, sales was his niche, but stunts always loomed in the background. He was a successful food broker, selling steak and seafood door to door throughout the Chicagoland area. When a stunt school opened in Chicago in 1988, Joe jumped at the opportunity. From there, he honed his stunt skills and learned about the motion picture industry, hustling sets and working as an extra. After several years he hustled the sets and got his big break doubling for Steven Seagal and hasn't looked back. Since then, Joe has also doubled for Arnold Schwarzenegger, Will Ferrell, John Cusack, Jeff Goldblum, David Hasselhoff, Dan Marino, and Vince Vaughn. He has been nominated twice for the World Stunt Awards and has won two Emmy awards for best action sequences for television.", + "birthday": "1964-04-04", + "death": null, + "birthplace": "Wheaton, Illinois, USA", + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-04T08:12:21.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Chad Randall", + "ids": { + "trakt": 424290, + "slug": "chad-randall", + "imdb": "nm0709555", + "tmdb": 1391129, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-23T08:01:31.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Ben Hernandez Bray", + "ids": { + "trakt": 424311, + "slug": "ben-hernandez-bray", + "imdb": "nm0004554", + "tmdb": 59674, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Ben Hernandez Bray is a film and television director who was born in Los Angeles, California and raised in one of the toughest San Fernando Valley neighborhoods. He's the oldest of six children, raised by his Mother and Grandmother and is of Mexican and Irish descent. In the late 1980's his boxing skills led him into the stunt industry where he then became one of the very few successful latino stuntmen in Hollywood and eventually becoming one of the top action stunt coordinators and second unit directors, working specifically with \"Smoking Aces\" Joe Carnahan and \"Silver Linings Playbook\" David O Russell. After twenty five plus years in the industry and over one hundred fifty film and tv credits, Mr Bray made his television directorial debut in 2015 with Katie Heigl's tv series \"State of Affairs\" for NBC/Universal, he then went on to direct episodes for Fox/Bruckheimer, CW/Greg Berlanti and ABC/Freeform. In 2018 will be Bray's feature film debut which he co-wrote with Joe Carnahan called \"El Chicano\" a Mexican Super hero story about two brothers growing up in East L.A. The story was inspired and originally written by Bray 10 years earlier after losing his youngest brother to gang violence. The film is being produced by War Party Productions and Lorenzo di Bonaventura.", + "birthday": null, + "death": null, + "birthplace": "Los Angeles, California, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-18T08:07:21.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Brian Machleit", + "ids": { + "trakt": 425212, + "slug": "brian-machleit", + "imdb": "nm0532646", + "tmdb": 1391698, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": "1972-10-26", + "death": null, + "birthplace": "St. Clair Shores, Michigan, USA", + "homepage": "https://brianmachleit.com", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-26T08:08:27.000Z" + } + }, + { + "job": "Stunt Double", + "jobs": [ + "Stunt Double" + ], + "person": { + "name": "Mike Justus", + "ids": { + "trakt": 425517, + "slug": "mike-justus-425517", + "imdb": "nm0433242", + "tmdb": 1393339, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-21T08:09:57.000Z" + } + }, + { + "job": "Stunt Double", + "jobs": [ + "Stunt Double" + ], + "person": { + "name": "Oakley Lehman", + "ids": { + "trakt": 443968, + "slug": "oakley-lehman", + "imdb": "nm1172599", + "tmdb": 1391327, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Doubles: Paul Walker.\n\nAwards \u0026 Achievements:\n\n2012 Taurus Stunt Award Best High Work\n\n2012 Taurus Stunt Award Best Work With Vehicle", + "birthday": "1975-11-20", + "death": null, + "birthplace": "Los Angeles County, California, USA", + "homepage": "http://www.brandxstunts.org/members/olehman", + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-01-19T08:09:00.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Steve Holladay", + "ids": { + "trakt": 479987, + "slug": "steve-holladay", + "imdb": "nm0390585", + "tmdb": 1404906, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "http://www.brandxstunts.org/members/sholladay", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-06-09T08:08:40.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "James M. Halty", + "ids": { + "trakt": 482013, + "slug": "james-m-halty", + "imdb": "nm0357127", + "tmdb": 1400371, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": "1949-01-01", + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-17T08:06:36.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Michael Hilow", + "ids": { + "trakt": 499949, + "slug": "michael-hilow", + "imdb": "nm0385190", + "tmdb": 1420973, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2025-01-20T08:09:40.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Anthony Kramme", + "ids": { + "trakt": 502737, + "slug": "anthony-kramme", + "imdb": "nm0469717", + "tmdb": 1423547, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2024-11-17T08:16:27.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Nito Larioza", + "ids": { + "trakt": 507173, + "slug": "nito-larioza", + "imdb": "nm1045914", + "tmdb": 1427711, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Nito Larioza is a stunt performer and an actor.", + "birthday": "1971-02-21", + "death": null, + "birthplace": "Wahiawa - Hawaii - USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-01-20T08:08:13.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Robert Alonzo", + "ids": { + "trakt": 525497, + "slug": "robert-alonzo", + "imdb": "nm0022315", + "tmdb": 1444239, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": "dynamicactiondesign", + "wikipedia": null + }, + "biography": "Robert Alonzo’s journey through the film business began early on in the city of Angels. He started with a passion for animation at age six, when he began drawing flip pages of his favourite cartoons. He focused on this craft until he was introduced to martial arts at an early age. Martial arts would ultimately turn into a lifelong obsession for Robert. He trained incessantly and eventually earned black belts in Tae Kwon Do and Hapkido, training under Grandmaster Jun Chong.\n\nRobert’s devotion to martial arts was fueled by his hunger for knowledge of all martial arts styles and disciplines. This passion led him to broaden his training, become proficient in multiple martial arts disciplines, and compete and excel in various international martial arts competitions.\n\nAlthough Robert had a deep passion for martial arts, he never forgot his childhood dreams of filmmaking. After graduating from Loyola High School in Los Angeles, he received an academic scholarship to attend Loyola Marymount University (LMU), where he earned his Bachelor of Arts degree in Studio Arts and Communication Studies while minoring in Animation and Business Marketing. This is where Robert trained under Dan McLaughlin and made his first hand-drawn animated senior thesis film titled “The Dive of Life.\" After graduating from LMU, Robert dabbled in the commercial world as a junior art director at Envision Group, but ultimately his love for martial arts and film came calling and led him to become a stunt performer.", + "birthday": "1970-08-04", + "death": null, + "birthplace": "Manila, Philippines", + "homepage": "https://www.dynamicactiondesign.com/", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-20T08:11:12.000Z" + } + }, + { + "job": "Stunts", + "jobs": [ + "Stunts" + ], + "person": { + "name": "Sandy Berumen", + "ids": { + "trakt": 539105, + "slug": "sandy-berumen", + "imdb": "nm0078499", + "tmdb": 1457926, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "female", + "updated_at": "2024-11-23T08:11:06.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "J.J. Dashnaw", + "ids": { + "trakt": 539996, + "slug": "j-j-dashnaw", + "imdb": "nm7495544", + "tmdb": 1458444, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "J.J. Dashnaw is an American stunt coordinator and stunt performer. His parents are also stunt coordinators and stunt performers, Jeff Dashnaw and Tracy Keehn-Dashnaw, as well as his brothers Chad Dashnaw and Jake Dashnaw. His brother Nick Dashnaw was a stunt performer until his death in 2016.", + "birthday": "1982-10-31", + "death": null, + "birthplace": null, + "homepage": "http://www.brandxstunts.org/jjdashnaw", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-20T08:08:27.000Z" + } + }, + { + "job": "Stunt Double", + "jobs": [ + "Stunt Double" + ], + "person": { + "name": "Daniel Stevens", + "ids": { + "trakt": 563162, + "slug": "daniel-stevens", + "imdb": "nm1174894", + "tmdb": 1502956, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": "danielstevens1", + "wikipedia": null + }, + "biography": "Daniel Stevens is known for Avengers: Endgame (2019), Black Panther (2018), and Iron Man (2008).", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "https://instabio.cc/danielstevens", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-11-13T18:26:51.000Z" + } + }, + { + "job": "Stunt Double", + "jobs": [ + "Stunt Double" + ], + "person": { + "name": "Clay Donahue Fontenot", + "ids": { + "trakt": 563163, + "slug": "clay-donahue-fontenot", + "imdb": "nm0285047", + "tmdb": 1291797, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Clay Donahue Fontenot is known for Blade II (2002), Iron Man (2008) and Logan (2017).", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "http://www.brandxstunts.org/members/cfontenot", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-09T08:08:56.000Z" + } + }, + { + "job": "Military Consultant", + "jobs": [ + "Military Consultant" + ], + "person": { + "name": "Harry Humphries", + "ids": { + "trakt": 564308, + "slug": "harry-humphries-0f8aa012-ab2a-4195-973a-c0eb7c936e9a", + "imdb": "nm0402051", + "tmdb": 1554033, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Harry R. Humphries is a former United States Navy SEAL, who is a producer, military consultant and actor.\n\nAfter graduating from Admiral Farragut Academy and attending Rutgers University in New Jersey, he joined the Navy in 1958. Soon after joining, he completed UDTR (Underwater Demolition Team Replacement) Class 29 and graduated as Honor man. He was assigned to UDT 22. After working with UDT 22 for some time in 1965 to 1967, he volunteered for and was accepted into SEAL Team Two. In 1971, he left the Navy with an Honorable Discharge.\n\nAfter a career with Henkel KGaA, the German Multi National Chemical Company, he moved to California, where he started Global Study Group, Inc. (\"GSGI\"). He works full-time as a Security Consultant and Entertainment Technical Adviser/Actor/Producer.", + "birthday": "1940-11-17", + "death": null, + "birthplace": "Kearny, New Jersey, USA", + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-12T08:05:45.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Rex Reddick", + "ids": { + "trakt": 574163, + "slug": "rex-reddick", + "imdb": "nm0714699", + "tmdb": 1531529, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "http://www.rexreddick.com/", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-16T08:07:07.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Norm Compton", + "ids": { + "trakt": 579604, + "slug": "norm-compton", + "imdb": "nm0174063", + "tmdb": 1391805, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-01-20T08:08:03.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Michael Runyard", + "ids": { + "trakt": 581871, + "slug": "michael-runyard", + "imdb": "nm0750354", + "tmdb": 1535124, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Michael has 39 years experience in the movie industry as Stuntman, Actor, Stunt Coordinator, 2nd Unit Director, Safety Advisor, Action Arranger, etc.\n\nDoubles: Michael Douglas, Robert Urich, Harrison Ford, Sean Connery, Roger Moore, Robin Williams, Adam Sandler, Matthew Modine, Christopher Walken, and many more.\n\nAwards:\n\nAma 500Cc National Motocross Champion, 1973 1St American\n\nCanadian (Cma) 250Cc \u0026 500Cc National Motocross Champion, 1974 \u0026 1975", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "http://www.brandxstunts.org/members/mrunyard", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-11-23T08:10:52.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Chris Carnel", + "ids": { + "trakt": 621531, + "slug": "chris-carnel", + "imdb": "nm0138691", + "tmdb": 1502542, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-26T08:11:00.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Mark Aaron Wagner", + "ids": { + "trakt": 633124, + "slug": "mark-aaron-wagner", + "imdb": "nm0906010", + "tmdb": 1622657, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": "1975-11-03", + "death": null, + "birthplace": "Fullerton, California, USA", + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-12-06T08:09:53.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Dino Dos Santos", + "ids": { + "trakt": 640046, + "slug": "dino-dos-santos", + "imdb": "nm1326778", + "tmdb": 1627986, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": "1968-07-18", + "death": null, + "birthplace": "Paraná, Brazil", + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-19T08:09:05.000Z" + } + }, + { + "job": "Stunt Driver", + "jobs": [ + "Stunt Driver" + ], + "person": { + "name": "Tammie Baird", + "ids": { + "trakt": 666224, + "slug": "tammie-baird", + "imdb": "nm1941679", + "tmdb": 1647187, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": "Livermore, California, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "female", + "updated_at": "2024-04-07T08:01:44.000Z" + } + }, + { + "job": "Stunts", + "jobs": [ + "Stunts" + ], + "person": { + "name": "Alvin Zalamea", + "ids": { + "trakt": 668004, + "slug": "alvin-zalamea", + "imdb": "nm1917938", + "tmdb": 1635225, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2025-01-15T08:09:35.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Matt Baker", + "ids": { + "trakt": 697878, + "slug": "matt-baker-638e2993-b0ad-45d2-8eb8-4fa5f1a7ad7f", + "imdb": "nm1290743", + "tmdb": 1673003, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-30T08:10:37.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "David Ott", + "ids": { + "trakt": 711852, + "slug": "david-ott", + "imdb": "nm2605709", + "tmdb": 1687703, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-03T08:10:26.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Mark Chavarria", + "ids": { + "trakt": 720555, + "slug": "mark-chavarria", + "imdb": null, + "tmdb": 1680212, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": "1968-09-30", + "death": "2017-11-09", + "birthplace": "Houston, Texas, USA", + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-06T08:27:17.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "David Castillo", + "ids": { + "trakt": 725202, + "slug": "david-castillo-035457a0-fb2f-4dba-b6b8-b5f100e94080", + "imdb": "nm0145045", + "tmdb": 1696636, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": "1972-09-13", + "death": null, + "birthplace": "Bellflower, California, USA", + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-08-05T12:33:38.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Paul Eliopoulos", + "ids": { + "trakt": 782609, + "slug": "paul-eliopoulos", + "imdb": "nm0253639", + "tmdb": 1737945, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-27T08:08:35.000Z" + } + }, + { + "job": "Post Production Assistant", + "jobs": [ + "Post Production Assistant" + ], + "person": { + "name": "John Bartnicki", + "ids": { + "trakt": 808006, + "slug": "john-bartnicki", + "imdb": "nm2962362", + "tmdb": 1760516, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "production", + "gender": "male", + "updated_at": "2024-01-02T15:06:59.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Laurence Todd Rosenthal", + "ids": { + "trakt": 814958, + "slug": "laurence-todd-rosenthal", + "imdb": "nm0742787", + "tmdb": 1766719, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-01-06T08:27:17.000Z" + } + }, + { + "job": "Stunt Driver", + "jobs": [ + "Stunt Driver" + ], + "person": { + "name": "Joost Janssen", + "ids": { + "trakt": 864304, + "slug": "joost-janssen", + "imdb": "nm7425457", + "tmdb": 1797772, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-14T08:07:22.000Z" + } + }, + { + "job": "Stunts", + "jobs": [ + "Stunts" + ], + "person": { + "name": "Victor Winters-Junco", + "ids": { + "trakt": 903701, + "slug": "victor-winters-junco", + "imdb": "nm2261724", + "tmdb": 1516873, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-03T08:05:20.000Z" + } + }, + { + "job": "Additional Music", + "jobs": [ + "Additional Music" + ], + "person": { + "name": "Clay Duncan", + "ids": { + "trakt": 921123, + "slug": "clay-duncan", + "imdb": null, + "tmdb": 1837416, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "sound", + "gender": null, + "updated_at": "2023-03-22T08:06:42.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Hannah Kozak", + "ids": { + "trakt": 938294, + "slug": "hannah-kozak", + "imdb": null, + "tmdb": 1852746, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": "1960-07-26", + "death": null, + "birthplace": "Chatsworth, California, USA", + "homepage": null, + "known_for_department": "crew", + "gender": "female", + "updated_at": "2025-02-21T08:07:26.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Tad Griffith", + "ids": { + "trakt": 952530, + "slug": "tad-griffith", + "imdb": "nm0341498", + "tmdb": 1866579, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Screen Actors Guild Awards Nominee:\n\n2008 Outstanding Performance By A Stunt Ensemble In A Motion Picture: The Kingdom\n\n2008 Outstanding Performance By A Stunt Ensemble In A Motion Picture: 300\n\n2010 World Stunt Awards Winner — Best Work With A Vehicle: Fast And Furious 4\n\nWorld Stunt Awards Nominee:\n\n2005 Best Work With A Vehicle: Spiderman II\n\n2004 Best Stunt By A Stuntman: Seabiscuit\n\n2004 Best Specialty Stunt: Seabiscuit\n\n2004 Best Stunt By A Stuntman: Cradle 2 The Grave\n\n2004 Best Work With A Vehicle: Cradle 2 The Grave\n\n2002 Best Fire Stunt: Last Castle\n\n2002 Best Work With An Animal: American Outlaws\n\nOther Awards \u0026 Achievements:\n\n— World Champion Trick Rider\n\n— Third Generation Professional Rodeo Cowboy Association Contestant \u0026 Performer", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "http://www.atadwest.com/", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-01T08:08:14.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Richie Gaona", + "ids": { + "trakt": 982104, + "slug": "richie-gaona", + "imdb": null, + "tmdb": 1893551, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": "Texas, USA", + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-20T08:05:42.000Z" + } + }, + { + "job": "Stunt Driver", + "jobs": [ + "Stunt Driver" + ], + "person": { + "name": "Craig Rondell", + "ids": { + "trakt": 1022435, + "slug": "craig-rondell", + "imdb": null, + "tmdb": 1931656, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "acting", + "gender": null, + "updated_at": "2023-06-18T08:07:47.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Greg Anthony", + "ids": { + "trakt": 1050216, + "slug": "greg-anthony-e17b22ce-571c-43e1-b315-89d57dc79a6b", + "imdb": null, + "tmdb": 1957590, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-07-06T08:05:59.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Annie Ellis", + "ids": { + "trakt": 1067510, + "slug": "annie-ellis", + "imdb": "nm0254695", + "tmdb": 1973515, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Stuntwoman Annie Ellis is a second generation Stuntwoman. Her father Richard Ellis was a well-known and beloved stuntman who raced motorcycles, was a well known surfer, and who went from stuntman and stunt coordinator to a feature film director.\n\nAnnie specializes in horse work, water work, fights and car work. Her resume includes thousands of TV credits and over 150 top films.", + "birthday": "1956-12-11", + "death": null, + "birthplace": "Los Angeles County, California, USA", + "homepage": null, + "known_for_department": "crew", + "gender": "female", + "updated_at": "2025-01-26T08:08:52.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Riley Harper", + "ids": { + "trakt": 1118940, + "slug": "riley-harper", + "imdb": "nm0363947", + "tmdb": 2020105, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": "lifeof_riley", + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": "Los Angeles, California, USA", + "homepage": "http://www.lifeof-riley.com/", + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-06T08:27:17.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Anderson Martin", + "ids": { + "trakt": 1119962, + "slug": "anderson-martin", + "imdb": "nm0551906", + "tmdb": 2020611, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-03T08:08:19.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "John T. Cypert", + "ids": { + "trakt": 1120305, + "slug": "john-t-cypert", + "imdb": "nm0194229", + "tmdb": 2020623, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-05-09T08:08:25.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Keith Splinter Davis", + "ids": { + "trakt": 1122889, + "slug": "keith-splinter-davis-9acc9b96-254c-40c2-a394-e72dde8a3f5d", + "imdb": "nm0204952", + "tmdb": 1578364, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-01-26T08:10:14.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Kevin Derr", + "ids": { + "trakt": 1152289, + "slug": "kevin-derr", + "imdb": "nm1232110", + "tmdb": 2052022, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": "1981-04-13", + "death": null, + "birthplace": "Hollywood, California, USA", + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-12-12T08:01:38.000Z" + } + }, + { + "job": "Stunts", + "jobs": [ + "Stunts" + ], + "person": { + "name": "Sebastiano Cartier", + "ids": { + "trakt": 1412501, + "slug": "sebastiano-cartier", + "imdb": null, + "tmdb": 2224820, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2025-02-09T08:08:32.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Paul Sklar", + "ids": { + "trakt": 1455065, + "slug": "paul-sklar", + "imdb": null, + "tmdb": 2274724, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2025-01-19T08:09:10.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Krisztian Kery", + "ids": { + "trakt": 1479004, + "slug": "krisztian-kery", + "imdb": "nm1445682", + "tmdb": 2299884, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2023-06-18T08:08:45.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Todd Warren", + "ids": { + "trakt": 1485021, + "slug": "todd-warren-0c1ae5e9-8297-4b43-89b6-8fbef2f873d7", + "imdb": "nm0913060", + "tmdb": 2306648, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-12-11T08:08:28.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Glenn Goldstein", + "ids": { + "trakt": 1592203, + "slug": "glenn-goldstein", + "imdb": null, + "tmdb": 2409990, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2024-05-06T08:08:28.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Darryl Reeves", + "ids": { + "trakt": 1637711, + "slug": "darryl-reeves", + "imdb": null, + "tmdb": 2451620, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2025-02-16T08:07:07.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Mike Rufino", + "ids": { + "trakt": 1675108, + "slug": "mike-rufino", + "imdb": "nm1196232", + "tmdb": 2506371, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2024-07-04T08:08:06.000Z" + } + }, + { + "job": "Stunts", + "jobs": [ + "Stunts" + ], + "person": { + "name": "Travis Fienhage", + "ids": { + "trakt": 1823374, + "slug": "travis-fienhage", + "imdb": "nm5361735", + "tmdb": 2636803, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-03T08:05:20.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Paul Crawford", + "ids": { + "trakt": 1940184, + "slug": "paul-crawford", + "imdb": null, + "tmdb": 2753653, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2024-11-11T08:08:10.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "J.C. Robaina", + "ids": { + "trakt": 1944253, + "slug": "j-c-robaina-f3e0d70d-5d8f-4aa3-b249-8e803641ead5", + "imdb": "nm0730154", + "tmdb": 2758531, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2025-02-16T08:07:00.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Danielle Wait", + "ids": { + "trakt": 1957716, + "slug": "danielle-wait-ee32e71c-7303-4f74-ad72-7046d2f35a14", + "imdb": null, + "tmdb": 2771944, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "female", + "updated_at": "2022-05-23T08:44:16.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Cain Smead", + "ids": { + "trakt": 1957717, + "slug": "cain-smead", + "imdb": null, + "tmdb": 2771948, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2024-08-05T08:08:30.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "John Pohmisano", + "ids": { + "trakt": 1957718, + "slug": "john-pohmisano", + "imdb": null, + "tmdb": 2771952, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2024-07-11T08:10:48.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Damien Moreno", + "ids": { + "trakt": 1957719, + "slug": "damien-moreno", + "imdb": "nm1862298", + "tmdb": 2771957, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2023-08-22T08:04:02.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Ross A. Jordan", + "ids": { + "trakt": 1957721, + "slug": "ross-a-jordan", + "imdb": null, + "tmdb": 2771965, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2025-01-20T08:06:47.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Brandon Johnson", + "ids": { + "trakt": 1957722, + "slug": "brandon-johnson-a8ca600d-31ef-48ce-a125-54a8e5d27a92", + "imdb": null, + "tmdb": 2771966, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2023-12-27T08:09:54.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Jorge Cisneros", + "ids": { + "trakt": 1957725, + "slug": "jorge-cisneros", + "imdb": null, + "tmdb": 2771989, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2023-12-09T08:07:47.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Mark Chapman", + "ids": { + "trakt": 1957726, + "slug": "mark-chapman-07be75b7-ffa5-4f85-b986-4480df155db4", + "imdb": null, + "tmdb": 2771991, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2024-11-11T08:07:16.000Z" + } + }, + { + "job": "Utility Stunts", + "jobs": [ + "Utility Stunts" + ], + "person": { + "name": "Hal Burton", + "ids": { + "trakt": 1957727, + "slug": "hal-burton-ee95ecf8-1a9e-44e7-b8c4-0d1913f81dd9", + "imdb": "nm0123592", + "tmdb": 2771993, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-08T08:07:52.000Z" + } + }, + { + "job": "Executive in Charge of Finance", + "jobs": [ + "Executive in Charge of Finance" + ], + "person": { + "name": "Matt Finick", + "ids": { + "trakt": 2588677, + "slug": "matt-finick", + "imdb": "nm2586682", + "tmdb": 3415861, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2022-05-23T08:44:16.000Z" + } + }, + { + "job": "Post Production Assistant", + "jobs": [ + "Post Production Assistant" + ], + "person": { + "name": "Patrick McMahon", + "ids": { + "trakt": 2588678, + "slug": "patrick-mcmahon-f2d5be62-36cb-4f1d-9d9c-c3c9759802a4", + "imdb": null, + "tmdb": 3415901, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2022-05-23T08:44:16.000Z" + } + }, + { + "job": "Translator", + "jobs": [ + "Translator" + ], + "person": { + "name": "Ilham Hosseini", + "ids": { + "trakt": 2588680, + "slug": "ilham-hosseini", + "imdb": null, + "tmdb": 3415904, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2022-05-23T08:44:16.000Z" + } + }, + { + "job": "Driver", + "jobs": [ + "Driver" + ], + "person": { + "name": "Steve Gentry", + "ids": { + "trakt": 3274072, + "slug": "steve-gentry-3c853b33-fcf2-4e1d-9f3d-1be79f282650", + "imdb": null, + "tmdb": 4090714, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2025-02-20T15:20:54.000Z" + } + } + ], + "editing": [ + { + "job": "Additional Editor", + "jobs": [ + "Additional Editor" + ], + "person": { + "name": "Michael Tronick", + "ids": { + "trakt": 1153, + "slug": "michael-tronick", + "imdb": "nm0873531", + "tmdb": 908, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Michael Tronick (born March 2, 1949) is an American film editor with more than 25 film credits. He has been nominated twice for American Cinema Editors \"Eddie\" Awards for Scent of a Woman (1992) and for Hairspray (2007).\n\nSince 2012, Tronick has served as a member of the board of governors of the Academy of Motion Picture Arts and Sciences Editors Branch.\n\nTronick is a member of the academy's Science and Technology Council and was previously selected for membership in the American Cinema Editors.\n\nDescription above from the Wikipedia article Michael Tronick, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1949-04-02", + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "editing", + "gender": "male", + "updated_at": "2024-09-20T08:01:49.000Z" + } + }, + { + "job": "Editor", + "jobs": [ + "Editor" + ], + "person": { + "name": "Dan Lebental", + "ids": { + "trakt": 9072, + "slug": "dan-lebental", + "imdb": "nm0495603", + "tmdb": 11455, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Dan Lebental is an American film editor who has edited many films for the Marvel Cinematic Universe.\n\nDescription above from the Wikipedia article Dan Lebental, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": null, + "death": null, + "birthplace": "USA", + "homepage": null, + "known_for_department": "editing", + "gender": "male", + "updated_at": "2024-12-07T08:02:06.000Z" + } + }, + { + "job": "Additional Editor", + "jobs": [ + "Additional Editor" + ], + "person": { + "name": "Derek Brechin", + "ids": { + "trakt": 18043, + "slug": "derek-brechin", + "imdb": "nm0106513", + "tmdb": 21351, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "editing", + "gender": "male", + "updated_at": "2024-09-23T08:01:48.000Z" + } + }, + { + "job": "Additional Editor", + "jobs": [ + "Additional Editor" + ], + "person": { + "name": "Greg Parsons", + "ids": { + "trakt": 42890, + "slug": "greg-parsons", + "imdb": "nm0663830", + "tmdb": 57344, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "editing", + "gender": "male", + "updated_at": "2024-06-09T08:08:40.000Z" + } + } + ], + "production": [ + { + "job": "Casting", + "jobs": [ + "Casting" + ], + "person": { + "name": "Sarah Halley Finn", + "ids": { + "trakt": 3223, + "slug": "sarah-halley-finn", + "imdb": "nm0278168", + "tmdb": 7232, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": "sarahfinncasting", + "wikipedia": null + }, + "biography": "Sarah Halley Finn is an American casting director and producer. Finn has cast over 100 feature films and is best known for casting the Marvel Cinematic Universe films. She also cast and co-produced Oscar-winning Best Picture Everything Everywhere All at Once, the most awarded film in movie history. It won three out of four acting categories at the 95th Academy Awards: Best Actress, Best Supporting Actor, and Best Supporting Actress – a feat only achieved twice before, and not since 1976. Other works include Oscar-winning films Black Panther, Three Billboards Outside Ebbing, Missouri, and Crash, all of which earned Finn the Casting Society of America’s highest honour, the Artios Award for Outstanding Achievement in Casting. Those films also won the prestigious SAG Award for Outstanding Performance by a Cast in a Motion Picture. In 2023 Finn was nominated for the BAFTA Award for Best Casting and won the Artios Zeitgeist award for her work on Everything Everywhere All At Once. In 2022, she received two Primetime Emmy nominations for her work casting WandaVision and The Mandalorian.\n\nDescription above from the Wikipedia article Sarah Halley Finn, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1965-02-01", + "death": null, + "birthplace": "New York City, New York, USA", + "homepage": null, + "known_for_department": "production", + "gender": "female", + "updated_at": "2025-02-19T08:08:01.000Z" + } + }, + { + "job": "Executive Producer, Unit Production Manager", + "jobs": [ + "Executive Producer", + "Unit Production Manager" + ], + "person": { + "name": "Louis D'Esposito", + "ids": { + "trakt": 4197, + "slug": "louis-d-esposito", + "imdb": "nm0195669", + "tmdb": 57027, + "tvrage": null + }, + "social_ids": { + "twitter": "louisde2", + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Louis D'Esposito is an American film producer and director who is known for executive producing every Marvel Cinematic Universe film with the exception of The Incredible Hulk (2008). He also directed two Marvel One-Shots: Item 47 and Agent Carter, the latter of which inspired a spin-off TV show on ABC. D'Esposito currently serves as Marvel Studios' co-president alongside Kevin Feige.", + "birthday": null, + "death": null, + "birthplace": "The Bronx, New York City, New York, USA", + "homepage": null, + "known_for_department": "production", + "gender": "male", + "updated_at": "2025-01-22T12:45:48.000Z" + } + }, + { + "job": "Producer", + "jobs": [ + "Producer" + ], + "person": { + "name": "Avi Arad", + "ids": { + "trakt": 5928, + "slug": "avi-arad", + "imdb": "nm0032696", + "tmdb": 7626, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Avi Arad (/ˈɑːvi ˈɑːrɑːd/; Hebrew: אבי ארד; born August 1, 1948) is an Israeli-American studio executive and producer of film, television, and animation. He became the CEO of Toy Biz in the 1990s, was the chief creative officer of Marvel Entertainment, and is the founder, former chairman, and former CEO of the latter's successor, Marvel Studios. Since then, he has produced and sometimes written a wide array of live-action, animated, and television comic book adaptations, including Spider-Man: Into the Spider-Verse.\n\nArad was born in 1948 in Ramat Gan, Israel, to a Jewish family. The son of Holocaust survivors from Poland, he grew up reading Superman and Spider-Man comics translated into Hebrew. In 1965, he was conscripted as a soldier into the Israel Defence Forces (IDF). He fought and was wounded in the 1967 Six-Day War and spent 15 days recuperating. Arad finished his military service in 1968.\n\nIn 1970, Arad moved to the United States and enrolled at Hofstra University to study industrial management. He worked as a truck driver and as a Hebrew teacher to put himself through college and graduated with a BBA in 1972.\n\nAlong with Israeli-American Toy Biz co-owner Isaac Perlmutter, Avi Arad came into conflict with Carl Icahn and Ron Perelman over control of Marvel Comics in the wake of its 1996 bankruptcy. In the end, Arad and Perlmutter came out on top, with Toy Biz taking over Marvel Comics in a complicated deal that included obtaining the rights to Spider-Man and other superheroes that Marvel had sold earlier. He was involved in Marvel's emergence from bankruptcy and the expansion of the company's profile through licensing and movies.\n\nOn May 31, 2006, Arad resigned from his various Marvel positions, including his leadership of Marvel Studios, to form his own production company, Arad Productions (also known as Arad Animation), a company that primarily produces Marvel-licensed films separate from the Marvel Cinematic Universe. His first non-Marvel film was 2007's Bratz. Further ventures include the manga adaptation Ghost in the Shell; an adaptation of Brandon Mull's teenage fantasy Fablehaven (which died in production); an adaptation of James Patterson's teenage novel Maximum Ride; and adaptations of video game properties Uncharted, Infamous, Metal Gear Solid, and The Legend of Zelda.\n\nIn August 2010, it was announced that Arad was given a chair with the American branch of animation studio Production I.G in Los Angeles, California.\n\nDescription above from the Wikipedia article Avi Arad, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1948-08-01", + "death": null, + "birthplace": "Givatayim, Israel", + "homepage": null, + "known_for_department": "production", + "gender": "male", + "updated_at": "2025-02-20T08:07:22.000Z" + } + }, + { + "job": "Executive Producer", + "jobs": [ + "Executive Producer" + ], + "person": { + "name": "Ari Arad", + "ids": { + "trakt": 15632, + "slug": "ari-arad", + "imdb": "nm7191024", + "tmdb": 937174, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "production", + "gender": "male", + "updated_at": "2024-03-19T08:08:26.000Z" + } + }, + { + "job": "Casting", + "jobs": [ + "Casting" + ], + "person": { + "name": "Randi Hiller", + "ids": { + "trakt": 15636, + "slug": "randi-hiller", + "imdb": "nm0384900", + "tmdb": 20540, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "production", + "gender": "female", + "updated_at": "2025-01-29T08:09:29.000Z" + } + }, + { + "job": "Co-Producer", + "jobs": [ + "Co-Producer" + ], + "person": { + "name": "Victoria Alonso", + "ids": { + "trakt": 15745, + "slug": "victoria-alonso", + "imdb": "nm0022285", + "tmdb": 113674, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Victoria Alonso (born 22 December 1965) is an Argentine film producer who formerly served as the president of physical and post-production, visual effects, and animation at Marvel Studios.\n\nVictoria Alonso was born on 22 December 1965 in La Plata, Buenos Aires Province, Argentina.\n\nAlonso moved to Seattle at the age of 19 to pursue an acting career. She relocated again to Los Angeles, where she began working in the visual effects industry, including at Digital Domain as a visual effects producer for four years, working on films such as Big Fish (2003), which was nominated for Best Special Visual Effects at the 57th British Academy Film Awards.\n\nAlonso joined Marvel Studios in 2005 as executive vice president of visual effects and post-production, working as a co-producer on Marvel Cinematic Universe films Iron Man (2008), Iron Man 2 (2010), Thor (2010), and Captain America: The First Avenger (2011), and serving as executive producer on every Marvel Studios production since The Avengers (2012), including television shows. She was promoted to executive vice president of production in 2015. In 2021, Alonso was promoted to president of physical and post-production, visual effects, and animation at Marvel Studios.\n\nIn 2016, Alonso became the first woman to win the Advanced Imaging Society's Harold Lloyd Award for her achievements in visual effects. In January 2020, she was awarded the Filmmaker Award by the Motion Picture Sound Editors at the 67th Golden Reel Awards. In October 2021, it was announced that Alonso would be the top honouree at Outfest's Visionary Award at the November ceremony at LA's Academy Museum of Motion Pictures.\n\nIn December 2022, she was named on The Hollywood Reporter's \"Women in Entertainment Power 100.\".\n\nIn 2023, Alonso was fired from her role at Marvel Studios for breach of contract after violating her noncompete clause by serving as a producer on the Amazon Studios film Argentina, 1985, despite having failed to seek permission to work on the film and continuing to promote it after being ordered by Disney to cease her involvement with the project.\n\nAt the time of her firing, criticism from VFX workers was noted, who had raised complaints of Marvel's \"demanding post-production schedules.\" Alonso was described by some as a \"kingmaker,\" with Chris Lee at Vulture reporting that Alonso was \"singularly responsible for Marvel's toxic work environment.\" However, Alonso was also described as the \"epitome of professional\" and supportive on set, with Joanna Robinson of The Ringer describing the reports as a \"gross mischaracterization\" and the opposite of Alonso's work. Alonso said that the real reason for her firing was her outspoken opposition to LGBTQ+ erasure at the company. Disney and Alonso reached a multimillion-dollar compensation settlement in April.\n\nAlonso is openly gay and is married to Australian actress Imelda Corcoran. The couple has one adopted daughter.\n\nDescription above from the Wikipedia article Victoria Alonso, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1965-12-22", + "death": null, + "birthplace": "La Plata, Argentina", + "homepage": null, + "known_for_department": "production", + "gender": "female", + "updated_at": "2024-12-13T13:12:23.000Z" + } + }, + { + "job": "Executive Producer", + "jobs": [ + "Executive Producer" + ], + "person": { + "name": "David Maisel", + "ids": { + "trakt": 15760, + "slug": "david-maisel", + "imdb": "nm2588950", + "tmdb": 113673, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "production", + "gender": "male", + "updated_at": "2024-09-24T08:02:32.000Z" + } + }, + { + "job": "Executive Producer", + "jobs": [ + "Executive Producer" + ], + "person": { + "name": "Stan Lee", + "ids": { + "trakt": 16093, + "slug": "stan-lee", + "imdb": "nm0498278", + "tmdb": 7624, + "tvrage": null + }, + "social_ids": { + "twitter": "TheRealStanLee", + "facebook": "realstanlee", + "instagram": "therealstanlee", + "wikipedia": null + }, + "biography": "Stan Lee (born Stanley Martin Lieber /ˈliːbər/; December 28, 1922–November 12, 2018) was an American comic book writer, editor, publisher, and producer. He rose through the ranks of a family-run business called Timely Comics, which later became Marvel Comics. He was Marvel's primary creative leader for two decades, expanding it from a small publishing house division to a multimedia corporation that dominated the comics and film industries.\n\nIn collaboration with others at Marvel—particularly co-writers and artists Jack Kirby and Steve Ditko—he co-created iconic characters, including Spider-Man, the X-Men, Iron Man, Thor, the Hulk, Ant-Man, the Wasp, the Fantastic Four, Black Panther, Daredevil, Doctor Strange, the Scarlet Witch, and Black Widow. These and other characters' introductions in the 1960s pioneered a more naturalistic approach in superhero comics. In the 1970s, Lee challenged the restrictions of the Comics Code Authority, indirectly leading to changes in its policies. In the 1980s, he pursued the development of Marvel properties in other media, with mixed results.\n\nFollowing his retirement from Marvel in the 1990s, Lee remained a public figurehead for the company. He frequently made cameo appearances in films and television shows based on Marvel properties, on which he received an executive producer credit, which allowed him to become the person with the highest-grossing film total ever. He continued independent creative ventures until his death, aged 95, in 2018. Lee was inducted into the comic book industry's Will Eisner Award Hall of Fame in 1994 and the Jack Kirby Hall of Fame in 1995. He received the NEA's National Medal of Arts in 2008.\n\nDescription above from the Wikipedia article Stan Lee, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1922-12-28", + "death": "2018-11-12", + "birthplace": "New York City, New York, USA", + "homepage": "https://therealstanlee.com/", + "known_for_department": "writing", + "gender": "male", + "updated_at": "2025-02-15T08:09:58.000Z" + } + }, + { + "job": "Executive Producer", + "jobs": [ + "Executive Producer" + ], + "person": { + "name": "Jon Favreau", + "ids": { + "trakt": 16183, + "slug": "jon-favreau", + "imdb": "nm0269463", + "tmdb": 15277, + "tvrage": null + }, + "social_ids": { + "twitter": "jon_favreau", + "facebook": "jonfavreau", + "instagram": "jonfavreau", + "wikipedia": null + }, + "biography": "Jonathan Kolia Favreau (/ˈfævroʊ/ FAV-roh; born October 19, 1966) is an American filmmaker and actor. As an actor, Favreau has appeared in films such as Rudy (1993), PCU (1994), Swingers (1996), Very Bad Things (1998), Deep Impact (1998), The Replacements (2000), Daredevil (2003), The Break-Up (2006), Four Christmases (2008), Couples Retreat (2009), I Love You, Man (2009), People Like Us (2012), The Wolf of Wall Street (2013), and Chef (2014).\n\nAs a filmmaker, Favreau has been significantly involved with the Marvel Cinematic Universe. He directed, produced, and appeared as Happy Hogan in the films Iron Man (2008) and Iron Man 2 (2010). He also served as an executive producer for and/or appeared as the character in the films The Avengers (2012), Iron Man 3 (2013), Avengers: Age of Ultron (2015), Spider-Man: Homecoming (2017), Avengers: Infinity War (2018), Avengers: Endgame (2019), Spider-Man: Far From Home (2019), Spider-Man: No Way Home (2021), and Deadpool \u0026 Wolverine (2024).\n\nHe has also directed the films Elf (2003), Zathura: A Space Adventure (2005), Cowboys \u0026 Aliens (2011), Chef (2014), The Jungle Book (2016), The Lion King (2019), and The Mandalorian \u0026 Grogu (2026). Recently, Favreau has been known for his work on the Star Wars franchise with Dave Filoni, creating the Disney+ original series The Mandalorian (2019–present), which Filoni helped develop, with both serving as executive producers. Alongside Filoni, he serves as an executive producer on all of the show's spin-off series, including The Book of Boba Fett, Ahsoka, and the upcoming Skeleton Crew. He produces films under his production company banner, Fairview Entertainment, and also presented the variety series Dinner for Five and the cooking series The Chef Show.\n\nDescription above from the Wikipedia article Jon Favreau, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1966-10-19", + "death": null, + "birthplace": "Queens, New York City, New York, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-13T20:04:33.000Z" + } + }, + { + "job": "Producer", + "jobs": [ + "Producer" + ], + "person": { + "name": "Kevin Feige", + "ids": { + "trakt": 16185, + "slug": "kevin-feige", + "imdb": "nm0270559", + "tmdb": 10850, + "tvrage": null + }, + "social_ids": { + "twitter": "Kevfeige", + "facebook": null, + "instagram": "kevfeige", + "wikipedia": null + }, + "biography": "Kevin Feige (/ˈfaɪɡi/ FY-ghee; born June 2, 1973) is an American film and television producer. He has been the president of Marvel Studios and the primary producer of the Marvel Cinematic Universe franchise since 2007. The films he has produced have a combined worldwide box office gross of over $30 billion, making him the highest grossing producer of all time, with Avengers: Endgame becoming the highest-grossing film at the time of its release.\n\nFeige is a member of the Producers Guild of America. In 2018, he was nominated for the Academy Award for Best Picture for producing Black Panther, the first superhero film to receive that honor and the first film in the Marvel Cinematic Universe to win an Academy Award. In October 2019, he became the chief creative officer of Marvel Entertainment.\n\nDescription above from the Wikipedia article Kevin Feige, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1973-06-02", + "death": null, + "birthplace": "Boston, Massachusetts, USA", + "homepage": null, + "known_for_department": "production", + "gender": "male", + "updated_at": "2025-02-15T08:12:01.000Z" + } + }, + { + "job": "Executive Producer", + "jobs": [ + "Executive Producer" + ], + "person": { + "name": "Peter Billingsley", + "ids": { + "trakt": 16197, + "slug": "peter-billingsley", + "imdb": "nm0082526", + "tmdb": 12708, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": "officialpeterb", + "wikipedia": null + }, + "biography": "Peter Billingsley (born April 16, 1971), also known as Peter Michaelsen and Peter Billingsley-Michaelsen, is an American actor, director, and producer. His acting roles include Ralphie Parker in the 1983 movie A Christmas Story, Jack Simmons in The Dirt Bike Kid, Billy in Death Valley, and as Messy Marvin in Hershey's chocolate syrup commercials during the 1980s. He began his career as an infant in television commercials.\n\nDescription above from the Wikipedia article Peter Billingsley, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1971-04-16", + "death": null, + "birthplace": "New York City, New York, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-12-21T08:10:50.000Z" + } + }, + { + "job": "Associate Producer", + "jobs": [ + "Associate Producer" + ], + "person": { + "name": "Jeremy Latcham", + "ids": { + "trakt": 48503, + "slug": "jeremy-latcham", + "imdb": "nm1436246", + "tmdb": 113675, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Jeremy Latcham is an American producer. Prior to launching Latcham Pictures, Latcham served as Senior Vice President of Production and Development at Marvel Studios, where he was an Executive Producer on Spider-Man: Homecoming, Avengers: Age of Ultron, Marvel's The Avengers, and Guardians of the Galaxy. Latcham was the associate producer on Iron Man and the co-producer on Iron Man 2. A graduate of Northwestern University, Latcham joined Marvel Studios in 2004 before leaving in 2017 to start Latcham Pictures.\n\nSince then, he has produced Dungeons \u0026 Dragons: Honor Among Thieves and Bad Times at the El Royale.\n\nIn 2011, Variety featured Latcham as one of Hollywood’s new leaders.", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "https://www.latchampictures.com/", + "known_for_department": "production", + "gender": "male", + "updated_at": "2025-01-09T08:08:46.000Z" + } + }, + { + "job": "Executive In Charge Of Production", + "jobs": [ + "Executive In Charge Of Production" + ], + "person": { + "name": "Ross T. Fanger", + "ids": { + "trakt": 110867, + "slug": "ross-t-fanger", + "imdb": "nm0266771", + "tmdb": 11014, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Ross Fanger is a producer, known for Planet of the Apes (2001), Fantastic Four (2005) and X-Men: The Last Stand (2006).", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "production", + "gender": "male", + "updated_at": "2024-12-03T08:01:41.000Z" + } + }, + { + "job": "Associate Producer", + "jobs": [ + "Associate Producer" + ], + "person": { + "name": "Eric Heffron", + "ids": { + "trakt": 146027, + "slug": "eric-heffron", + "imdb": "nm0373610", + "tmdb": 143894, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "directing", + "gender": "male", + "updated_at": "2024-10-21T17:06:45.000Z" + } + }, + { + "job": "Unit Production Manager", + "jobs": [ + "Unit Production Manager" + ], + "person": { + "name": "Sara E. White", + "ids": { + "trakt": 228014, + "slug": "sara-e-white", + "imdb": "nm0925436", + "tmdb": 1085294, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "production", + "gender": null, + "updated_at": "2023-11-22T17:41:10.000Z" + } + }, + { + "job": "Production Supervisor", + "jobs": [ + "Production Supervisor" + ], + "person": { + "name": "Gary R. Wordham", + "ids": { + "trakt": 563745, + "slug": "gary-r-wordham", + "imdb": null, + "tmdb": 1552051, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "production", + "gender": null, + "updated_at": "2022-10-30T08:04:18.000Z" + } + }, + { + "job": "Production Supervisor", + "jobs": [ + "Production Supervisor" + ], + "person": { + "name": "David J. Grant", + "ids": { + "trakt": 565409, + "slug": "david-j-grant", + "imdb": "nm0335343", + "tmdb": 1533708, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "David J. Grant is one of the Vice Presidents of Physical Production at Marvel Studios.", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "production", + "gender": "male", + "updated_at": "2025-02-16T08:06:45.000Z" + } + }, + { + "job": "Executive In Charge Of Post Production", + "jobs": [ + "Executive In Charge Of Post Production" + ], + "person": { + "name": "Charlie Davis", + "ids": { + "trakt": 576959, + "slug": "charlie-davis", + "imdb": null, + "tmdb": 1562239, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "crew", + "gender": null, + "updated_at": "2022-06-05T08:06:45.000Z" + } + }, + { + "job": "Production Assistant", + "jobs": [ + "Production Assistant" + ], + "person": { + "name": "Trinh Tran", + "ids": { + "trakt": 1456711, + "slug": "trinh-tran", + "imdb": null, + "tmdb": 2275675, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Trinh T. Tran is an executive in production and development at Marvel Studios.\n\nShe was an associate producer on Captain America: Civil War and an executive producer on Avengers: Infinity War and Avengers: Endgame.\n\nShe was also an assistant to Matt Finick and Charlie Davis on Iron Man and The Incredible Hulk, an assistant to Louis D'Esposito on Iron Man 2, Thor, Captain America: The First Avenger and The Avengers and a creative executive on Captain America: The Winter Soldier and Captain America: Civil War.", + "birthday": null, + "death": null, + "birthplace": "Ho Chi Minh City, Vietnam", + "homepage": null, + "known_for_department": "production", + "gender": "female", + "updated_at": "2025-01-24T08:07:55.000Z" + } + } + ], + "camera": [ + { + "job": "Director of Photography", + "jobs": [ + "Director of Photography" + ], + "person": { + "name": "Matthew Libatique", + "ids": { + "trakt": 3739, + "slug": "matthew-libatique", + "imdb": "nm0508732", + "tmdb": 4867, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": "libatique", + "wikipedia": null + }, + "biography": "Matthew Libatique (born July 19, 1968) is an American cinematographer. He is best known for his collaborations with director Darren Aronofsky on the films Pi (1998), Requiem for a Dream (2000), The Fountain (2006), Black Swan (2010), Noah (2014), and Mother! (2017). Libatique has received three Academy Award for Best Cinematography nominations for his work on Black Swan (2010), A Star Is Born (2018), and Maestro (2023).\n\nMatthew Libatique was born in Elmhurst, Queens, New York City, to Filipino immigrant parents Georgina (née José) and Justiniáno Libatique. His father was from Dagupan, and his mother was from Lucena.\n\nLibatique studied sociology and communications at California State University, Fullerton, before earning an MFA in cinematography at AFI Conservatory.\n\nLibatique served as director of photography for music videos and teamed with fellow AFI alumnus Aronofsky for the short film Protozoa. The two collaborated on the first three of Aronofsky's feature films. Other frequent collaborators are Julie Dash (music videos including Tracy Chapman's \"Give Me One Reason\"), Spike Lee (She Hate Me, Inside Man, and Miracle at St. Anna), Joel Schumacher (Tigerland, Phone Booth, and The Number 23), and Jon Favreau (Iron Man, Iron Man 2, and Cowboys \u0026 Aliens).\n\nLibatique's notable films include blockbusters such as Iron Man and Iron Man 2. In 2010, he was nominated for an Academy Award for Best Cinematography for his work on Black Swan, for which he won his second Independent Spirit Award. He has also won best cinematography awards at the LA Film Critics Association, NY Film Critics Online, SF Film Critics, among many others.\n\nLibatique discussed the importance of working closely with a director on a Cinematographer Roundtable with The Hollywood Reporter, revealing: “The main thing is that you (both the cinematographer and director) have the same goal and are telling the same story. Going into preparation, you really need to be on the same page. Conflicts may arise when there’s a miscommunication about what’s important in a scene. So, it’s really important to listen...The director can (understandably) get pulled in a lot of different directions in prep. We, cinematographers, are sort of guarding the gate of filmmaking, amongst all the other things that are happening.”\n\nIn addition to guarding the filmmaking gate, he also says of his process, “I’d like to think each film is custom made. The director obviously dictates the approach that I have because everybody has a different working style. Some people want to talk intensely and visually about shots. Some don’t talk much at all. They concentrate more on the performances, and they give you a broad idea of what they want the film to look like. So my first approach is to evaluate them, which may start in the interview process. But you also learn in preparation, as much as you can, about the director. And that informs how I prepare in pre-production. If I’m lucky, I can shape a visual language off some kind of inspiration. But the director definitely dictates how I do it.”\n\nDescription above from the Wikipedia article Matthew Libatique, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1968-07-19", + "death": null, + "birthplace": "Queens, New York City, New York, USA", + "homepage": null, + "known_for_department": "camera", + "gender": "male", + "updated_at": "2025-01-12T08:10:56.000Z" + } + }, + { + "job": "Additional Photography", + "jobs": [ + "Additional Photography" + ], + "person": { + "name": "Gabriel Beristain", + "ids": { + "trakt": 13375, + "slug": "gabriel-beristain", + "imdb": "nm0075244", + "tmdb": 10832, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Luis Gabriel Beristáin, ASC, BSC, AMC, is a Mexican cinematographer, producer, and television director known for his work on numerous well-known films, including The Distinguished Gentleman, The Spanish Prisoner, Blade II, and Street Kings, and several entries in the Marvel Cinematic Universe, including the Agent Carter television series.\n\nHe has collaborated with filmmakers like Guillermo del Toro, Derek Jarman, David Mamet, and David Ayer. He is an active member of both the Academy of Motion Picture Arts and Sciences and the British Academy of Film and Television Arts.\n\nBeristain was born in Mexico City, the son of actor Luis Beristáin. His interest in filmmaking began with his involvement in Mexico's independent film scene in the seventies. He studied engineering at the Instituto Politécnico Nacional and later joined a new film studies program at the school while also producing audiovisual training materials for the health department.\n\nAfter filming a number of documentaries, he founded a small commercial production company before moving to Italy in 1977. At the recommendation of director Sergio Leone, he relocated to the United Kingdom, where he enrolled in the prestigious National Film and Television School, which accepted only 25 students a year. He was one of only five foreigners to be accepted into the school and studied cinematography under Oswald Morris and Billy Williams.\n\nHis first feature film as a cinematographer was the 1983 Colombian horror film Bloody Flesh (Spanish: Carne de tu carne, \"Flesh of Your Flesh\"), for which he won the Best Cinematography Award at the Bogotá Film Festival. His work on Derek Jarman's 1986 film Caravaggio earned him a Special Silver Bear Award at the Berlin International Film Festival. Beristain was one of several cinematographers on the 1987 anthology film Aria, which was nominated for a Palme d'Or at the Cannes Film Festival. Allen Daviau suggested he move to Hollywood, where he could apply his talents and unique insight into both Mexican and Anglo cultures. Beristain has been a member of the British Society of Cinematographers since 1990 and the American Society of Cinematographers since 2002.\n\nWhile working on 2003's S.W.A.T., Beristain became friends with executive producer Louis D'Esposito, who, after helping form Marvel Studios, invited Beristain to do additional photography for Iron Man. He wound up in the same function in six other Marvel Cinematic Universe films and also served as cinematographer for the D'Esposito-directed Marvel One-Shot short films Item 47 (2012) and Agent Carter (2013), as well as the television series Agent Carter. Beristein would eventually have his first feature for the studio as cinematographer in 2021's Black Widow.\n\nBeristain had a son born in 1980 who lived in Austria and died in 2000.\n\nBeristain's influences are Gregg Toland, Freddie Young, Emmanuel Lubezki, and Roger Deakins. Description above from the Wikipedia article Gabriel Beristain, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1955-05-09", + "death": null, + "birthplace": "Mexico City, Mexico", + "homepage": "https://www.gabrielberistain.com", + "known_for_department": "camera", + "gender": "male", + "updated_at": "2025-02-01T08:08:46.000Z" + } + }, + { + "job": "Second Unit Director of Photography", + "jobs": [ + "Second Unit Director of Photography" + ], + "person": { + "name": "Jonathan Taylor", + "ids": { + "trakt": 503452, + "slug": "jonathan-taylor-8d85ecf0-7012-4d1b-a711-05cdb53d2173", + "imdb": "nm0852658", + "tmdb": 1424180, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "camera", + "gender": "male", + "updated_at": "2024-06-14T08:08:00.000Z" + } + } + ], + "art": [ + { + "job": "Production Design", + "jobs": [ + "Production Design" + ], + "person": { + "name": "J. Michael Riva", + "ids": { + "trakt": 5968, + "slug": "j-michael-riva", + "imdb": "nm0728951", + "tmdb": 13304, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "John Michael Riva (June 28, 1948–June 7, 2012), better known as J. Michael Riva, was an American production designer.\n\nRiva was born in Manhattan to William Riva, a Broadway set designer, and Maria Elisabeth Sieber, a German-born actress and the daughter of Marlene Dietrich. Riva had three brothers (John Peter, John Paul, and John David). Riva attended the prep school Institute Le Rosey in Switzerland for six years before attending UCLA. Married to Wendy Mickell, he had four sons, Jean-Paul, Mikey, Daniel, and Adam.\n\nRiva had a long and prestigious career as an art director and production designer on numerous films, including the 1985 film The Colour Purple, for which he was nominated for the Academy Award for Best Art Direction. Other credits include The Goonies (1985), Lethal Weapon (1987), A Few Good Men (1992), Spider-Man 3 (2007), Iron Man (2008), and Iron Man 2 (2010).\n\nHis final films, The Amazing Spider-Man and Django Unchained, were released posthumously. He was the production designer for the opening ceremony of the 1996 Summer Olympics in Atlanta, as well as for the 74th and 79th Academy Awards in 2002 and 2007, respectively. He won a Primetime Emmy Award for his work on the latter.\n\nRiva suffered a stroke on June 1, 2012, in New Orleans, Louisiana, during production of Django Unchained. He died in a hospital there on June 7, 2012, at age 63. Django director Quentin Tarantino commented, \"Michael became a dear friend on this picture, as well as a magnificent, talented colleague.\"\n\nDescription above from the Wikipedia article J. Michael Riv, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1948-06-28", + "death": "2012-06-07", + "birthplace": "Manhattan, New York, USA", + "homepage": null, + "known_for_department": "art", + "gender": "male", + "updated_at": "2024-12-06T08:01:30.000Z" + } + }, + { + "job": "Supervising Art Director", + "jobs": [ + "Supervising Art Director" + ], + "person": { + "name": "David F. Klassen", + "ids": { + "trakt": 8605, + "slug": "david-f-klassen", + "imdb": "nm0458333", + "tmdb": 14349, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "art", + "gender": "male", + "updated_at": "2024-12-07T08:02:46.000Z" + } + }, { - "job": "Casting", + "job": "Art Direction", "jobs": [ - "Casting" + "Art Direction" ], "person": { - "name": "Sarah Finn", + "name": "Richard F. Mays", "ids": { - "trakt": 3223, - "slug": "sarah-finn", - "imdb": "nm0278168", - "tmdb": 7232, + "trakt": 12080, + "slug": "richard-f-mays", + "imdb": "nm0563081", + "tmdb": 14350, "tvrage": null }, - "biography": "Sarah Finn, sometimes credited Sarah Halley Finn is an American casting director. She has a casting director credit on every Marvel Studios film except The Incredible Hulk.", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "art", + "gender": "male", + "updated_at": "2024-06-12T08:14:43.000Z" } }, { - "job": "Producer", + "job": "Property Master", "jobs": [ - "Producer" + "Property Master" ], "person": { - "name": "Avi Arad", + "name": "Russell Bobbitt", "ids": { - "trakt": 5928, - "slug": "avi-arad", - "imdb": "nm0032696", - "tmdb": 7626, + "trakt": 15582, + "slug": "russell-bobbitt", + "imdb": "nm0090309", + "tmdb": 1004624, "tvrage": null }, - "biography": "Avi Arad is an Israeli-American businessman. He became the CEO of the company Toy Biz in the 1990s, and soon afterward became the chief creative officer of Marvel Entertainment, a Marvel director, and the chairman, CEO, and founder of Marvel Studios.", - "birthday": "1948-08-01", + "social_ids": { + "twitter": "MarvelProps", + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Russell Bobbitt is a property master.\n\nBobbitt has worked on multiple Hollywood films. Since working as a property master on Marvel Studios' Iron Man (2008), he has worked on many of the studio's projects, for example, Captain America: Civil War (2016), Avengers: Endgame (2019), and some of their Disney+ TV shows as well: WandaVision (2021), Loki (2021), and Hawkeye (2021).\n\nBobbitt has won multiple of Hamilton's \"Behind the Camera Awards,\"  including in 2008 for his prop work on \"Iron Man\" (friend and actress Lucy Liu presented), in 2011 for his collective work as Property Master on \"Thor,\" \"Cowboys \u0026 Aliens,\" and \"Hangover 2\" (Bobbitt's \"Iron Man\" and \"Cowboys \u0026 Aliens\" director and friend Jon Favreau presented).\n\nHe is currently working on many of Marvel Studios' upcoming projects as property master.", + "birthday": null, "death": null, - "birthplace": "Givatayim, Israel", - "homepage": null + "birthplace": null, + "homepage": "https://russellbobbitt.com/", + "known_for_department": "art", + "gender": "male", + "updated_at": "2024-12-13T13:12:23.000Z" } }, { - "job": "Executive Producer", + "job": "Assistant Art Director", "jobs": [ - "Executive Producer" + "Assistant Art Director" ], "person": { - "name": "Ari Arad", + "name": "Michael E. Goldman", "ids": { - "trakt": 15632, - "slug": "ari-arad", - "imdb": "nm7191024", - "tmdb": 937174, + "trakt": 16847, + "slug": "michael-e-goldman", + "imdb": "nm0325864", + "tmdb": 60223, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "art", + "gender": null, + "updated_at": "2024-06-06T08:09:07.000Z" } }, { - "job": "Casting", + "job": "Art Direction", "jobs": [ - "Casting" + "Art Direction" ], "person": { - "name": "Randi Hiller", + "name": "Suzan Wexler", "ids": { - "trakt": 15636, - "slug": "randi-hiller", - "imdb": "nm0384900", - "tmdb": 20540, + "trakt": 16849, + "slug": "suzan-wexler", + "imdb": "nm0923328", + "tmdb": 963355, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "art", + "gender": "female", + "updated_at": "2024-12-11T08:10:11.000Z" } }, { - "job": "Co-Producer", + "job": "Graphic Designer", "jobs": [ - "Co-Producer" + "Graphic Designer" ], "person": { - "name": "Victoria Alonso", + "name": "Dianne Chadwick", "ids": { - "trakt": 15745, - "slug": "victoria-alonso", - "imdb": "nm0022285", - "tmdb": 113674, + "trakt": 179863, + "slug": "dianne-chadwick", + "imdb": "nm0149478", + "tmdb": 562696, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "art", + "gender": null, + "updated_at": "2024-11-05T08:09:41.000Z" } }, { - "job": "Executive Producer", + "job": "Storyboard Artist", "jobs": [ - "Executive Producer" + "Storyboard Artist" ], "person": { - "name": "Stan Lee", + "name": "Philip Keller", "ids": { - "trakt": 16093, - "slug": "stan-lee", - "imdb": "nm0498278", - "tmdb": 7624, - "tvrage": 37350 + "trakt": 544433, + "slug": "philip-keller", + "imdb": "nm0445745", + "tmdb": 1463323, + "tvrage": null }, - "biography": "Stan Lee (born Stanley Martin Lieber, December 28, 1922 – November 12, 2018)  was an American comic book writer, editor, actor, producer, publisher, television personality, and the former president and chairman of Marvel Comics.\n\nIn collaboration with several artists, most notably Jack Kirby and Steve Ditko, he co-created Spider-Man, the Fantastic Four, the X-Men, the Avengers, Iron Man, the Hulk, Thor, Daredevil, Doctor Strange, and many other fictional characters, introducing complex, naturalistic characters and a thoroughly shared universe into superhero comic books. In addition, he headed the first major successful challenge to the industry's censorship organization, the Comics Code Authority, and forced it to reform its policies. Lee subsequently led the expansion of Marvel Comics from a small division of a publishing house to a large multimedia corporation.\n\nHe was inducted into the comic book industry's Will Eisner Comic Book Hall of Fame in 1994 and the Jack Kirby Hall of Fame in 1995.", - "birthday": "1922-12-28", - "death": "2018-11-12", - "birthplace": "New York City, New York, USA", - "homepage": "http://therealstanlee.com/" + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "art", + "gender": null, + "updated_at": "2024-02-14T03:46:38.000Z" } }, { - "job": "Producer", + "job": "Storyboard Artist", "jobs": [ - "Producer" + "Storyboard Artist" ], "person": { - "name": "Kevin Feige", + "name": "David Lowery", "ids": { - "trakt": 16185, - "slug": "kevin-feige", - "imdb": "nm0270559", - "tmdb": 10850, + "trakt": 544442, + "slug": "david-lowery-a6718b7b-fb3b-4097-99f5-271711999469", + "imdb": "nm0523204", + "tmdb": 1463332, "tvrage": null }, - "biography": "Kevin Feige was born on June 2, 1973 in Boston, Massachusetts, USA. He is a producer, known for The Avengers (2012), Guardians of the Galaxy (2014) and Iron Man (2008). He is married to Caitlin Feige.", - "birthday": "1973-06-02", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": "1959-04-10", "death": null, - "birthplace": "Boston, Massachusetts, USA", - "homepage": null + "birthplace": "Chicago, Illinois, USA", + "homepage": null, + "known_for_department": "art", + "gender": "male", + "updated_at": "2025-02-03T08:10:22.000Z" } }, { - "job": "Production Assistant", + "job": "Storyboard Artist", "jobs": [ - "Production Assistant" + "Storyboard Artist" ], "person": { - "name": "Trinh Tran", + "name": "Eric Ramsey", "ids": { - "trakt": 1456711, - "slug": "trinh-tran", + "trakt": 1050223, + "slug": "eric-ramsey", "imdb": null, - "tmdb": 2275675, + "tmdb": 1957619, "tvrage": null }, - "biography": "Trinh T. Tran was an associate producer on Captain America: Civil War and an executive producer on Avengers: Infinity War and Avengers: Endgame.\n\nShe was also an assistant to Matt Finick and Charlie Davis on Iron Man and The Incredible Hulk, an assistant to Louis D'Esposito on Iron Man 2, Thor, Captain America: The First Avenger and The Avengers and a creative executive on Captain America: The Winter Soldier and Captain America: Civil War.", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", "birthday": null, "death": null, - "birthplace": "Ho Chi Minh City, Vietnam", - "homepage": null + "birthplace": null, + "homepage": null, + "known_for_department": "art", + "gender": "male", + "updated_at": "2023-12-19T08:18:51.000Z" } - } - ], - "camera": [ + }, { - "job": "Director of Photography", + "job": "Storyboard Artist", "jobs": [ - "Director of Photography" + "Storyboard Artist" ], "person": { - "name": "Matthew Libatique", + "name": "Stephen Platt", "ids": { - "trakt": 3739, - "slug": "matthew-libatique", - "imdb": "nm0508732", - "tmdb": 4867, + "trakt": 1427131, + "slug": "stephen-platt", + "imdb": null, + "tmdb": 2243003, "tvrage": null }, - "biography": "Matthew Libatique is an American cinematographer best known for his work with director Darren Aronofsky on such films as π, Requiem for a Dream, The Fountain, and Black Swan.", - "birthday": "1968-07-19", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", + "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "art", + "gender": null, + "updated_at": "2022-06-01T08:17:16.000Z" } } ], - "art": [ + "sound": [ { - "job": "Art Direction", + "job": "Sound Designer", "jobs": [ - "Art Direction" + "Sound Designer" ], "person": { - "name": "J. Michael Riva", + "name": "Shannon Mills", "ids": { - "trakt": 5968, - "slug": "j-michael-riva", - "imdb": "nm0728951", - "tmdb": 13304, + "trakt": 6848, + "slug": "shannon-mills", + "imdb": "nm0590185", + "tmdb": 8159, "tvrage": null }, - "biography": "", - "birthday": "1948-06-28", - "death": "2012-06-07", - "birthplace": "Manhattan, New York, USA", - "homepage": null + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Born and raised in Indiana, Shannon started his career in film sound, working with some of the most talented people in the business. Getting a job as the Sound Librarian at Skywalker Sound, he quickly began work for the likes of Christopher Boyes, Gary Rydstrom, Richard Hymns, and George Watters. After working as Gary Rydstrom’s right hand man for 8 years, he began branching out to do work on his own, still under the guidance of Chris Boyes and George Watters. He now remains a freelance supervising sound editor and sound designer.", + "birthday": null, + "death": null, + "birthplace": "Indiana, USA", + "homepage": null, + "known_for_department": "sound", + "gender": null, + "updated_at": "2025-02-11T08:08:47.000Z" } }, { - "job": "Property Master", + "job": "Original Music Composer", "jobs": [ - "Property Master" + "Original Music Composer" ], "person": { - "name": "Russell Bobbitt", + "name": "Ramin Djawadi", "ids": { - "trakt": 15582, - "slug": "russell-bobbitt", - "imdb": "nm0090309", - "tmdb": 1004624, + "trakt": 15629, + "slug": "ramin-djawadi", + "imdb": "nm1014697", + "tmdb": 10851, + "tvrage": null + }, + "social_ids": { + "twitter": "Djawadi_Ramin", + "facebook": "RaminDjawadiOfficial", + "instagram": "ramindjawadi_official", + "wikipedia": null + }, + "biography": "Ramin Djawadi (born 19 July 1974) is an Iranian-German film score composer, conductor, and record producer. He is known for his scores for the HBO series Game of Thrones, for which he was nominated for Grammy Awards in 2018 and 2020. He is also the composer for the HBO Game of Thrones prequel series, House of the Dragon (2022–present). He has scored films such as Clash of the Titans, Pacific Rim, Warcraft, A Wrinkle in Time, Iron Man, and Eternals; television series including 3 Body Problem, Prison Break, Person of Interest, Jack Ryan, Westworld, and Fallout; and video games such as Medal of Honour, Gears of War 4, Gears 5, and System Shock 2. He won two consecutive Emmy Awards for Game of Thrones, in 2018 for the episode \"The Dragon and the Wolf\" and in 2019 for \"The Long Night.\".\n\nDescription above from the Wikipedia article Ramin Djawadi, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1974-07-19", + "death": null, + "birthplace": "Duisburg, Germany", + "homepage": "https://www.ramindjawadi.com/", + "known_for_department": "sound", + "gender": "male", + "updated_at": "2025-02-10T08:01:18.000Z" + } + }, + { + "job": "Music Supervisor", + "jobs": [ + "Music Supervisor" + ], + "person": { + "name": "Dave Jordan", + "ids": { + "trakt": 20344, + "slug": "dave-jordan", + "imdb": "nm1044497", + "tmdb": 24192, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Dave Jordan started out in the music industry as a record label employee but quickly made the switch to become one of Hollywood's most in-demand theatrical music supervisors. Among the most popular movie series in the world under his guidance are Transformers, Iron Man, Guardians of the Galaxy, Avengers, and Black Panther. Due to his achievements, Dave is now the top-grossing music supervisor in movie history.", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "https://www.formatent.com/dave-jordan", + "known_for_department": "sound", + "gender": "male", + "updated_at": "2025-02-16T08:06:45.000Z" + } + }, + { + "job": "Music Editor", + "jobs": [ + "Music Editor" + ], + "person": { + "name": "David Klotz", + "ids": { + "trakt": 85635, + "slug": "david-klotz", + "imdb": "nm0460106", + "tmdb": 1070156, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "sound", + "gender": "male", + "updated_at": "2024-11-14T08:23:34.000Z" } - } - ], - "costume & make-up": [ + }, { - "job": "Costume Design", + "job": "Supervising Sound Editor", "jobs": [ - "Costume Design" + "Supervising Sound Editor" ], "person": { - "name": "Laura Jean Shannon", + "name": "Frank E. Eulner", "ids": { - "trakt": 7542, - "slug": "laura-jean-shannon", - "imdb": "nm0788320", - "tmdb": 9551, + "trakt": 143888, + "slug": "frank-e-eulner", + "imdb": "nm0262361", + "tmdb": 1339446, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Frank E. Eulner (born 1963) is a sound editor who is also a member of Skywalker Sound. He was nominated for Best Sound Editing for the film Iron Man at the 81st Academy Awards. His nomination was shared with Christopher Boyes.\n\nHe has over 70 sound editing credits since 1986.\n\nDescription above from the Wikipedia article Frank E. Eulner, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1963-01-01", + "death": null, + "birthplace": "Little Silver, New Jersey, USA", + "homepage": null, + "known_for_department": "sound", + "gender": "male", + "updated_at": "2024-12-08T08:02:34.000Z" + } + }, + { + "job": "Sound Re-Recording Mixer", + "jobs": [ + "Sound Re-Recording Mixer" + ], + "person": { + "name": "Lora Hirschberg", + "ids": { + "trakt": 240616, + "slug": "lora-hirschberg", + "imdb": "nm0386567", + "tmdb": 1327030, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Lora Hirschberg (born 1963) is an American sound engineer. She won the Academy Award for Best Sound Mixing for the film Inception and was nominated for the same award for the film The Dark Knight. She has worked on more than 110 films since 1990.\n\nHirschberg was born near Cleveland, Ohio, in the city of Olmsted Falls, and she attended New York University's film school. She worked in film sound in New York after graduation, having been hired by the film production company American Zoetrope in 1989. She started her career in the company's central machine room and later relocated to San Francisco, California. She is lesbian.", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": "https://www.skysound.com/people/lora-hirschberg/", + "known_for_department": "sound", + "gender": "female", + "updated_at": "2025-02-17T08:06:12.000Z" + } + }, + { + "job": "Music Supervisor", + "jobs": [ + "Music Supervisor" + ], + "person": { + "name": "Richard Bernard", + "ids": { + "trakt": 297225, + "slug": "richard-bernard", + "imdb": "nm0076372", + "tmdb": 1169818, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Actor and multi-instrumentalist Richard Bernard is seen frequently in whimsical commercials playing Contra Bass Balalaika, Bouzouki, Sitar, Concertina, Didgeridoo or other exotic instruments. He is known for his work on films Indiana Jones and the Kingdom of the Crystal Skull, Iron Man, Oscar-winner The Artist, Love Affair, TV shows Gilmore Girls, Monk and many more. When not leading his Klezmer-fusion band The OY!Stars, he has performed with the Los Angeles Philharmonic and Hollywood Bowl Orchestra and been seen by millions as the Orchestra Conductor in United Airlines commercials and other TV shows and films. Born in Brooklyn, NY, Richard began playing music professionally in rural Georgia by age 13 including backing up Jerry Lee Lewis and other Sun Records Rockabilly pioneers. At age 20 after earning a BFA in Theatre from University of Georgia, he moved to Atlanta where he wrote and performed songs for theatre and bands, including a \"black consciousness raising theater\" of which he was a founding member along with Samuel L. Jackson (Richard generally acting the villainous white roles). Performing in shows by Sid \u0026 Marty Krofft led to relocating to Los Angeles where he was a regular on NBC's Barbara Mandrell and the Mandrell Sisters for two seasons while simultaneously leading his trio in downtown L.A.'s Bonaventure Hotel. Session work for films and major artists followed, as did performing in concert with Blue Man Group, Marvin Hamlisch, Theodore Bikel and many others. Along the way, Richard has written hundreds of songs, authored TV animation scripts for Hanna Barbara and Filmation, brought the creations of Jim Henson to life in major films, and played Mandolin, Tenor Banjo and Classical Guitar for Geffen Playhouse's production of Schlemiel the First. He acted the role of mysterious G-Man in Half-Life: Raise the Bar and many Mafia characters. His several international, Rock, and Folk music bands are regularly seen performing around Los Angeles and Las Vegas. Richard gives seminars in Ethnomusicology for students at California State University and University of Southern California, and is active in performing for Holocaust survivor groups.", + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2024-03-16T08:01:23.000Z" + } + }, + { + "job": "Sound Re-Recording Mixer, Sound Designer", + "jobs": [ + "Sound Re-Recording Mixer", + "Sound Designer" + ], + "person": { + "name": "Christopher Boyes", + "ids": { + "trakt": 411301, + "slug": "christopher-boyes", + "imdb": "nm0102110", + "tmdb": 900, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Christopher Boyes is an American sound engineer. He has won four Academy Awards and has been nominated for another eleven. He has worked on more than 100 films since 1991.\n\nDescription above from the Wikipedia article Christopher Boyes, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": null, + "death": null, + "birthplace": "Des Moines, Iowa, USA", + "homepage": "https://www.skysound.com/people/chris-boyes/", + "known_for_department": "sound", + "gender": "male", + "updated_at": "2025-02-07T08:10:27.000Z" + } + }, + { + "job": "Music Editor", + "jobs": [ + "Music Editor" + ], + "person": { + "name": "Shannon Erbe", + "ids": { + "trakt": 458817, + "slug": "shannon-erbe", + "imdb": "nm0258647", + "tmdb": 1396827, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "sound", + "gender": null, + "updated_at": "2024-12-20T08:13:57.000Z" } }, { - "job": "Costume Design", + "job": "Music Coordinator", "jobs": [ - "Costume Design" + "Music Coordinator" ], "person": { - "name": "Rebecca Bentjen", + "name": "Jojo Villanueva", "ids": { - "trakt": 15637, - "slug": "rebecca-bentjen", - "imdb": null, - "tmdb": 962467, + "trakt": 564408, + "slug": "jojo-villanueva", + "imdb": "nm1173467", + "tmdb": 1527657, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "sound", + "gender": null, + "updated_at": "2024-11-16T08:09:11.000Z" } - } - ], - "editing": [ + }, { - "job": "Editor", + "job": "Scoring Mixer", "jobs": [ - "Editor" + "Scoring Mixer" ], "person": { - "name": "Dan Lebental", + "name": "Alan Meyerson", "ids": { - "trakt": 9072, - "slug": "dan-lebental", - "imdb": "nm0495603", - "tmdb": 11455, + "trakt": 625350, + "slug": "alan-meyerson", + "imdb": null, + "tmdb": 1600114, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "sound", + "gender": null, + "updated_at": "2025-01-18T08:08:48.000Z" } - } - ], - "crew": [ + }, { - "job": "Special Effects Coordinator", + "job": "Music Coordinator", "jobs": [ - "Special Effects Coordinator" + "Music Coordinator" ], "person": { - "name": "Daniel Sudick", + "name": "Rebekah Johnson", "ids": { - "trakt": 12238, - "slug": "daniel-sudick", - "imdb": "nm0837203", - "tmdb": 15356, + "trakt": 2127642, + "slug": "rebekah-johnson-2127642", + "imdb": "nm2359435", + "tmdb": 2944425, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "sound", + "gender": "female", + "updated_at": "2024-10-04T08:09:23.000Z" } - }, + } + ], + "costume \u0026 make-up": [ { - "job": "Utility Stunts", + "job": "Costume Design", "jobs": [ - "Utility Stunts" + "Costume Design" ], "person": { - "name": "Tad Griffith", + "name": "Laura Jean Shannon", "ids": { - "trakt": 952530, - "slug": "tad-griffith", - "imdb": "nm0341498", - "tmdb": 1866579, + "trakt": 7542, + "slug": "laura-jean-shannon", + "imdb": "nm0788320", + "tmdb": 9551, "tvrage": null }, - "biography": "Screen Actors Guild Awards Nominee:\n\n2008 Outstanding Performance By A Stunt Ensemble In A Motion Picture: The Kingdom\n\n2008 Outstanding Performance By A Stunt Ensemble In A Motion Picture: 300\n\n2010 World Stunt Awards Winner — Best Work With A Vehicle: Fast And Furious 4\n\nWorld Stunt Awards Nominee:\n\n2005 Best Work With A Vehicle: Spiderman II\n\n2004 Best Stunt By A Stuntman: Seabiscuit\n\n2004 Best Specialty Stunt: Seabiscuit\n\n2004 Best Stunt By A Stuntman: Cradle 2 The Grave\n\n2004 Best Work With A Vehicle: Cradle 2 The Grave\n\n2002 Best Fire Stunt: Last Castle\n\n2002 Best Work With An Animal: American Outlaws\n\nOther Awards & Achievements:\n\n— World Champion Trick Rider\n\n— Third Generation Professional Rodeo Cowboy Association Contestant & Performer", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": "http://www.atadwest.com/" + "homepage": null, + "known_for_department": "costume \u0026 make-up", + "gender": "female", + "updated_at": "2024-09-20T08:01:51.000Z" } }, { - "job": "Utility Stunts", + "job": "Costume Design", "jobs": [ - "Utility Stunts" + "Costume Design" ], "person": { - "name": "Riley Harper", + "name": "Rebecca Gregg", "ids": { - "trakt": 1118940, - "slug": "riley-harper", - "imdb": "nm0363947", - "tmdb": 2020105, + "trakt": 503191, + "slug": "rebecca-gregg", + "imdb": "nm0191945", + "tmdb": 1423983, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, - "birthplace": "Los Angeles, California, USA", - "homepage": "http://www.lifeof-riley.com/" + "birthplace": null, + "homepage": null, + "known_for_department": "costume \u0026 make-up", + "gender": "female", + "updated_at": "2024-04-14T08:09:03.000Z" } } ], @@ -2257,11 +7053,20 @@ "tmdb": 18873, "tvrage": null }, - "biography": "", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Art Marcum is an American screenwriter, working with his screenwriting partner Matt Holloway, best known for writing the scripts of movies like Iron Man and Punisher: War Zone.In 2008, Marcum and Holloway wrote the script of Marvel Studios' superhero film Iron Man, which was directed by Jon Favreau and released on May 2, 2008, by Paramount Pictures. The duo also wrote the script for the action film Punisher: War Zone, directed by Lexi Alexander and released on December 5, 2008, by Lionsgate. They were hired by Paramount to co-write a script with John Fusco for the 2014 Teenage Mutant Ninja Turtles film, but their script was ultimately never used. In 2019, they were hired by Sony to write the script for the adventure film Uncharted, directed by Ruben Fleischer. In 2020, Deadline revealed they were working on a script for the Kraven the Hunter movie.Description above from the Wikipedia article Art Marcum and Matt Holloway, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "writing", + "gender": "male", + "updated_at": "2024-12-10T08:01:23.000Z" } }, { @@ -2278,17 +7083,26 @@ "tmdb": 18875, "tvrage": null }, - "biography": "", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Matt Holloway is an American screenwriter, working with his screenwriting partner Art Marcum, best known for writing the scripts of movies like Iron Man and Punisher: War Zone.In 2008, Marcum and Holloway wrote the script of Marvel Studios' superhero film Iron Man, which was directed by Jon Favreau and released on May 2, 2008, by Paramount Pictures. The duo also wrote the script for the action film Punisher: War Zone, directed by Lexi Alexander and released on December 5, 2008, by Lionsgate. They were hired by Paramount to co-write a script with John Fusco for the 2014 Teenage Mutant Ninja Turtles film, but their script was ultimately never used. In 2019, they were hired by Sony to write the script for the adventure film Uncharted, directed by Ruben Fleischer. In 2020, Deadline revealed they were working on a script for the Kraven the Hunter movie.Description above from the Wikipedia article Art Marcum and Matt Holloway, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "writing", + "gender": "male", + "updated_at": "2024-12-10T08:01:23.000Z" } }, { - "job": "Characters", + "job": "Comic Book", "jobs": [ - "Characters" + "Comic Book" ], "person": { "name": "Larry Lieber", @@ -2299,17 +7113,26 @@ "tmdb": 18876, "tvrage": null }, - "biography": "", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Lawrence D. Lieber (/ˈliːbər/; born October 26, 1931) is an American comic book writer and artist best known as the co-creator of the Marvel Comics superheroes Iron Man, Thor, and Ant-Man. He is also known for his long stint both writing and drawing the Marvel Western Rawhide Kid and for illustrating the newspaper comic strip The Amazing Spider-Man from 1986 to 2018. From 1974 to 1975, he was editor of Atlas/Seaboard Comics. Lieber is the younger brother of Stan Lee, who had been a writer, editor, and publisher of Marvel Comics.\n\nDescription above from the Wikipedia article Larry Lieber, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": "1931-10-26", "death": null, - "birthplace": "New York City, New York", - "homepage": null + "birthplace": "New York City, New York, USA", + "homepage": null, + "known_for_department": "writing", + "gender": "male", + "updated_at": "2025-02-04T15:20:27.000Z" } }, { - "job": "Characters", + "job": "Comic Book", "jobs": [ - "Characters" + "Comic Book" ], "person": { "name": "Don Heck", @@ -2320,17 +7143,26 @@ "tmdb": 18877, "tvrage": null }, - "biography": "", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Donald L. Heck (January 2, 1929 – February 23, 1995) was an American comics artist best known for co-creating the Marvel Comics characters Iron Man, the Wasp, Black Widow, Hawkeye, and Wonder Man and for his long run pencilling the Marvel superhero-team series The Avengers during the 1960s Silver Age of comic books.\n\nDescription above from the Wikipedia article Don Heck, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": "1929-01-02", "death": "1995-02-23", - "birthplace": null, - "homepage": null + "birthplace": "Queens, New York City, New York, USA", + "homepage": null, + "known_for_department": "writing", + "gender": "male", + "updated_at": "2025-01-30T08:01:24.000Z" } }, { - "job": "Characters", + "job": "Comic Book", "jobs": [ - "Characters" + "Comic Book" ], "person": { "name": "Stan Lee", @@ -2339,19 +7171,28 @@ "slug": "stan-lee", "imdb": "nm0498278", "tmdb": 7624, - "tvrage": 37350 + "tvrage": null + }, + "social_ids": { + "twitter": "TheRealStanLee", + "facebook": "realstanlee", + "instagram": "therealstanlee", + "wikipedia": null }, - "biography": "Stan Lee (born Stanley Martin Lieber, December 28, 1922 – November 12, 2018)  was an American comic book writer, editor, actor, producer, publisher, television personality, and the former president and chairman of Marvel Comics.\n\nIn collaboration with several artists, most notably Jack Kirby and Steve Ditko, he co-created Spider-Man, the Fantastic Four, the X-Men, the Avengers, Iron Man, the Hulk, Thor, Daredevil, Doctor Strange, and many other fictional characters, introducing complex, naturalistic characters and a thoroughly shared universe into superhero comic books. In addition, he headed the first major successful challenge to the industry's censorship organization, the Comics Code Authority, and forced it to reform its policies. Lee subsequently led the expansion of Marvel Comics from a small division of a publishing house to a large multimedia corporation.\n\nHe was inducted into the comic book industry's Will Eisner Comic Book Hall of Fame in 1994 and the Jack Kirby Hall of Fame in 1995.", + "biography": "Stan Lee (born Stanley Martin Lieber /ˈliːbər/; December 28, 1922–November 12, 2018) was an American comic book writer, editor, publisher, and producer. He rose through the ranks of a family-run business called Timely Comics, which later became Marvel Comics. He was Marvel's primary creative leader for two decades, expanding it from a small publishing house division to a multimedia corporation that dominated the comics and film industries.\n\nIn collaboration with others at Marvel—particularly co-writers and artists Jack Kirby and Steve Ditko—he co-created iconic characters, including Spider-Man, the X-Men, Iron Man, Thor, the Hulk, Ant-Man, the Wasp, the Fantastic Four, Black Panther, Daredevil, Doctor Strange, the Scarlet Witch, and Black Widow. These and other characters' introductions in the 1960s pioneered a more naturalistic approach in superhero comics. In the 1970s, Lee challenged the restrictions of the Comics Code Authority, indirectly leading to changes in its policies. In the 1980s, he pursued the development of Marvel properties in other media, with mixed results.\n\nFollowing his retirement from Marvel in the 1990s, Lee remained a public figurehead for the company. He frequently made cameo appearances in films and television shows based on Marvel properties, on which he received an executive producer credit, which allowed him to become the person with the highest-grossing film total ever. He continued independent creative ventures until his death, aged 95, in 2018. Lee was inducted into the comic book industry's Will Eisner Award Hall of Fame in 1994 and the Jack Kirby Hall of Fame in 1995. He received the NEA's National Medal of Arts in 2008.\n\nDescription above from the Wikipedia article Stan Lee, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": "1922-12-28", "death": "2018-11-12", "birthplace": "New York City, New York, USA", - "homepage": "http://therealstanlee.com/" + "homepage": "https://therealstanlee.com/", + "known_for_department": "writing", + "gender": "male", + "updated_at": "2025-02-15T08:09:58.000Z" } }, { - "job": "Characters", + "job": "Comic Book", "jobs": [ - "Characters" + "Comic Book" ], "person": { "name": "Jack Kirby", @@ -2360,13 +7201,22 @@ "slug": "jack-kirby", "imdb": "nm0456158", "tmdb": 18866, - "tvrage": 73874 + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null }, - "biography": "From Wikipedia, the free encyclopedia. Jack Kirby (August 28, 1917 – February 6, 1994),  born Jacob Kurtzberg, was an American comic book artist, writer and editor. Growing up poor in New York City, Kurtzberg entered the nascent comics industry in the 1930s. He drew various comic strips under different pen names, ultimately settling on Jack Kirby. In 1941, Kirby and writer Joe Simon created the highly successful superhero character Captain America for Timely Comics. During the 1940s, Kirby would create a number of comics for various publishers, often teaming with Simon.\n\nAfter serving in World War II, Kirby returned to comics and worked in a variety of genres. He contributed to a number of publishers, including Archie Comics and DC Comics, but ultimately found himself at Timely's 1950s iteration, Atlas Comics, later to be known as Marvel Comics. In the 1960s, Kirby co-created many of Marvel Comics' major characters, including the Fantastic Four, the X-Men, and the Hulk, along with writer-editor Stan Lee. Despite the high sales and critical acclaim of the Lee-Kirby titles, Kirby felt treated unfairly, and left the company in 1970 for rival DC Comics.\n\nWhile working for DC, Kirby created his Fourth World saga, which spanned several comics titles. While these and other titles proved commercially unsuccessful and were canceled, several of their characters and the Fourth World mythos have continued as a significant part of the DC Comics universe. Kirby returned to Marvel briefly in the mid-to-late 1970s, then ventured into television animation and independent comics. In his later years, Kirby received great recognition for his career accomplishments, and is regarded by historians and fans as one of the major innovators and most influential creators in the comic book medium.\n\nIn 1987, Kirby, along with Carl Barks and Will Eisner, was one of the three inaugural inductees of the Will Eisner Comic Book Hall of Fame.\n\nDescription above from the Wikipedia article Jack Kirby, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "biography": "Jack Kirby (born Jacob Kurtzberg; August 28, 1917–February 6, 1994) was an American comic book artist, widely regarded as one of the medium's major innovators and one of its most prolific and influential creators. He grew up in New York City and learnt to draw cartoon figures by tracing characters from comic strips and editorial cartoons. He entered the nascent comics industry in the 1930s, drawing various comics features under different pen names, including Jack Curtiss, before settling on Jack Kirby. In 1940, he and writer-editor Joe Simon created the highly successful superhero character Captain America for Timely Comics, predecessor of Marvel Comics. During the 1940s, Kirby regularly teamed with Simon, creating numerous characters for that company and for National Comics Publications, later to become DC Comics.\n\nAfter serving in the European Theatre in World War II, Kirby produced work for DC Comics, Harvey Comics, Hillman Periodicals, and other publishers. At Crestwood Publications, he and Simon created the genre of romance comics and later founded their own short-lived comic company, Mainline Publications. Kirby was involved in Timely's 1950s iteration, Atlas Comics, which in the next decade became Marvel. There, in the 1960s, Kirby cocreated many of the company's major characters, including Ant-Man, the Avengers, the Black Panther, the Fantastic Four, the Hulk, Iron Man, the Silver Surfer, Thor, and the X-Men, among many others. Kirby's titles garnered high sales and critical acclaim, but in 1970, feeling he had been treated unfairly, largely in the realm of authorship credit and creators' rights, Kirby left the company for rival DC.\n\nAt DC, Kirby created his Fourth World saga, which spanned several comic titles. While these series proved commercially unsuccessful and were cancelled, the Fourth World's New Gods have continued as a significant part of the DC Universe. Kirby returned to Marvel briefly in the mid-to-late 1970s and then ventured into television animation and independent comics. In his later years, Kirby, who has been called \"the William Blake of comics,\" began receiving great recognition in the mainstream press for his career accomplishments, and in 1987 he was one of the three inaugural inductees of the Will Eisner Comic Book Hall of Fame. In 2017, Kirby was posthumously named a Disney Legend for his creations not only in the field of publishing but also because those creations formed the basis for The Walt Disney Company's financially and critically successful media franchise, the Marvel Cinematic Universe.\n\nKirby was married to Rosalind Goldstein in 1942. They had four children and remained married until his death from heart failure in 1994, at the age of 76. The Jack Kirby Awards and Jack Kirby Hall of Fame were named in his honour, and he is known as \"The King\" among comics fans for his many influential contributions to the medium.\n\nDescription above from the Wikipedia article Jack Kirby, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": "1917-08-28", "death": "1994-02-06", "birthplace": "New York City, New York, USA", - "homepage": null + "homepage": null, + "known_for_department": "writing", + "gender": "male", + "updated_at": "2025-02-12T19:42:42.000Z" } }, { @@ -2383,11 +7233,20 @@ "tmdb": 79209, "tvrage": null }, - "biography": "", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Hawk Ostby is a screenwriter, working alongside his writing partner, Mark Fergus. They are best known for their work on Children of Men (for which they were nominated for the Academy Award for Best Adapted Screenplay) and Iron Man. Their other work includes First Snow, which was also directed by Fergus, and Cowboys \u0026 Aliens.\n\nThey are the creators and executive producers of the TV series The Expanse, which debuted on Syfy in December 2015 and ended its six-season run on Amazon Prime Video on January 14, 2022.\n\nDescription above from the Wikipedia article Mark Fergus and Hawk Ostby, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": null, "death": null, - "birthplace": null, - "homepage": null + "birthplace": "India", + "homepage": null, + "known_for_department": "writing", + "gender": "male", + "updated_at": "2024-12-10T08:01:23.000Z" } }, { @@ -2404,76 +7263,232 @@ "tmdb": 79207, "tvrage": null }, - "biography": "", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Mark Fergus is a screenwriter, working alongside his writing partner, Hawk Ostby. They are best known for their work on Children of Men (for which they were nominated for the Academy Award for Best Adapted Screenplay) and Iron Man. Their other work includes First Snow, which was also directed by Fergus, and Cowboys \u0026 Aliens.\n\nThey are the creators and executive producers of the TV series The Expanse, which debuted on Syfy in December 2015 and ended its six-season run on Amazon Prime Video on January 14, 2022.\n\nDescription above from the Wikipedia article Mark Fergus and Hawk Ostby, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": null, "death": null, - "birthplace": null, - "homepage": null + "birthplace": "Queens, New York City, New York, USA", + "homepage": null, + "known_for_department": "writing", + "gender": "male", + "updated_at": "2024-12-10T08:01:23.000Z" } } ], - "sound": [ + "visual effects": [ { - "job": "Original Music Composer", + "job": "Visual Effects Producer", "jobs": [ - "Original Music Composer" + "Visual Effects Producer" ], "person": { - "name": "Ramin Djawadi", + "name": "Victoria Alonso", "ids": { - "trakt": 15629, - "slug": "ramin-djawadi", - "imdb": "nm1014697", - "tmdb": 10851, + "trakt": 15745, + "slug": "victoria-alonso", + "imdb": "nm0022285", + "tmdb": 113674, "tvrage": null }, - "biography": "", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Victoria Alonso (born 22 December 1965) is an Argentine film producer who formerly served as the president of physical and post-production, visual effects, and animation at Marvel Studios.\n\nVictoria Alonso was born on 22 December 1965 in La Plata, Buenos Aires Province, Argentina.\n\nAlonso moved to Seattle at the age of 19 to pursue an acting career. She relocated again to Los Angeles, where she began working in the visual effects industry, including at Digital Domain as a visual effects producer for four years, working on films such as Big Fish (2003), which was nominated for Best Special Visual Effects at the 57th British Academy Film Awards.\n\nAlonso joined Marvel Studios in 2005 as executive vice president of visual effects and post-production, working as a co-producer on Marvel Cinematic Universe films Iron Man (2008), Iron Man 2 (2010), Thor (2010), and Captain America: The First Avenger (2011), and serving as executive producer on every Marvel Studios production since The Avengers (2012), including television shows. She was promoted to executive vice president of production in 2015. In 2021, Alonso was promoted to president of physical and post-production, visual effects, and animation at Marvel Studios.\n\nIn 2016, Alonso became the first woman to win the Advanced Imaging Society's Harold Lloyd Award for her achievements in visual effects. In January 2020, she was awarded the Filmmaker Award by the Motion Picture Sound Editors at the 67th Golden Reel Awards. In October 2021, it was announced that Alonso would be the top honouree at Outfest's Visionary Award at the November ceremony at LA's Academy Museum of Motion Pictures.\n\nIn December 2022, she was named on The Hollywood Reporter's \"Women in Entertainment Power 100.\".\n\nIn 2023, Alonso was fired from her role at Marvel Studios for breach of contract after violating her noncompete clause by serving as a producer on the Amazon Studios film Argentina, 1985, despite having failed to seek permission to work on the film and continuing to promote it after being ordered by Disney to cease her involvement with the project.\n\nAt the time of her firing, criticism from VFX workers was noted, who had raised complaints of Marvel's \"demanding post-production schedules.\" Alonso was described by some as a \"kingmaker,\" with Chris Lee at Vulture reporting that Alonso was \"singularly responsible for Marvel's toxic work environment.\" However, Alonso was also described as the \"epitome of professional\" and supportive on set, with Joanna Robinson of The Ringer describing the reports as a \"gross mischaracterization\" and the opposite of Alonso's work. Alonso said that the real reason for her firing was her outspoken opposition to LGBTQ+ erasure at the company. Disney and Alonso reached a multimillion-dollar compensation settlement in April.\n\nAlonso is openly gay and is married to Australian actress Imelda Corcoran. The couple has one adopted daughter.\n\nDescription above from the Wikipedia article Victoria Alonso, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1965-12-22", + "death": null, + "birthplace": "La Plata, Argentina", + "homepage": null, + "known_for_department": "production", + "gender": "female", + "updated_at": "2024-12-13T13:12:23.000Z" + } + }, + { + "job": "Visual Effects Production Manager", + "jobs": [ + "Visual Effects Production Manager" + ], + "person": { + "name": "Susan Pickett", + "ids": { + "trakt": 456991, + "slug": "susan-pickett", + "imdb": "nm0681916", + "tmdb": 1388871, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Susan Pickett is a visual effects producer.", "birthday": null, "death": null, - "birthplace": null, - "homepage": null + "birthplace": "Bronxville, New York, USA", + "homepage": null, + "known_for_department": "visual effects", + "gender": "female", + "updated_at": "2024-12-07T08:02:19.000Z" } }, { - "job": "Supervising Sound Editor", + "job": "Animation Supervisor", "jobs": [ - "Supervising Sound Editor" + "Animation Supervisor" ], "person": { - "name": "Frank E. Eulner", + "name": "Hal Hickel", "ids": { - "trakt": 143888, - "slug": "frank-e-eulner", - "imdb": "nm0262361", - "tmdb": 1339446, + "trakt": 506279, + "slug": "hal-hickel", + "imdb": "nm0382579", + "tmdb": 1426773, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Hal T. Hickel is a visual effects animator for Industrial Light \u0026 Magic.\n\nAt the age of 12, Hickel wrote a letter to Lucasfilm, outlining his ideas for a sequel to the original Star Wars movie (now known as Star Wars Episode IV: A New Hope), and received a polite rejection letter from producer Gary Kurtz. The letter now hangs on the wall of Hickel's office at ILM. Twenty years later, Hickel found himself working on Star Wars after all, as a lead animator on Star Wars: Episode I – The Phantom Menace.\n\nA native of Bailey, Colorado, Hickel joined the Film Graphics Program at CalArts in 1982. He worked at An-FX from 1982 until 1988, and then joined Will Vinton Studios, working in stop-motion and motion control.\n\nHickel began his animation career at Pixar in 1994, where he worked on Toy Story and the THXpromos, as well as some of Pixar's short films. Hearing that a new Star Wars trilogy was in pre-production, Hickel applied for a transfer to ILM on the chance that he might get to work on the prequels. He was first assigned as an animator on The Lost World: Jurassic Park, but was eventually assigned to work on The Phantom Menace, and later its sequel, Star Wars: Episode II – Attack of the Clones, where he was responsible for the unique movement of the Droideka destroyer droids.\n\nHis other credits include A.I. Artificial Intelligence, Space Cowboys, Dreamcatcher and Van Helsing. In 2007, Hickel won the BAFTA and the Academy Award for Best Visual Effects along with John Knoll, Charles Gibson and Allen Hall, for Pirates of the Caribbean: Dead Man's Chest. He also received an Academy Award nomination for his work on Rogue One: A Star Wars Story.\n\nDescription above from the Wikipedia article Hal Hickel, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": null, + "death": null, + "birthplace": "Bailey, Colorado, USA", + "homepage": null, + "known_for_department": "visual effects", + "gender": "male", + "updated_at": "2025-01-24T08:01:14.000Z" + } + }, + { + "job": "Visual Effects", + "jobs": [ + "Visual Effects" + ], + "person": { + "name": "Andy Hass", + "ids": { + "trakt": 535241, + "slug": "andy-hass", + "imdb": "nm3037885", + "tmdb": 1453929, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "visual effects", + "gender": null, + "updated_at": "2022-06-06T08:10:04.000Z" } }, { - "job": "Sound Re-Recording Mixer", + "job": "Visual Effects Supervisor", "jobs": [ - "Sound Re-Recording Mixer" + "Visual Effects Supervisor" ], "person": { - "name": "Christopher Boyes", + "name": "John Nelson", "ids": { - "trakt": 411301, - "slug": "christopher-boyes", - "imdb": "nm0102110", - "tmdb": 900, + "trakt": 537827, + "slug": "john-nelson-ce5eee21-c0df-40fe-ad63-ba2fa5b563f7", + "imdb": "nm0625471", + "tmdb": 1456374, "tvrage": null }, - "biography": "", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "John Nelson (born July 21, 1953) is an American visual effects supervisor. He has won two Academy Awards for Best Visual Effects for his work on the films Gladiator (2000) and Blade Runner 2049 (2017). He has also been nominated for I, Robot (2004) and Iron Man (2008).\n\nHe also won the 2018 British Academy of Film and Television Arts for the Special Visual Effects in Blade Runner 2049.\n\nAfter graduating with high distinction from the University of Michigan in 1976, he worked as a cameraman, technical director, and director at the pioneering computer animation and commercial production company Robert Abel and Associates, where he won two Clio awards and earned six additional Clio nominations. Early in his career while at ILM, Nelson modelled, animated, lit, and composited several shots for Terminator 2: Judgement Day (1991), including the iconic scene in which the shotgunned head of the chrome terminator splits open and reseals. He is a member of the Academy of Motion Picture Arts and Sciences, the Visual Effects Society, the International Cinematographers Guild, and the Directors Guild of America.\n\nDescription above from the Wikipedia article John Nelson, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "birthday": "1959-07-21", + "death": null, + "birthplace": "Detroit, Michigan, USA", + "homepage": "https://www.fullnelsonvfx.com/index.html", + "known_for_department": "visual effects", + "gender": "male", + "updated_at": "2024-12-22T08:07:00.000Z" + } + }, + { + "job": "Effects Supervisor", + "jobs": [ + "Effects Supervisor" + ], + "person": { + "name": "Shane Mahan", + "ids": { + "trakt": 602263, + "slug": "shane-mahan", + "imdb": "nm0536752", + "tmdb": 1600624, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Shane Patrick Mahan (born September 22, 1964) is an American special effects creator, creature designer, puppeteer, and producer known for his work at Stan Winston Studio and its successor, Legacy Effects. His film credits include The Terminator and Terminator 2: Judgement Day; Aliens; Predator and Predator 2; The Lost World: Jurassic Park; Iron Man, Iron Man 2 and Iron Man 3; Pacific Rim and The Shape of Water.\n\nMahan was born and raised in Greenville, Michigan. After graduating from Greenville High School in 1981, he left for Hollywood. His first job was with Stan Winston Studios as a crew member working on The Terminator in 1983. There he worked as a creature effects supervisor. After Stan Winston's passing, he formed Legacy Effects with three partners who were also his former colleagues at Stan Winston Studios, and this has now become the leading VFX company in Hollywood.\n\nMahan and his design team made the special suits worn by Robert Downey Jr. in the film Iron Man. They also designed the suit for Iron Man, which was ten feet tall, weighed 800 lbs, and required five operators to puppeteer. Besides several other nominations, Mahan and his team were nominated for the 2008 Oscar for 'Best Achievement in Visual Effects' for Iron Man. The team was hired as concept artists by James Cameron for his film Avatar. This was special for Mahan, as his first special effects credits were for the film The Terminator.\n\nMahan worked for Stan Winston Studios until the death of Stan Winston in 2008, when he and three other veterans of the company, Lindsay MacGowan, J. Alan Scott, and John Rosengrant, incorporated Legacy Effects, a character design, make-up, and animatronic studio so named in honour of the late Winston's legacy and lifelong achievements. He lives in Los Angeles.", + "birthday": "1964-09-22", + "death": null, + "birthplace": "Greenville, Michigan, USA", + "homepage": null, + "known_for_department": "visual effects", + "gender": "male", + "updated_at": "2025-02-22T08:05:52.000Z" + } + }, + { + "job": "Visual Effects Supervisor", + "jobs": [ + "Visual Effects Supervisor" + ], + "person": { + "name": "Ben Snow", + "ids": { + "trakt": 747447, + "slug": "ben-snow", + "imdb": "nm0811240", + "tmdb": 1715666, + "tvrage": null + }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Ben Snow is a special effects artist who has been nominated four times at the Academy Awards. He works at Industrial Light \u0026 Magic.\n\nSnow grew up in Australia, where he attended Narrabundah College and the University of Canberra. His university degree, which he completed in 1986, was a Bachelor of Arts in Computer Studies.\n\nDescription above from the Wikipedia article Ben Snow, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "visual effects", + "gender": "male", + "updated_at": "2024-11-01T08:01:18.000Z" } } ], @@ -2490,145 +7505,206 @@ "slug": "jon-favreau", "imdb": "nm0269463", "tmdb": 15277, - "tvrage": 29076 + "tvrage": null + }, + "social_ids": { + "twitter": "jon_favreau", + "facebook": "jonfavreau", + "instagram": "jonfavreau", + "wikipedia": null }, - "biography": "Jonathan Favreau (/ˈfævroʊ/; born October 19, 1966) is an American actor, director, producer, and screenwriter.\n\nAs an actor, he is known for roles in films such as \"Rudy\" (1993), \"Swingers\" (1996) (which he also wrote), \"Very Bad Things\" (1998), \"Daredevil\" (2003), \"The Break-Up\" (2006), \"Couples Retreat\" (2009), and \"Chef\" (2014) (which he also wrote and directed).\n\nHe has additionally directed the films \"Elf\" (2003), \"Zathura: A Space Adventure\" (2005), \"Iron Man\" (2008), \"Iron Man 2\" (2010), \"Cowboys & Aliens\" (2011), \"The Jungle Book\" (2016), and \"The Lion King\" (2019) and served as an executive producer on \"The Avengers\" (2012), \"Iron Man 3\" (2013), \"Avengers: Age of Ultron\" (2015), \"Avengers: Infinity War\" (2018) and \"Avengers: Endgame\" (2019). Favreau also portrays Happy Hogan in the Marvel Cinematic Universe and played Pete Becker during season three of the television sitcom \"Friends\". He produces films under his banner, Fairview Entertainment. The company has been credited as co-producers in most of Favreau's directorial ventures.\n\nDescription above from the Wikipedia article Jon Favreau, licensed under CC-BY-SA, full list of contributors on Wikipedia.", + "biography": "Jonathan Kolia Favreau (/ˈfævroʊ/ FAV-roh; born October 19, 1966) is an American filmmaker and actor. As an actor, Favreau has appeared in films such as Rudy (1993), PCU (1994), Swingers (1996), Very Bad Things (1998), Deep Impact (1998), The Replacements (2000), Daredevil (2003), The Break-Up (2006), Four Christmases (2008), Couples Retreat (2009), I Love You, Man (2009), People Like Us (2012), The Wolf of Wall Street (2013), and Chef (2014).\n\nAs a filmmaker, Favreau has been significantly involved with the Marvel Cinematic Universe. He directed, produced, and appeared as Happy Hogan in the films Iron Man (2008) and Iron Man 2 (2010). He also served as an executive producer for and/or appeared as the character in the films The Avengers (2012), Iron Man 3 (2013), Avengers: Age of Ultron (2015), Spider-Man: Homecoming (2017), Avengers: Infinity War (2018), Avengers: Endgame (2019), Spider-Man: Far From Home (2019), Spider-Man: No Way Home (2021), and Deadpool \u0026 Wolverine (2024).\n\nHe has also directed the films Elf (2003), Zathura: A Space Adventure (2005), Cowboys \u0026 Aliens (2011), Chef (2014), The Jungle Book (2016), The Lion King (2019), and The Mandalorian \u0026 Grogu (2026). Recently, Favreau has been known for his work on the Star Wars franchise with Dave Filoni, creating the Disney+ original series The Mandalorian (2019–present), which Filoni helped develop, with both serving as executive producers. Alongside Filoni, he serves as an executive producer on all of the show's spin-off series, including The Book of Boba Fett, Ahsoka, and the upcoming Skeleton Crew. He produces films under his production company banner, Fairview Entertainment, and also presented the variety series Dinner for Five and the cooking series The Chef Show.\n\nDescription above from the Wikipedia article Jon Favreau, licensed under CC-BY-SA, full list of contributors on Wikipedia.", "birthday": "1966-10-19", "death": null, - "birthplace": "Queens, New York, USA", - "homepage": null + "birthplace": "Queens, New York City, New York, USA", + "homepage": null, + "known_for_department": "acting", + "gender": "male", + "updated_at": "2025-02-13T20:04:33.000Z" } }, { - "job": "Script Supervisor", + "job": "First Assistant Director", "jobs": [ - "Script Supervisor" + "First Assistant Director" ], "person": { - "name": "Cristina Weigmann", + "name": "Eric Heffron", "ids": { - "trakt": 469606, - "slug": "cristina-weigmann", - "imdb": "nm0917881", - "tmdb": 1397300, + "trakt": 146027, + "slug": "eric-heffron", + "imdb": "nm0373610", + "tmdb": 143894, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", - "birthday": "1971-01-27", - "death": "2009-07-28", - "birthplace": "Beirut, Lebanon", - "homepage": null + "birthday": null, + "death": null, + "birthplace": null, + "homepage": null, + "known_for_department": "directing", + "gender": "male", + "updated_at": "2024-10-21T17:06:45.000Z" } - } - ], - "visual effects": [ + }, { - "job": "Animation Supervisor", + "job": "Second Unit Director", "jobs": [ - "Animation Supervisor" + "Second Unit Director" ], "person": { - "name": "Hal T. Hickel", + "name": "Phil Neilson", "ids": { - "trakt": 506279, - "slug": "hal-t-hickel", - "imdb": "nm0382579", - "tmdb": 1426773, + "trakt": 423807, + "slug": "phil-neilson", + "imdb": "nm0624827", + "tmdb": 1378239, "tvrage": null }, - "biography": "", + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Philip Cody Neilson is an American stunt coordinator, stunt performer, assistant director, and actor.", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "crew", + "gender": "male", + "updated_at": "2025-02-16T08:06:56.000Z" } }, { - "job": "Visual Effects", + "job": "Script Supervisor", "jobs": [ - "Visual Effects" + "Script Supervisor" ], "person": { - "name": "Andy Hass", + "name": "Cristina Weigmann", "ids": { - "trakt": 535241, - "slug": "andy-hass", - "imdb": "nm3037885", - "tmdb": 1453929, + "trakt": 469606, + "slug": "cristina-weigmann", + "imdb": "nm0917881", + "tmdb": 1397300, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", - "birthday": null, - "death": null, - "birthplace": null, - "homepage": null + "birthday": "1971-01-27", + "death": "2009-07-28", + "birthplace": "Beirut, Lebanon", + "homepage": null, + "known_for_department": "directing", + "gender": "female", + "updated_at": "2024-10-25T08:09:51.000Z" } }, { - "job": "Senior Visual Effects Supervisor", + "job": "Script Supervisor", "jobs": [ - "Senior Visual Effects Supervisor" + "Script Supervisor" ], "person": { - "name": "John Nelson", + "name": "Rebecca Robertson", "ids": { - "trakt": 537827, - "slug": "john-nelson-ce5eee21-c0df-40fe-ad63-ba2fa5b563f7", - "imdb": "nm0625471", - "tmdb": 1456374, + "trakt": 495015, + "slug": "rebecca-robertson", + "imdb": "nm0731980", + "tmdb": 1416438, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "directing", + "gender": "female", + "updated_at": "2024-07-24T08:10:22.000Z" } }, { - "job": "Effects Supervisor", + "job": "Second Assistant Director", "jobs": [ - "Effects Supervisor" + "Second Assistant Director" ], "person": { - "name": "Shane Mahan", + "name": "Michael J. Moore", "ids": { - "trakt": 602263, - "slug": "shane-mahan", - "imdb": null, - "tmdb": 1600624, + "trakt": 778856, + "slug": "michael-j-moore-f9ccb9f6-5c81-4519-ac76-de931812b9de", + "imdb": "nm0601635", + "tmdb": 1734699, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "directing", + "gender": "male", + "updated_at": "2024-06-03T08:08:05.000Z" } - }, + } + ], + "lighting": [ { - "job": "Visual Effects Supervisor", + "job": "Gaffer", "jobs": [ - "Visual Effects Supervisor" + "Gaffer" ], "person": { - "name": "Ben Snow", + "name": "Michael Bauman", "ids": { - "trakt": 747447, - "slug": "ben-snow", - "imdb": null, - "tmdb": 1715666, + "trakt": 516475, + "slug": "michael-bauman", + "imdb": "nm0062177", + "tmdb": 1435657, "tvrage": null }, - "biography": "", + "social_ids": { + "twitter": "BaumanMike", + "facebook": null, + "instagram": null, + "wikipedia": null + }, + "biography": "Michael Bauman is mostly known as chief lighting technician and gaffer. He worked with Paul Thomas Anderson on The Master (2012), Inherent Vice (2014), Radiohead's Daydreaming music video (2016), Phantom Thread (2017), and shared director of photography credits with Anderson on Licorice Pizza (2021). Other films Bauman worked on include Training Day (2001), Munich (2005), Iron Man (2008), Iron Man 2 (2010), Nightcrawler (2014), Ford v Ferrari (2019), The Tragedy of Macbeth (2021), as well as the upcoming David O. Russell film.", "birthday": null, "death": null, "birthplace": null, - "homepage": null + "homepage": null, + "known_for_department": "camera", + "gender": "male", + "updated_at": "2024-07-21T08:09:13.000Z" } - } - ], - "lighting": [ + }, { "job": "Lighting Technician", "jobs": [ @@ -2643,11 +7719,20 @@ "tmdb": 2457356, "tvrage": null }, + "social_ids": { + "twitter": null, + "facebook": null, + "instagram": null, + "wikipedia": null + }, "biography": "", "birthday": "1958-03-29", "death": "2019-11-13", "birthplace": "Los Angeles, California, USA", - "homepage": null + "homepage": null, + "known_for_department": "lighting", + "gender": "male", + "updated_at": "2022-05-23T08:32:49.000Z" } } ] diff --git a/Tests/TraktKitTests/Models/Movies/test_get_movie_studios.json b/Tests/TraktKitTests/Models/Movies/test_get_movie_studios.json new file mode 100644 index 0000000..491fa30 --- /dev/null +++ b/Tests/TraktKitTests/Models/Movies/test_get_movie_studios.json @@ -0,0 +1,47 @@ +[ + { + "name": "20th Century Fox", + "country": "us", + "ids": { + "trakt": 20, + "slug": "20th-century-fox", + "tmdb": 25 + } + }, + { + "name": "Marvel Entertainment", + "country": "us", + "ids": { + "trakt": 19, + "slug": "marvel-entertainment", + "tmdb": 7505 + } + }, + { + "name": "The Donners' Company", + "country": "us", + "ids": { + "trakt": 25, + "slug": "the-donners-company", + "tmdb": 431 + } + }, + { + "name": "TSG Entertainment", + "country": "us", + "ids": { + "trakt": 22, + "slug": "tsg-entertainment", + "tmdb": 22213 + } + }, + { + "name": "Genre Films", + "country": "us", + "ids": { + "trakt": 23, + "slug": "genre-films", + "tmdb": 28788 + } + } +] diff --git a/Tests/TraktKitTests/MovieTests+Async.swift b/Tests/TraktKitTests/MovieTests+Async.swift index 76993f9..b4a1b68 100644 --- a/Tests/TraktKitTests/MovieTests+Async.swift +++ b/Tests/TraktKitTests/MovieTests+Async.swift @@ -64,5 +64,36 @@ extension TraktTestSuite { #expect(movies.count == 10) } + @Test func getPeopleInMovie() async throws { + try mock(.GET, "https://api.trakt.tv/movies/iron-man-2008/people?extended=min", result: .success(jsonData(named: "test_get_cast_and_crew"))) + + let castAndCrew = try await traktManager.movie(id: "iron-man-2008") + .people() + .extend(.Min) + .perform() + + #expect(castAndCrew.cast?.count == 65) + #expect(castAndCrew.crew?.count == 118) + #expect(castAndCrew.editors?.count == 4) + #expect(castAndCrew.producers?.count == 19) + #expect(castAndCrew.camera?.count == 3) + #expect(castAndCrew.art?.count == 11) + #expect(castAndCrew.sound?.count == 12) + #expect(castAndCrew.costume?.count == 2) + #expect(castAndCrew.writers?.count == 8) + #expect(castAndCrew.visualEffects?.count == 7) + #expect(castAndCrew.directors?.count == 6) + #expect(castAndCrew.lighting?.count == 2) + } + + @Test func getMovieStudios() async throws { + try mock(.GET, "https://api.trakt.tv/movies/tron-legacy-2010/studios", result: .success(jsonData(named: "test_get_movie_studios"))) + + let studios = try await traktManager.movie(id: "tron-legacy-2010") + .studios() + .perform() + + #expect(studios.count == 5) + } } } diff --git a/Tests/TraktKitTests/MovieTests.swift b/Tests/TraktKitTests/MovieTests.swift index 3384b34..430fee2 100644 --- a/Tests/TraktKitTests/MovieTests.swift +++ b/Tests/TraktKitTests/MovieTests.swift @@ -363,14 +363,23 @@ final class MovieTests: TraktTestCase { // MARK: - People func test_get_cast_and_crew() throws { - try mock(.GET, "https://api.trakt.tv/movies/tron-legacy-2010/people?extended=min", result: .success(jsonData(named: "test_get_cast_and_crew"))) + try mock(.GET, "https://api.trakt.tv/movies/iron-man-2008/people?extended=min", result: .success(jsonData(named: "test_get_cast_and_crew"))) let expectation = XCTestExpectation(description: "Get movie cast and crew") - traktManager.getPeopleInMovie(movieID: "tron-legacy-2010") { result in + traktManager.getPeopleInMovie(movieID: "iron-man-2008") { result in if case .success(let castAndCrew) = result { + XCTAssertEqual(castAndCrew.cast?.count, 65) + XCTAssertEqual(castAndCrew.crew?.count, 118) + XCTAssertEqual(castAndCrew.editors?.count, 4) + XCTAssertEqual(castAndCrew.producers?.count, 19) + XCTAssertEqual(castAndCrew.camera?.count, 3) + XCTAssertEqual(castAndCrew.art?.count, 11) + XCTAssertEqual(castAndCrew.sound?.count, 12) + XCTAssertEqual(castAndCrew.costume?.count, 2) XCTAssertEqual(castAndCrew.writers?.count, 8) - XCTAssertEqual(castAndCrew.directors?.count, 2) - XCTAssertEqual(castAndCrew.cast?.count, 89) + XCTAssertEqual(castAndCrew.visualEffects?.count, 7) + XCTAssertEqual(castAndCrew.directors?.count, 6) + XCTAssertEqual(castAndCrew.lighting?.count, 2) expectation.fulfill() } } From 7b4262a00e63cc04747ef21734632f0177178253 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 22 Feb 2025 14:47:05 -0500 Subject: [PATCH 13/38] Add remaining show endpoints --- Common/Wrapper/Resources/MovieResource.swift | 6 +- Common/Wrapper/Resources/ShowResource.swift | 186 ++++++++++++++++++- Common/Wrapper/Route.swift | 9 +- 3 files changed, 190 insertions(+), 11 deletions(-) diff --git a/Common/Wrapper/Resources/MovieResource.swift b/Common/Wrapper/Resources/MovieResource.swift index 7a379b6..b0faff6 100644 --- a/Common/Wrapper/Resources/MovieResource.swift +++ b/Common/Wrapper/Resources/MovieResource.swift @@ -173,6 +173,8 @@ extension TraktManager { /** Returns all lists that contain this movie. By default, `personal` lists are returned sorted by the most `popular`. + 📄 Pagination 😁 Emojis + - parameter type: Filter for a specific list type. Possible values: `all` , `personal` , `official` , `watchlists` , `favorites` . - parameter sort: How to sort . Possible values: `popular` , `likes` , `comments` , `items` , `added` , `updated` . */ @@ -213,7 +215,7 @@ extension TraktManager { } /** - Returns lots of movie stats. + Returns all studios for a movie. */ public func studios() -> Route<[TraktStudio]> { Route(paths: [path, "studios"], method: .GET, traktManager: traktManager) @@ -245,7 +247,7 @@ extension TraktManager { 🔥 VIP Only 🔒 OAuth Required */ public func refreshMetadata() -> EmptyRoute { - EmptyRoute(paths: [path, "videos"], method: .GET, requiresAuthentication: true, traktManager: traktManager) + EmptyRoute(paths: [path, "refresh"], method: .GET, requiresAuthentication: true, traktManager: traktManager) } } } diff --git a/Common/Wrapper/Resources/ShowResource.swift b/Common/Wrapper/Resources/ShowResource.swift index fba3418..e2ebc33 100644 --- a/Common/Wrapper/Resources/ShowResource.swift +++ b/Common/Wrapper/Resources/ShowResource.swift @@ -102,6 +102,7 @@ extension TraktManager { /// Trakt ID, Trakt slug, or IMDB ID internal let id: CustomStringConvertible + internal let path: String private let traktManager: TraktManager @@ -110,6 +111,7 @@ extension TraktManager { internal init(id: CustomStringConvertible, traktManager: TraktManager) { self.id = id self.traktManager = traktManager + self.path = "movies/\(id)" } // MARK: - Methods @@ -118,21 +120,21 @@ extension TraktManager { Returns a single shows's details. If you request extended info, the `airs` object is relative to the show's country. You can use the `day`, `time`, and `timezone` to construct your own date then convert it to whatever timezone your user is in. */ public func summary() -> Route { - Route(path: "shows/\(id)", method: .GET, traktManager: traktManager) + Route(path: path, method: .GET, traktManager: traktManager) } /** Returns all title aliases for a show. Includes country where name is different. */ public func aliases() -> Route<[Alias]> { - Route(path: "shows/\(id)/aliases", method: .GET, traktManager: traktManager) + Route(paths: [path, "aliases"], method: .GET, traktManager: traktManager) } /** Returns all content certifications for a show, including the country. */ public func certifications() -> Route { - Route(path: "shows/\(id)/certifications", method: .GET, traktManager: traktManager) + Route(paths: [path, "certifications"], method: .GET, traktManager: traktManager) } /** @@ -141,7 +143,7 @@ extension TraktManager { - parameter language: 2 character language code Example: `es` */ public func translations(language: String? = nil) -> Route<[TraktShowTranslation]> { - Route(paths: ["shows/\(id)/translations", language], method: .GET, traktManager: traktManager) + Route(paths: [path, "translations", language], method: .GET, traktManager: traktManager) } /** @@ -155,7 +157,179 @@ extension TraktManager { - parameter authenticate: comments from blocked users will be automatically filtered out if `true`. */ public func comments(sort: String? = nil, authenticate: Bool = false) -> Route> { - Route(paths: ["shows/\(id)/comments", sort], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) + Route(paths: [path, "comments", sort], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) + } + + /** + Returns all lists that contain this show. By default, `personal` lists are returned sorted by the most `popular`. + + 📄 Pagination 😁 Emojis + + - parameter type: Filter for a specific list type. Possible values: `all` , `personal` , `official` , `watchlists` , `favorites` . + - parameter sort: How to sort . Possible values: `popular` , `likes` , `comments` , `items` , `added` , `updated` . + */ + public func containingLists(type: String? = nil, sort: String? = nil) -> Route> { + Route(paths: [path, "lists", type, sort], method: .GET, traktManager: traktManager) + } + + // MARK: - Progress + + /** + Returns collection progress for a show including details on all aired seasons and episodes. The `next_episode` will be the next episode the user should collect, if there are no upcoming episodes it will be set to `null`. + + By default, any hidden seasons will be removed from the response and stats. To include these and adjust the completion stats, set the `hidden` flag to `true`. + + By default, specials will be excluded from the response. Set the `specials` flag to `true` to include season 0 and adjust the stats accordingly. If you'd like to include specials, but not adjust the stats, set `count_specials` to `false`. + + By default, the `last_episode` and `next_episode` are calculated using the last `aired` episode the user has collected, even if they've collected older episodes more recently. To use their last collected episode for these calculations, set the `last_activity` flag to `collected`. + + > note: Only aired episodes are used to calculate progress. Episodes in the future or without an air date are ignored. + + 🔒 OAuth Required + + - parameter includeHiddenSeasons: Include any hidden seasons + - parameter includeSpecials: Include specials as season 0 + - parameter progressCountsSpecials: Count specials in the overall stats (only applies if specials are included) + */ + public func collectedProgress(includeHiddenSeasons: Bool? = nil, includeSpecials: Bool? = nil, progressCountsSpecials: Bool? = nil) -> Route { + Route( + paths: [path, "progress/collection"], + queryItems: [ + "hidden": includeHiddenSeasons?.description, + "specials": includeSpecials?.description, + "count_specials": progressCountsSpecials?.description + ].compactMapValues { $0 }, + method: .GET, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + /** + Returns watched progress for a show including details on all aired seasons and episodes. The `next_episode` will be the next episode the user should watch, if there are no upcoming episodes it will be set to `null`. If not `null`, the `reset_at` date is when the user started re-watching the show. Your app can adjust the progress by ignoring episodes with a `last_watched_at` prior to the `reset_at`. + + By default, any hidden seasons will be removed from the response and stats. To include these and adjust the completion stats, set the `hidden` flag to `true`. + + By default, specials will be excluded from the response. Set the `specials` flag to `true` to include season 0 and adjust the stats accordingly. If you'd like to include specials, but not adjust the stats, set `count_specials` to `false`. + + By default, the `last_episode` and `next_episode` are calculated using the last `aired` episode the user has watched, even if they've watched older episodes more recently. To use their last watched episode for these calculations, set the `last_activity` flag to watched. + + > note: Only aired episodes are used to calculate progress. Episodes in the future or without an air date are ignored. + + 🔒 OAuth Required + + - parameter includeHiddenSeasons: Include any hidden seasons + - parameter includeSpecials: Include specials as season 0 + - parameter progressCountsSpecials: Count specials in the overall stats (only applies if specials are included) + */ + public func watchedProgress(includeHiddenSeasons: Bool? = nil, includeSpecials: Bool? = nil, progressCountsSpecials: Bool? = nil) -> Route { + Route( + paths: [path, "progress/watched"], + queryItems: [ + "hidden": includeHiddenSeasons?.description, + "specials": includeSpecials?.description, + "count_specials": progressCountsSpecials?.description + ].compactMapValues { $0 }, + method: .GET, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + // MARK: Reset - VIP + + // MARK: - + + /** + Returns all `cast` and `crew` for a show. Each `cast` member will have a `characters` array and a standard `person` object.The `crew` object will be broken up by department into production, `art`, `crew`, `costume & make-up`, `directing`, `writing`, `sound`, `camera`, `visual effects`, `lighting`, and `editing` (if there are people for those crew positions). Each of those members will have a `jobs` array and a standard `person` object. + + **Guest Stars** + + If you add `?extended=guest_stars` to the URL, it will return all guest stars that appeared in at least 1 episode of the show. + + > note: This returns a lot of data, so please only use this extended parameter if you actually need it! + + ✨ Extended Info + */ + public func people() -> Route> { + Route(paths: [path, "people"], method: .GET, traktManager: traktManager) + } + + /** + Returns rating (between 0 and 10) and distribution for a show. + */ + public func ratings() -> Route { + Route(paths: [path, "ratings"], method: .GET, traktManager: traktManager) + } + + /** + Returns related and similar shows. + + 📄 Pagination ✨ Extended Info + */ + public func relatedShows() -> Route> { + Route(paths: [path, "related"], method: .GET, traktManager: traktManager) + } + + /** + Returns lots of show stats. + */ + public func stats() -> Route { + Route(paths: [path, "stats"], method: .GET, traktManager: traktManager) + } + + /** + Returns all studios for a show. + */ + public func studios() -> Route<[TraktStudio]> { + Route(paths: [path, "studios"], method: .GET, traktManager: traktManager) + } + + /** + Returns all users watching this show right now. + + ✨ Extended Info + */ + public func usersWatching() -> Route<[User]> { + Route(paths: [path, "watching"], method: .GET, traktManager: traktManager) + } + + /** + Returns the next scheduled to air episode. If no episode is found, a `204` HTTP status code will be returned. + + ✨ Extended Info + */ + public func nextEpisode() -> Route { + Route(paths: [path, "next_episode"], method: .GET, traktManager: traktManager) + } + + /** + Returns the most recently aired episode. If no episode is found, a `204` HTTP status code will be returned. + + ✨ Extended Info + */ + public func lastEpisode() -> Route { + Route(paths: [path, "last_episode"], method: .GET, traktManager: traktManager) + } + + /** + Returns all videos including trailers, teasers, clips, and featurettes. + + ✨ Extended Info + */ + public func videos() -> Route<[TraktVideo]> { + Route(paths: [path, "videos"], method: .GET, traktManager: traktManager) + } + + /** + Queue this show for a full metadata and image refresh. It might take up to 8 hours for the updated metadata to be availabe through the API. + + > note: If this show is already queued, a `409` HTTP status code will returned. + + 🔥 VIP Only 🔒 OAuth Required + */ + public func refreshMetadata() -> EmptyRoute { + EmptyRoute(paths: [path, "refresh"], method: .GET, requiresAuthentication: true, traktManager: traktManager) } /** @@ -170,7 +344,7 @@ extension TraktManager { ✨ Extended Info */ public func seasons() -> Route<[TraktSeason]> { - Route(path: "shows/\(id)/seasons", method: .GET, traktManager: traktManager) + Route(paths: [path, "seasons"], method: .GET, traktManager: traktManager) } // MARK: - Resources diff --git a/Common/Wrapper/Route.swift b/Common/Wrapper/Route.swift index 7a3b696..34ace08 100644 --- a/Common/Wrapper/Route.swift +++ b/Common/Wrapper/Route.swift @@ -18,6 +18,7 @@ public struct Route: Sendable { internal var path: String internal let method: Method internal let requiresAuthentication: Bool + internal var queryItems: [String: String] private var extended = [ExtendedType]() private var page: Int? @@ -29,16 +30,18 @@ public struct Route: Sendable { // MARK: - Lifecycle - public init(path: String, method: Method, requiresAuthentication: Bool = false, resultType: T.Type = T.self, traktManager: TraktManager) { + public init(path: String, queryItems: [String: String] = [:], method: Method, requiresAuthentication: Bool = false, resultType: T.Type = T.self, traktManager: TraktManager) { self.path = path + self.queryItems = queryItems self.method = method self.requiresAuthentication = requiresAuthentication self.resultType = resultType self.traktManager = traktManager } - public init(paths: [CustomStringConvertible?], method: Method, requiresAuthentication: Bool = false, resultType: T.Type = T.self, traktManager: TraktManager) { + public init(paths: [CustomStringConvertible?], queryItems: [String: String] = [:], method: Method, requiresAuthentication: Bool = false, resultType: T.Type = T.self, traktManager: TraktManager) { self.path = paths.compactMap { $0?.description }.joined(separator: "/") + self.queryItems = queryItems self.method = method self.requiresAuthentication = requiresAuthentication self.resultType = resultType @@ -97,7 +100,7 @@ public struct Route: Sendable { } private func makeRequest(traktManager: TraktManager) throws -> URLRequest { - var query: [String: String] = [:] + var query: [String: String] = queryItems if !extended.isEmpty { query["extended"] = extended.queryString() From ef5b0d37c74606743f128ae2819f1a4f68e451dc Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 22 Feb 2025 15:47:37 -0500 Subject: [PATCH 14/38] Add remaining season endpoints --- Common/Wrapper/Resources/SeasonResource.swift | 81 +++++++++++++++++-- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/Common/Wrapper/Resources/SeasonResource.swift b/Common/Wrapper/Resources/SeasonResource.swift index 262c445..9c3943e 100644 --- a/Common/Wrapper/Resources/SeasonResource.swift +++ b/Common/Wrapper/Resources/SeasonResource.swift @@ -23,6 +23,8 @@ public struct SeasonResource { // MARK: - Methods + // MARK: Season + /** Returns a single seasons for a show. @@ -32,6 +34,8 @@ public struct SeasonResource { Route(path: "\(path)/info", method: .GET, traktManager: traktManager) } + // MARK: Episodes + /** Returns all episodes for a specific season of a show. @@ -42,9 +46,14 @@ public struct SeasonResource { > note: This returns a lot of data, so please only use this extended parameter if you actually need it! ✨ Extended Info + + - parameter translations: Include episode translations. Example: `es` */ - public func episodes() -> Route<[TraktEpisode]> { - Route(path: "\(path)", method: .GET, traktManager: traktManager) + public func episodes(translations: String? = nil) -> Route<[TraktEpisode]> { + Route(path: path, + queryItems: ["translations": translations].compactMapValues { $0 }, + method: .GET, + traktManager: traktManager) } /** @@ -60,11 +69,73 @@ public struct SeasonResource { > note: If you send OAuth, comments from blocked users will be automatically filtered out. 🔓 OAuth Optional 📄 Pagination 😁 Emojis + + - parameter sort: how to sort Example: `newest`. + - parameter authenticate: comments from blocked users will be automatically filtered out if `true`. */ - public func comments() -> Route<[Comment]> { - Route(path: "\(path)/comments", method: .GET, traktManager: traktManager) + public func comments(sort: String? = nil, authenticate: Bool = false) -> Route<[Comment]> { + Route(paths: [path, "comments", sort], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) } - + + /** + Returns all lists that contain this season. By default, `personal` lists are returned sorted by the most `popular`. + + 📄 Pagination 😁 Emojis + + - parameter type: Filter for a specific list type. Possible values: `all` , `personal` , `official` , `watchlists` , `favorites` . + - parameter sort: How to sort . Possible values: `popular` , `likes` , `comments` , `items` , `added` , `updated` . + */ + public func containingLists(type: String? = nil, sort: String? = nil) -> Route> { + Route(paths: [path, "lists", type, sort], method: .GET, traktManager: traktManager) + } + + /** + Returns all `cast` and `crew` for a season. Each `cast` member will have a `characters` array and a standard `person` object.The `crew` object will be broken up by department into production, `art`, `crew`, `costume & make-up`, `directing`, `writing`, `sound`, `camera`, `visual effects`, `lighting`, and `editing` (if there are people for those crew positions). Each of those members will have a `jobs` array and a standard `person` object. + + **Guest Stars** + + If you add `?extended=guest_stars` to the URL, it will return all guest stars that appeared in at least 1 episode of the season. + + > note: This returns a lot of data, so please only use this extended parameter if you actually need it! + + ✨ Extended Info + */ + public func people() -> Route> { + Route(paths: [path, "people"], method: .GET, traktManager: traktManager) + } + + /** + Returns rating (between 0 and 10) and distribution for a season. + */ + public func ratings() -> Route { + Route(paths: [path, "ratings"], method: .GET, traktManager: traktManager) + } + + /** + Returns lots of season stats. + */ + public func stats() -> Route { + Route(paths: [path, "stats"], method: .GET, traktManager: traktManager) + } + + /** + Returns all users watching this season right now. + + ✨ Extended Info + */ + public func usersWatching() -> Route<[User]> { + Route(paths: [path, "watching"], method: .GET, traktManager: traktManager) + } + + /** + Returns all videos including trailers, teasers, clips, and featurettes. + + ✨ Extended Info + */ + public func videos() -> Route<[TraktVideo]> { + Route(paths: [path, "videos"], method: .GET, traktManager: traktManager) + } + // MARK: - Resources public func episode(_ number: Int) -> EpisodeResource { From 42a991a0856edf907445fa4738d1dad0e46d7b14 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 22 Feb 2025 16:41:34 -0500 Subject: [PATCH 15/38] Add remaining episode resources --- .../Wrapper/Resources/EpisodeResource.swift | 51 ++++++++++++------- Common/Wrapper/Resources/SeasonResource.swift | 4 +- Common/Wrapper/Resources/ShowResource.swift | 2 +- .../Shows/ShowCastAndCrew_GuestStars.json | 1 - 4 files changed, 37 insertions(+), 21 deletions(-) diff --git a/Common/Wrapper/Resources/EpisodeResource.swift b/Common/Wrapper/Resources/EpisodeResource.swift index b1da2b6..0e48430 100644 --- a/Common/Wrapper/Resources/EpisodeResource.swift +++ b/Common/Wrapper/Resources/EpisodeResource.swift @@ -26,7 +26,9 @@ public struct EpisodeResource { /** Returns a single episode's details. All date and times are in UTC and were calculated using the episode's `air_date` and show's `country` and `air_time`. - **Note**: If the `first_aired` is unknown, it will be set to `null`. + > note: If the `first_aired` is unknown, it will be set to `null`. + + > note: When getting `full` extended info, the `episode_type` field can have a value of `standard`, `series_premiere` (season 1, episode 1), `season_premiere` (episode 1), `mid_season_finale`, `mid_season_premiere` (the next episode after the mid season finale), `season_finale`, or `series_finale` (last episode to air for an ended show). */ public func summary() -> Route { Route(path: path, method: .GET, traktManager: traktManager) @@ -38,20 +40,21 @@ public struct EpisodeResource { - parameter language: 2 character language code */ public func translations(language: String? = nil) -> Route<[TraktEpisodeTranslation]> { - var path = path + "/translations" - if let language { - path += "/\(language)" - } - return Route(path: path, method: .GET, traktManager: traktManager) + Route(paths: [path, "translations", language], method: .GET, traktManager: traktManager) } /** - Returns all top level comments for an episode. Most recent comments returned first. + Returns all top level comments for an episode. By default, the `newest` comments are returned first. Other sorting options include `oldest`, most `likes`, most `replies`, `highest` rated, `lowest` rated, and most `plays`. - 📄 Pagination + > note: If you send OAuth, comments from blocked users will be automatically filtered out. + + 🔓 OAuth Optional 📄 Pagination 😁 Emojis + + - parameter sort: how to sort Example: `newest`. + - parameter authenticate: comments from blocked users will be automatically filtered out if `true`. */ - public func comments() -> Route<[Comment]> { - Route(path: path + "/comments", method: .GET, traktManager: traktManager) + public func comments(sort: String? = nil, authenticate: Bool = false) -> Route<[Comment]> { + Route(paths: [path, "comments", sort], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) } /** @@ -64,10 +67,15 @@ public struct EpisodeResource { } /** - Returns rating (between 0 and 10) and distribution for an episode. + Returns all lists that contain this episode. By default, `personal` lists are returned sorted by the most `popular`. + + 📄 Pagination 😁 Emojis + + - parameter type: Filter for a specific list type. Possible values: `all` , `personal` , `official` , `watchlists` , `favorites` . + - parameter sort: How to sort . Possible values: `popular` , `likes` , `comments` , `items` , `added` , `updated` . */ - public func ratings() -> Route { - Route(path: path + "/ratings", method: .GET, traktManager: traktManager) + public func containingLists(type: String? = nil, sort: String? = nil) -> Route> { + Route(paths: [path, "lists", type, sort], method: .GET, traktManager: traktManager) } /** @@ -84,30 +92,39 @@ public struct EpisodeResource { ✨ Extended Info */ public func people() -> Route> { - Route(path: path + "/comments", method: .GET, traktManager: traktManager) + Route(paths: [path, "people"], method: .GET, traktManager: traktManager) + } + + /** + Returns rating (between 0 and 10) and distribution for an episode. + */ + public func ratings() -> Route { + Route(paths: [path, "ratings"], method: .GET, traktManager: traktManager) } /** Returns lots of episode stats. */ public func stats() -> Route { - Route(path: path + "/stats", method: .GET, traktManager: traktManager) + Route(paths: [path, "stats"], method: .GET, traktManager: traktManager) } /** Returns all users watching this episode right now. + ✨ Extended Info */ public func usersWatching() -> Route<[User]> { - Route(path: path + "/watching", method: .GET, traktManager: traktManager) + Route(paths: [path, "watching"], method: .GET, traktManager: traktManager) } /** Returns all videos including trailers, teasers, clips, and featurettes. + ✨ Extended Info */ public func videos() -> Route<[TraktVideo]> { - Route(path: path + "/videos", method: .GET, traktManager: traktManager) + Route(paths: [path, "videos"], method: .GET, traktManager: traktManager) } } diff --git a/Common/Wrapper/Resources/SeasonResource.swift b/Common/Wrapper/Resources/SeasonResource.swift index 9c3943e..be17d72 100644 --- a/Common/Wrapper/Resources/SeasonResource.swift +++ b/Common/Wrapper/Resources/SeasonResource.swift @@ -59,8 +59,8 @@ public struct SeasonResource { /** Returns all translations for an season, including language and translated values for title and overview. */ - public func translations(language: String) -> Route<[TraktSeasonTranslation]> { - Route(path: "\(path)/translations/\(language)", method: .GET, traktManager: traktManager) + public func translations(language: String? = nil) -> Route<[TraktSeasonTranslation]> { + Route(paths: [path, "translations", language], method: .GET, traktManager: traktManager) } /** diff --git a/Common/Wrapper/Resources/ShowResource.swift b/Common/Wrapper/Resources/ShowResource.swift index e2ebc33..f00a0ba 100644 --- a/Common/Wrapper/Resources/ShowResource.swift +++ b/Common/Wrapper/Resources/ShowResource.swift @@ -111,7 +111,7 @@ extension TraktManager { internal init(id: CustomStringConvertible, traktManager: TraktManager) { self.id = id self.traktManager = traktManager - self.path = "movies/\(id)" + self.path = "shows/\(id)" } // MARK: - Methods diff --git a/Tests/TraktKitTests/Models/Shows/ShowCastAndCrew_GuestStars.json b/Tests/TraktKitTests/Models/Shows/ShowCastAndCrew_GuestStars.json index f0f3922..a1413ba 100644 --- a/Tests/TraktKitTests/Models/Shows/ShowCastAndCrew_GuestStars.json +++ b/Tests/TraktKitTests/Models/Shows/ShowCastAndCrew_GuestStars.json @@ -10899,4 +10899,3 @@ } ] } - From 2b6413ac5e2ae0f2bc1889930f590408d16406d6 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 1 Mar 2025 09:00:15 -0500 Subject: [PATCH 16/38] Add additional User and Sync endpoints, update models and tests --- .../Authentication/AuthenticationInfo.swift | 1 + Common/Models/BodyPost.swift | 44 +++- Common/Models/Structures.swift | 213 +++++++++++------- Common/Models/Users/HiddenItem.swift | 5 +- Common/Models/Users/HideItemResult.swift | 2 + Common/Models/Users/UnhideItemResult.swift | 2 + Common/Wrapper/Enums.swift | 16 +- Common/Wrapper/Resources/SyncResource.swift | 59 +++++ .../Resources/TraktManager+Resources.swift | 8 +- Common/Wrapper/Resources/UserResource.swift | 153 ++++++++++++- Common/Wrapper/Route.swift | 11 +- Common/Wrapper/TraktManager.swift | 11 +- Common/Wrapper/Users.swift | 12 +- .../Models/Sync/test_get_last_activity.json | 98 ++++---- .../Models/Users/test_add_hidden_item.json | 39 ++-- .../Users/test_post_remove_hidden_items.json | 40 ++-- Tests/TraktKitTests/SyncTests+Async.swift | 45 ++++ Tests/TraktKitTests/UserTests.swift | 8 +- 18 files changed, 566 insertions(+), 201 deletions(-) create mode 100644 Common/Wrapper/Resources/SyncResource.swift create mode 100644 Tests/TraktKitTests/SyncTests+Async.swift diff --git a/Common/Models/Authentication/AuthenticationInfo.swift b/Common/Models/Authentication/AuthenticationInfo.swift index 9d240fc..86d5bea 100644 --- a/Common/Models/Authentication/AuthenticationInfo.swift +++ b/Common/Models/Authentication/AuthenticationInfo.swift @@ -9,6 +9,7 @@ import Foundation public typealias TraktObject = Codable & Hashable & Sendable +public typealias EncodableTraktObject = Encodable & Hashable & Sendable public struct AuthenticationInfo: TraktObject { public let accessToken: String diff --git a/Common/Models/BodyPost.swift b/Common/Models/BodyPost.swift index ef380fe..29e68a3 100644 --- a/Common/Models/BodyPost.swift +++ b/Common/Models/BodyPost.swift @@ -9,21 +9,32 @@ import Foundation /// Body data for endpoints like `/sync/history` that contains Trakt Ids. -struct TraktMediaBody: Encodable { +struct TraktMediaBody: EncodableTraktObject { let movies: [ID]? let shows: [ID]? let seasons: [ID]? let episodes: [ID]? let ids: [Int]? + /// Cast and crew, not users let people: [ID]? - - init(movies: [ID]? = nil, shows: [ID]? = nil, seasons: [ID]? = nil, episodes: [ID]? = nil, ids: [Int]? = nil, people: [ID]? = nil) { + let users: [ID]? + + init( + movies: [ID]? = nil, + shows: [ID]? = nil, + seasons: [ID]? = nil, + episodes: [ID]? = nil, + ids: [Int]? = nil, + people: [ID]? = nil, + users: [ID]? = nil + ) { self.movies = movies self.shows = shows self.seasons = seasons self.episodes = episodes self.ids = ids self.people = people + self.users = users } } @@ -55,37 +66,46 @@ class TraktCommentBody: TraktSingleObjectBody { } } -/// ID used to sync with Trakt. +/** + Trakt or Slug ID to send to Trakt in POST requests related to media objects and users. + */ public struct SyncId: TraktObject { /// Trakt id of the movie / show / season / episode - public let trakt: Int - + public let trakt: Int? + /// Slug id for movie / show / season / episode / user + public let slug: String? + enum CodingKeys: String, CodingKey { case ids } enum IDCodingKeys: String, CodingKey { case trakt + case slug } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) var nested = container.nestedContainer(keyedBy: IDCodingKeys.self, forKey: .ids) - try nested.encode(trakt, forKey: .trakt) + try nested.encodeIfPresent(trakt, forKey: .trakt) + try nested.encodeIfPresent(slug, forKey: .slug) } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let nested = try container.nestedContainer(keyedBy: IDCodingKeys.self, forKey: .ids) - self.trakt = try nested.decode(Int.self, forKey: .trakt) + self.trakt = try nested.decodeIfPresent(Int.self, forKey: .trakt) + self.slug = try nested.decodeIfPresent(String.self, forKey: .slug) } - public init(trakt: Int) { + public init(trakt: Int? = nil, slug: String? = nil) { + assert(!(trakt == nil && slug == nil), "One of the ids must be set.") self.trakt = trakt + self.slug = slug } } -public struct AddToHistoryId: Encodable, Hashable { +public struct AddToHistoryId: EncodableTraktObject { /// Trakt id of the movie / show / season / episode public let trakt: Int /// UTC datetime when the item was watched. @@ -112,7 +132,7 @@ public struct AddToHistoryId: Encodable, Hashable { } } -public struct RatingId: Encodable, Hashable { +public struct RatingId: EncodableTraktObject { /// Trakt id of the movie / show / season / episode public let trakt: Int /// Between 1 and 10. @@ -143,7 +163,7 @@ public struct RatingId: Encodable, Hashable { } } -public struct CollectionId: Encodable, Hashable { +public struct CollectionId: EncodableTraktObject { /// Trakt id of the movie / show / season / episode public let trakt: Int /// UTC datetime when the item was collected. Set to `released` to automatically use the inital release date. diff --git a/Common/Models/Structures.swift b/Common/Models/Structures.swift index d7190f9..52395e2 100644 --- a/Common/Models/Structures.swift +++ b/Common/Models/Structures.swift @@ -137,96 +137,151 @@ public struct TraktMovieStats: TraktObject { public struct TraktLastActivities: TraktObject { public let all: Date - public let movies: TraktLastActivityMovies - public let episodes: TraktLastActivityEpisodes - public let shows: TraktLastActivityShows - public let seasons: TraktLastActivitySeasons - public let comments: TraktLastActivityComments - public let lists: TraktLastActivityLists -} + public let movies: Movies + public let episodes: Episodes + public let shows: Shows + public let seasons: Seasons + public let comments: Comments + public let lists: Lists + public let watchlist: LastUpdated + public let favorites: LastUpdated + public let account: Account + public let savedFilters: LastUpdated + public let notes: LastUpdated -public struct TraktLastActivityMovies: TraktObject { - public let watchedAt: Date - public let collectedAt: Date - public let ratedAt: Date - public let watchlistedAt: Date - public let commentedAt: Date - public let pausedAt: Date - public let hiddenAt: Date - enum CodingKeys: String, CodingKey { - case watchedAt = "watched_at" - case collectedAt = "collected_at" - case ratedAt = "rated_at" - case watchlistedAt = "watchlisted_at" - case commentedAt = "commented_at" - case pausedAt = "paused_at" - case hiddenAt = "hidden_at" + case all + case movies + case episodes + case shows + case seasons + case comments + case lists + case watchlist + case favorites + case account + case savedFilters = "saved_filters" + case notes } -} -public struct TraktLastActivityEpisodes: TraktObject { - public let watchedAt: Date - public let collectedAt: Date - public let ratedAt: Date - public let watchlistedAt: Date - public let commentedAt: Date - public let pausedAt: Date - - enum CodingKeys: String, CodingKey { - case watchedAt = "watched_at" - case collectedAt = "collected_at" - case ratedAt = "rated_at" - case watchlistedAt = "watchlisted_at" - case commentedAt = "commented_at" - case pausedAt = "paused_at" + public struct Movies: TraktObject { + public let watchedAt: Date + public let collectedAt: Date + public let ratedAt: Date + public let watchlistedAt: Date + public let favoritesAt: Date + public let commentedAt: Date + public let pausedAt: Date + public let hiddenAt: Date + + enum CodingKeys: String, CodingKey { + case watchedAt = "watched_at" + case collectedAt = "collected_at" + case ratedAt = "rated_at" + case watchlistedAt = "watchlisted_at" + case favoritesAt = "favorited_at" + case commentedAt = "commented_at" + case pausedAt = "paused_at" + case hiddenAt = "hidden_at" + } } -} -public struct TraktLastActivityShows: TraktObject { - public let ratedAt: Date - public let watchlistedAt: Date - public let commentedAt: Date - public let hiddenAt: Date - - enum CodingKeys: String, CodingKey { - case ratedAt = "rated_at" - case watchlistedAt = "watchlisted_at" - case commentedAt = "commented_at" - case hiddenAt = "hidden_at" + public struct Episodes: TraktObject { + public let watchedAt: Date + public let collectedAt: Date + public let ratedAt: Date + public let watchlistedAt: Date + public let commentedAt: Date + public let pausedAt: Date + + enum CodingKeys: String, CodingKey { + case watchedAt = "watched_at" + case collectedAt = "collected_at" + case ratedAt = "rated_at" + case watchlistedAt = "watchlisted_at" + case commentedAt = "commented_at" + case pausedAt = "paused_at" + } } -} -public struct TraktLastActivitySeasons: TraktObject { - public let ratedAt: Date - public let watchlistedAt: Date - public let commentedAt: Date - public let hiddenAt: Date - - enum CodingKeys: String, CodingKey { - case ratedAt = "rated_at" - case watchlistedAt = "watchlisted_at" - case commentedAt = "commented_at" - case hiddenAt = "hidden_at" + public struct Shows: TraktObject { + public let ratedAt: Date + public let watchlistedAt: Date + public let favoritesAt: Date + public let commentedAt: Date + public let hiddenAt: Date + + enum CodingKeys: String, CodingKey { + case ratedAt = "rated_at" + case watchlistedAt = "watchlisted_at" + case favoritesAt = "favorited_at" + case commentedAt = "commented_at" + case hiddenAt = "hidden_at" + } } -} -public struct TraktLastActivityComments: TraktObject { - public let likedAt: Date - - enum CodingKeys: String, CodingKey { - case likedAt = "liked_at" + public struct Seasons: TraktObject { + public let ratedAt: Date + public let watchlistedAt: Date + public let commentedAt: Date + public let hiddenAt: Date + + enum CodingKeys: String, CodingKey { + case ratedAt = "rated_at" + case watchlistedAt = "watchlisted_at" + case commentedAt = "commented_at" + case hiddenAt = "hidden_at" + } } -} -public struct TraktLastActivityLists: TraktObject { - public let likedAt: Date - public let updatedAt: Date - public let commentedAt: Date - - enum CodingKeys: String, CodingKey { - case likedAt = "liked_at" - case updatedAt = "updated_at" - case commentedAt = "commented_at" + public struct Comments: TraktObject { + public let likedAt: Date + public let blockedAt: Date + + enum CodingKeys: String, CodingKey { + case likedAt = "liked_at" + case blockedAt = "blocked_at" + } + } + + public struct Lists: TraktObject { + public let likedAt: Date + public let updatedAt: Date + public let commentedAt: Date + + enum CodingKeys: String, CodingKey { + case likedAt = "liked_at" + case updatedAt = "updated_at" + case commentedAt = "commented_at" + } + } + + public struct Account: TraktObject { + /// When the OAuth user updates any of their Trakt settings on the website + public let settingsChanged: Date + /// When another Trakt user follows or unfollows the OAuth user. + public let followedChanged: Date + /// When the OAuth user follows or unfollows another Trakt user. + public let followingChanged: Date + /// When the OAuth user follows a private account, which requires their approval. + public let pendingFollowingChanged: Date + /// When the OAuth user has a private account and someone requests to follow them. + public let requestedFollowingChanged: Date + + enum CodingKeys: String, CodingKey { + case settingsChanged = "settings_at" + case followedChanged = "followed_at" + case followingChanged = "following_at" + case pendingFollowingChanged = "pending_at" + case requestedFollowingChanged = "requested_at" + } + } + + public struct LastUpdated: TraktObject { + public let updatedAt: Date + + enum CodingKeys: String, CodingKey { + case updatedAt = "updated_at" + } } } diff --git a/Common/Models/Users/HiddenItem.swift b/Common/Models/Users/HiddenItem.swift index 6e9c821..d19ba38 100644 --- a/Common/Models/Users/HiddenItem.swift +++ b/Common/Models/Users/HiddenItem.swift @@ -15,12 +15,15 @@ public struct HiddenItem: TraktObject { public let movie: TraktMovie? public let show: TraktShow? public let season: TraktSeason? - + public let user: User? + enum CodingKeys: String, CodingKey { case hiddenAt = "hidden_at" case type + case movie case show case season + case user } } diff --git a/Common/Models/Users/HideItemResult.swift b/Common/Models/Users/HideItemResult.swift index f20bcb9..cc9a0ee 100644 --- a/Common/Models/Users/HideItemResult.swift +++ b/Common/Models/Users/HideItemResult.swift @@ -16,12 +16,14 @@ public struct HideItemResult: TraktObject { public let movies: Int public let shows: Int public let seasons: Int + public let users: Int } public struct NotFound: TraktObject { public let movies: [NotFoundIds] public let shows: [NotFoundIds] public let seasons: [NotFoundIds] + public let users: [NotFoundIds] } enum CodingKeys: String, CodingKey { diff --git a/Common/Models/Users/UnhideItemResult.swift b/Common/Models/Users/UnhideItemResult.swift index 745a783..5ad8965 100644 --- a/Common/Models/Users/UnhideItemResult.swift +++ b/Common/Models/Users/UnhideItemResult.swift @@ -16,12 +16,14 @@ public struct UnhideItemResult: TraktObject { public let movies: Int public let shows: Int public let seasons: Int + public let users: Int } public struct NotFound: TraktObject { public let movies: [NotFoundIds] public let shows: [NotFoundIds] public let seasons: [NotFoundIds] + public let users: [NotFoundIds] } enum CodingKeys: String, CodingKey { diff --git a/Common/Wrapper/Enums.swift b/Common/Wrapper/Enums.swift index af1a153..7e0418a 100644 --- a/Common/Wrapper/Enums.swift +++ b/Common/Wrapper/Enums.swift @@ -293,15 +293,21 @@ public enum Period: String, Sendable, CustomStringConvertible { } } -public enum SectionType: String, Sendable { +public struct HiddenItemSection: Sendable { /// Can hide movie, show objects - case Calendar = "calendar" + static let calendar = "calendar" /// Can hide show, season objects - case ProgressWatched = "progress_watched" + static let progressWatched = "progress_watched" /// Can hide show, season objects - case ProgressCollected = "progress_collected" + static let progressWatchedReset = "progress_watched_reset" + /// Can hide show, season objects + static let progressCollected = "progress_collected" /// Can hide movie, show objects - case Recommendations = "recommendations" + static let recommendations = "recommendations" + // Can hide users + static let comments = "comments" + // Can hide shows + static let dropped = "dropped" } public enum HiddenItemsType: String, Sendable { diff --git a/Common/Wrapper/Resources/SyncResource.swift b/Common/Wrapper/Resources/SyncResource.swift new file mode 100644 index 0000000..846bb85 --- /dev/null +++ b/Common/Wrapper/Resources/SyncResource.swift @@ -0,0 +1,59 @@ +// +// SyncResource.swift +// TraktKit +// +// Created by Maximilian Litteral on 3/1/25. +// + +import Foundation + +extension TraktManager { + /** + Syncing with trakt opens up quite a few cool features. Most importantly, trakt can serve as a cloud based backup for the data in your app. This is especially useful when rebuilding a media center or installing a mobile app on your new phone. It can also be nice to sync up multiple media centers with a central trakt account. If everything is in sync, your media can be managed from trakt and be reflected in your apps. + + **Media objects for syncing** + + As a baseline, all add and remove sync methods accept arrays of `movies`, `shows`, and `episodes`. Each of these top level array elements should themselves be an array of standard `movie`, `show`, or `episode` objects. Full examples are in the intro section called **Standard Media Objects**. Keep in mind that `episode` objects really only need the `ids` so it can find an exact match. This is useful for absolute ordered shows. Some methods also have optional metadata you can attach, so check the docs for each specific method. + + Media objects will be matched by ID first, then fall back to title and year. IDs will be matched in this order `trakt`, `imdb`, `tmdb`, `tvdb`, and `slug`. If nothing is found, it will match on the `title` and `year`. If still nothing, it would use just the `title` (or `name` for people) and find the most current object that exists. + + --- + **Watched History Sync** + + This is a 2 way sync that will get items from trakt to sync locally, plus find anything new and sync back to trakt. Perform this sync on startup or at set intervals (i.e. once every day) to keep everything in sync. This will only send data to trakt and not remove it. + + --- + **Collection Sync** + + It's very handy to have a snapshot on trakt of everything you have available to watch locally. Syncing your local connection will do just that. This will only send data to trakt and not remove it. + + --- + **Clean Collection** + + Cleaning a collection involves comparing the trakt collection to what exists locally. This will remove items from the trakt collection if they don't exist locally anymore. You should make this clear to the user that data might be removed from trakt. + */ + public struct SyncResource { + + private let path = "sync" + private let traktManager: TraktManager + + internal init(traktManager: TraktManager) { + self.traktManager = traktManager + } + + // MARK: - Routes + + /** + This method is a useful first step in the syncing process. We recommended caching these dates locally, then you can compare to know exactly what data has changed recently. This can greatly optimize your syncs so you don't pull down a ton of data only to see nothing has actually changed. + + **Account** + + `settings_at` is set when the OAuth user updates any of their Trakt settings on the website. `followed_at` is set when another Trakt user follows or unfollows the OAuth user. `following_at` is set when the OAuth user follows or unfollows another Trakt user. `pending_at` is set when the OAuth user follows a private account, which requires their approval. `requested_at` is set when the OAuth user has a private account and someone requests to follow them. + + 🔒 OAuth Required + */ + public func lastActivities() -> Route { + Route(paths: [path, "last_activities"], method: .GET, requiresAuthentication: true, traktManager: traktManager) + } + } +} diff --git a/Common/Wrapper/Resources/TraktManager+Resources.swift b/Common/Wrapper/Resources/TraktManager+Resources.swift index e20685f..0d8021c 100644 --- a/Common/Wrapper/Resources/TraktManager+Resources.swift +++ b/Common/Wrapper/Resources/TraktManager+Resources.swift @@ -46,13 +46,17 @@ extension TraktManager { EpisodeResource(showId: showId, seasonNumber: season, episodeNumber: episode, traktManager: self) } + public func sync() -> SyncResource { + SyncResource(traktManager: self) + } + // MARK: - User public func currentUser() -> CurrentUserResource { CurrentUserResource(traktManager: self) } - public func user(_ username: String) -> UsersResource { - UsersResource(username: username, traktManager: self) + public func user(_ slug: String) -> UsersResource { + UsersResource(slug: slug, traktManager: self) } } diff --git a/Common/Wrapper/Resources/UserResource.swift b/Common/Wrapper/Resources/UserResource.swift index 16233e7..01fda98 100644 --- a/Common/Wrapper/Resources/UserResource.swift +++ b/Common/Wrapper/Resources/UserResource.swift @@ -11,6 +11,8 @@ extension TraktManager { /// Resource for authenticated user public struct CurrentUserResource { + static let currentUserSlug = "me" + private let traktManager: TraktManager internal init(traktManager: TraktManager) { @@ -60,32 +62,167 @@ extension TraktManager { 🔒 OAuth Required 📄 Pagination */ - public func savedFilters(for section: String? = nil) -> Route<[SavedFilter]> { - let path = ["users/saved_filters", section].compactMap { $0 }.joined(separator: "/") - return Route(path: path, method: .POST, requiresAuthentication: true, traktManager: traktManager) + public func savedFilters(for section: String? = nil) -> Route> { + Route(paths: ["users/saved_filters", section], method: .GET, requiresAuthentication: true, traktManager: traktManager) + } + + // MARK: - Hidden + + /** + Get hidden items for a section. This will return an array of standard media objects. You can optionally limit the type of results to return. + + 🔒 OAuth Required 📄 Pagination ✨ Extended Info + */ + public func hiddenItems(for section: String, type: String? = nil) -> Route> { + Route( + paths: [ + "users/hidden", + section + ], + queryItems: ["type": type].compactMapValues { $0 }, + method: .GET, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + /** + Hide items for a specific section. Here's what type of items can hidden for each section. + + 🔒 OAuth Required + */ + public func hide(movies: [SyncId], shows: [SyncId], seasons: [SyncId], users: [SyncId], in section: String) -> Route { + Route( + paths: [ + "users/hidden", + section + ], + body: TraktMediaBody(movies: movies, shows: shows, seasons: seasons, users: users), + method: .POST, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + /** + Unhide items for a specific section. Here's what type of items can unhidden for each section. + + 🔒 OAuth Required + */ + public func unhide(movies: [SyncId], shows: [SyncId], seasons: [SyncId], users: [SyncId], in section: String) -> Route { + Route( + paths: [ + "users/hidden/remove", + section + ], + body: TraktMediaBody(movies: movies, shows: shows, seasons: seasons, users: users), + method: .POST, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + /** + Get a user's profile information. If the user is private, info will only be returned if you send OAuth and are either that user or an approved follower. Adding `?extended=vip` will return some additional VIP related fields so you can display the user's Trakt VIP status and year count. + + 🔓 OAuth Required ✨ Extended Info + */ + public func profile() -> Route { + UsersResource(slug: Self.currentUserSlug, traktManager: traktManager).profile(authenticate: true) } } /// Resource for /Users/id public struct UsersResource { - public let username: String + public let slug: String + private let path: String private let traktManager: TraktManager - internal init(username: String, traktManager: TraktManager) { - self.username = username + internal init(slug: String, traktManager: TraktManager) { + self.slug = slug + self.path = "users/\(slug)" self.traktManager = traktManager } // MARK: - Methods + /** + Get a user's profile information. If the user is private, info will only be returned if you send OAuth and are either that user or an approved follower. Adding `?extended=vip` will return some additional VIP related fields so you can display the user's Trakt VIP status and year count. + + 🔓 OAuth Optional ✨ Extended Info + */ + public func profile(authenticate: Bool = false) -> Route { + Route(paths: [path], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) + } + + // MARK: - Likes + + /** + Get items a user likes. This will return an array of standard media objects. You can optionally limit the `type` of results to return. + + **Comment Media Objects** + + If you add `?extended=comments` to the URL, it will return media objects for each comment like. + + > note: This returns a lot of data, so please only use this extended parameter if you actually need it! + + 🔒 OAuth Optional 📄 Pagination + */ + public func likes(type: String? = nil, authenticate: Bool = false) -> Route> { + Route(paths: [path, "likes", type], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) + } + + // MARK: - Collection + + /** + Get all collected items in a user's collection. A collected item indicates availability to watch digitally or on physical media. + + Each `movie` object contains `collected_at` and `updated_at` timestamps. Since users can set custom dates when they collected movies, it is possible for `collected_at` to be in the past. We also include `updated_at` to help sync Trakt data with your app. Cache this timestamp locally and only re-process the movie if you see a newer timestamp. + + Each show object contains `last_collected_at` and `last_updated_at` timestamps. Since users can set custom dates when they collected episodes, it is possible for `last_collected_at` to be in the past. We also include `last_updated_at` to help sync Trakt data with your app. Cache this timestamp locally and only re-process the show if you see a newer timestamp. + + If you add `?extended=metadata` to the URL, it will return the additional `media_type`, `resolution`, `hdr`, `audio`, `audio_channels` and `3d` metadata. It will use `null` if the metadata isn't set for an item. + + 🔓 OAuth Optional ✨ Extended Info + + - parameter type: `movies` or `shows` + */ + public func collection(type: String, authenticate: Bool = false) -> Route<[TraktCollectedItem]> { + Route(paths: [path, "collection", type], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) + } + + // MARK: - Comments + + /** + Returns the most recently written comments for the user. You can optionally filter by the `comment_type` and media `type` to limit what gets returned. + + By default, only top level comments are returned. Set `?include_replies=true` to return replies in addition to top level comments. Set `?include_replies=only` to return only replies and no top level comments. + + 🔓 OAuth Optional 📄 Pagination ✨ Extended Info + */ + public func comments(commentType: String? = nil, mediaType: String? = nil, includeReplies: String? = nil, authenticate: Bool = false) -> Route> { + Route( + paths: [ + path, + "comments", + commentType, + mediaType + ], + queryItems: ["include_replies": includeReplies].compactMapValues { $0 }, + method: .GET, + requiresAuthentication: authenticate, + traktManager: traktManager + ) + } + // MARK: Settings public func lists() -> Route<[TraktList]> { - Route(path: "users/\(username)/lists", method: .GET, traktManager: traktManager) + Route(paths: [path, "lists"], method: .GET, traktManager: traktManager) } public func itemsOnList(_ listId: String, type: ListItemType? = nil) -> Route<[TraktListItem]> { - Route(paths: ["users/\(username)/lists/\(listId)/items", type?.rawValue], method: .GET, traktManager: traktManager) + Route(paths: ["users/\(slug)/lists/\(listId)/items", type?.rawValue], method: .GET, traktManager: traktManager) } } } diff --git a/Common/Wrapper/Route.swift b/Common/Wrapper/Route.swift index 34ace08..257c69c 100644 --- a/Common/Wrapper/Route.swift +++ b/Common/Wrapper/Route.swift @@ -28,20 +28,24 @@ public struct Route: Sendable { private var searchType: SearchType? private var searchQuery: String? + private var body: (any EncodableTraktObject)? + // MARK: - Lifecycle - public init(path: String, queryItems: [String: String] = [:], method: Method, requiresAuthentication: Bool = false, resultType: T.Type = T.self, traktManager: TraktManager) { + public init(path: String, queryItems: [String: String] = [:], body: (any TraktObject)? = nil, method: Method, requiresAuthentication: Bool = false, resultType: T.Type = T.self, traktManager: TraktManager) { self.path = path self.queryItems = queryItems + self.body = body self.method = method self.requiresAuthentication = requiresAuthentication self.resultType = resultType self.traktManager = traktManager } - public init(paths: [CustomStringConvertible?], queryItems: [String: String] = [:], method: Method, requiresAuthentication: Bool = false, resultType: T.Type = T.self, traktManager: TraktManager) { + public init(paths: [CustomStringConvertible?], queryItems: [String: String] = [:], body: (any EncodableTraktObject)? = nil, method: Method, requiresAuthentication: Bool = false, resultType: T.Type = T.self, traktManager: TraktManager) { self.path = paths.compactMap { $0?.description }.joined(separator: "/") self.queryItems = queryItems + self.body = body self.method = method self.requiresAuthentication = requiresAuthentication self.resultType = resultType @@ -136,7 +140,8 @@ public struct Route: Sendable { forPath: path, withQuery: query, isAuthorized: requiresAuthentication, - withHTTPMethod: method + withHTTPMethod: method, + body: body ) } } diff --git a/Common/Wrapper/TraktManager.swift b/Common/Wrapper/TraktManager.swift index 0705958..11fd0f0 100644 --- a/Common/Wrapper/TraktManager.swift +++ b/Common/Wrapper/TraktManager.swift @@ -234,7 +234,8 @@ public final class TraktManager: Sendable { return request } - internal func mutableRequest(forPath path: String, withQuery query: [String: String], isAuthorized authorized: Bool, withHTTPMethod httpMethod: Method) throws -> URLRequest { + internal func mutableRequest(forPath path: String, withQuery query: [String: String], isAuthorized authorized: Bool, withHTTPMethod httpMethod: Method, body: Encodable? = nil) throws -> URLRequest { + // Build URL let urlString = "https://\(apiHost)/" + path guard var components = URLComponents(string: urlString) else { throw TraktKitError.malformedURL } @@ -247,10 +248,13 @@ public final class TraktManager: Sendable { } guard let url = components.url else { throw TraktKitError.malformedURL } + + // Request var request = URLRequest(url: url) request.httpMethod = httpMethod.rawValue request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + // Headers request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.addValue("2", forHTTPHeaderField: "trakt-api-version") request.addValue(clientId, forHTTPHeaderField: "trakt-api-key") @@ -261,6 +265,11 @@ public final class TraktManager: Sendable { } } + // Body + if let body { + request.httpBody = try Self.jsonEncoder.encode(body) + } + return request } diff --git a/Common/Wrapper/Users.swift b/Common/Wrapper/Users.swift index 7a85435..8b5e815 100644 --- a/Common/Wrapper/Users.swift +++ b/Common/Wrapper/Users.swift @@ -97,7 +97,7 @@ extension TraktManager { ✨ Extended Info */ @discardableResult - public func hiddenItems(section: SectionType, type: HiddenItemsType? = nil, extended: [ExtendedType] = [.Min], pagination: Pagination? = nil, completion: @escaping HiddenItemsCompletionHandler) -> URLSessionDataTask? { + public func hiddenItems(section: String, type: HiddenItemsType? = nil, extended: [ExtendedType] = [.Min], pagination: Pagination? = nil, completion: @escaping HiddenItemsCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString()] if let type = type { query["type"] = type.rawValue @@ -110,7 +110,7 @@ extension TraktManager { } } - guard let request = try? mutableRequest(forPath: "users/hidden/\(section.rawValue)", + guard let request = try? mutableRequest(forPath: "users/hidden/\(section)", withQuery: query, isAuthorized: true, withHTTPMethod: .GET) else { return nil } @@ -123,9 +123,9 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func hide(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, from section: SectionType, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { + public func hide(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, from section: String, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons) - guard let request = post("users/hidden/\(section.rawValue)", body: body) else { return nil } + guard let request = post("users/hidden/\(section)", body: body) else { return nil } return performRequest(request: request, completion: completion) } @@ -135,9 +135,9 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func unhide(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, from section: SectionType, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { + public func unhide(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, from section: String, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons) - guard let request = post("users/hidden/\(section.rawValue)/remove", body: body) else { return nil } + guard let request = post("users/hidden/\(section)/remove", body: body) else { return nil } return performRequest(request: request, completion: completion) } diff --git a/Tests/TraktKitTests/Models/Sync/test_get_last_activity.json b/Tests/TraktKitTests/Models/Sync/test_get_last_activity.json index 9a711ba..8f8b7ae 100644 --- a/Tests/TraktKitTests/Models/Sync/test_get_last_activity.json +++ b/Tests/TraktKitTests/Models/Sync/test_get_last_activity.json @@ -1,40 +1,62 @@ { - "movies" : { - "rated_at" : "2014-11-19T18:32:29.000Z", - "watched_at" : "2014-11-19T21:42:41.000Z", - "watchlisted_at" : "2014-11-19T21:42:41.000Z", - "collected_at" : "2014-11-20T06:51:30.000Z", - "paused_at" : "2014-11-20T06:51:30.000Z", - "hidden_at" : "2016-08-20T06:51:30.000Z", - "commented_at" : "2014-11-20T06:51:30.000Z" - }, - "seasons" : { - "watchlisted_at" : "2014-11-20T06:51:30.000Z", - "commented_at" : "2014-11-20T06:51:30.000Z", - "rated_at" : "2014-11-19T19:54:24.000Z", - "hidden_at" : "2016-08-20T06:51:30.000Z" - }, - "comments" : { - "liked_at" : "2014-11-20T03:38:09.000Z" - }, - "lists" : { - "commented_at" : "2014-11-20T06:51:30.000Z", - "updated_at" : "2014-11-20T06:52:18.000Z", - "liked_at" : "2014-11-20T00:36:48.000Z" - }, - "episodes" : { - "watchlisted_at" : "2014-11-20T06:51:30.000Z", - "commented_at" : "2014-11-20T06:51:30.000Z", - "watched_at" : "2014-11-20T06:51:30.000Z", - "rated_at" : "2014-11-20T06:51:30.000Z", - "paused_at" : "2014-11-20T06:51:30.000Z", - "collected_at" : "2014-11-19T22:02:41.000Z" - }, - "all" : "2014-11-20T07:01:32.000Z", - "shows" : { - "watchlisted_at" : "2014-11-20T06:51:30.000Z", - "commented_at" : "2014-11-20T06:51:30.000Z", - "rated_at" : "2014-11-19T19:50:58.000Z", - "hidden_at" : "2016-08-20T06:51:30.000Z" - } + "all": "2014-11-20T07:01:32.000Z", + "movies": { + "watched_at": "2014-11-19T21:42:41.000Z", + "collected_at": "2014-11-20T06:51:30.000Z", + "rated_at": "2014-11-19T18:32:29.000Z", + "watchlisted_at": "2014-11-19T21:42:41.000Z", + "favorited_at": "2014-11-19T21:42:41.000Z", + "commented_at": "2014-11-20T06:51:30.000Z", + "paused_at": "2014-11-20T06:51:30.000Z", + "hidden_at": "2016-08-20T06:51:30.000Z" + }, + "episodes": { + "watched_at": "2014-11-20T06:51:30.000Z", + "collected_at": "2014-11-19T22:02:41.000Z", + "rated_at": "2014-11-20T06:51:30.000Z", + "watchlisted_at": "2014-11-20T06:51:30.000Z", + "commented_at": "2014-11-20T06:51:30.000Z", + "paused_at": "2014-11-20T06:51:30.000Z" + }, + "shows": { + "rated_at": "2014-11-19T19:50:58.000Z", + "watchlisted_at": "2014-11-20T06:51:30.000Z", + "favorited_at": "2014-11-20T06:51:30.000Z", + "commented_at": "2014-11-20T06:51:30.000Z", + "hidden_at": "2016-08-20T06:51:30.000Z" + }, + "seasons": { + "rated_at": "2014-11-19T19:54:24.000Z", + "watchlisted_at": "2014-11-20T06:51:30.000Z", + "commented_at": "2014-11-20T06:51:30.000Z", + "hidden_at": "2016-08-20T06:51:30.000Z" + }, + "comments": { + "liked_at": "2014-11-20T03:38:09.000Z", + "blocked_at": "2022-02-22T03:38:09.000Z" + }, + "lists": { + "liked_at": "2014-11-20T00:36:48.000Z", + "updated_at": "2014-11-20T06:52:18.000Z", + "commented_at": "2014-11-20T06:51:30.000Z" + }, + "watchlist": { + "updated_at": "2014-11-20T06:52:18.000Z" + }, + "favorites": { + "updated_at": "2014-11-20T06:52:18.000Z" + }, + "account": { + "settings_at": "2020-03-04T03:38:09.000Z", + "followed_at": "2020-03-04T03:38:09.000Z", + "following_at": "2020-03-04T03:38:09.000Z", + "pending_at": "2020-03-04T03:38:09.000Z", + "requested_at": "2022-04-27T03:38:09.000Z" + }, + "saved_filters": { + "updated_at": "2022-06-14T06:52:18.000Z" + }, + "notes": { + "updated_at": "2023-08-31T17:18:19.000Z" + } } diff --git a/Tests/TraktKitTests/Models/Users/test_add_hidden_item.json b/Tests/TraktKitTests/Models/Users/test_add_hidden_item.json index f103332..e51cee2 100644 --- a/Tests/TraktKitTests/Models/Users/test_add_hidden_item.json +++ b/Tests/TraktKitTests/Models/Users/test_add_hidden_item.json @@ -1,23 +1,20 @@ { - "added" : { - "movies" : 1, - "shows" : 2, - "seasons" : 2 - }, - "not_found" : { - "movies" : [ - { - "ids" : { - "imdb" : "tt0000111" - } - } - ], - "shows" : [ - - ], - "seasons" : [ - - ] - } + "added": { + "movies": 1, + "shows": 2, + "seasons": 2, + "users": 0 + }, + "not_found": { + "movies": [ + { + "ids": { + "imdb": "tt0000111" + } + } + ], + "shows": [], + "seasons": [], + "users": [] + } } - diff --git a/Tests/TraktKitTests/Models/Users/test_post_remove_hidden_items.json b/Tests/TraktKitTests/Models/Users/test_post_remove_hidden_items.json index e211437..e627a33 100644 --- a/Tests/TraktKitTests/Models/Users/test_post_remove_hidden_items.json +++ b/Tests/TraktKitTests/Models/Users/test_post_remove_hidden_items.json @@ -1,23 +1,21 @@ -{ - "deleted" : { - "movies" : 1, - "shows" : 2, - "seasons" : 2 - }, - "not_found" : { - "movies" : [ - { - "ids" : { - "imdb" : "tt0000111" - } - } - ], - "shows" : [ - - ], - "seasons" : [ - ] - } +{ + "deleted": { + "movies": 1, + "shows": 2, + "seasons": 2, + "users": 0 + }, + "not_found": { + "movies": [ + { + "ids": { + "imdb": "tt0000111" + } + } + ], + "shows": [], + "seasons": [], + "users": [] + } } - diff --git a/Tests/TraktKitTests/SyncTests+Async.swift b/Tests/TraktKitTests/SyncTests+Async.swift new file mode 100644 index 0000000..05a05f6 --- /dev/null +++ b/Tests/TraktKitTests/SyncTests+Async.swift @@ -0,0 +1,45 @@ +// +// SyncTests+Async.swift +// TraktKit +// +// Created by Maximilian Litteral on 3/1/25. +// + +import Foundation +import Testing + +extension TraktTestSuite { + @Suite(.serialized) + struct SyncTestSuite { + @Test func getLastActivities() async throws { + try mock(.GET, "https://api.trakt.tv/sync/last_activities", result: .success(jsonData(named: "test_get_last_activity"))) + + let lastActivities = try await traktManager.sync() + .lastActivities() + .perform() + + // Date formatter to convert date back into String + let dateFormatter = DateFormatter() + dateFormatter.timeZone = .gmt + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + + #expect(dateFormatter.string(from: lastActivities.all) == "2014-11-20T07:01:32.000Z") + + #expect(dateFormatter.string(from: lastActivities.movies.watchedAt) == "2014-11-19T21:42:41.000Z") + #expect(dateFormatter.string(from: lastActivities.movies.collectedAt) == "2014-11-20T06:51:30.000Z") + #expect(dateFormatter.string(from: lastActivities.movies.ratedAt) == "2014-11-19T18:32:29.000Z") + #expect(dateFormatter.string(from: lastActivities.movies.watchlistedAt) == "2014-11-19T21:42:41.000Z") + #expect(dateFormatter.string(from: lastActivities.movies.favoritesAt) == "2014-11-19T21:42:41.000Z") + #expect(dateFormatter.string(from: lastActivities.movies.commentedAt) == "2014-11-20T06:51:30.000Z") + #expect(dateFormatter.string(from: lastActivities.movies.pausedAt) == "2014-11-20T06:51:30.000Z") + #expect(dateFormatter.string(from: lastActivities.movies.hiddenAt) == "2016-08-20T06:51:30.000Z") + + #expect(dateFormatter.string(from: lastActivities.episodes.watchedAt) == "2014-11-20T06:51:30.000Z") + #expect(dateFormatter.string(from: lastActivities.episodes.collectedAt) == "2014-11-19T22:02:41.000Z") + #expect(dateFormatter.string(from: lastActivities.episodes.ratedAt) == "2014-11-20T06:51:30.000Z") + #expect(dateFormatter.string(from: lastActivities.episodes.watchlistedAt) == "2014-11-20T06:51:30.000Z") + #expect(dateFormatter.string(from: lastActivities.episodes.commentedAt) == "2014-11-20T06:51:30.000Z") + #expect(dateFormatter.string(from: lastActivities.episodes.pausedAt) == "2014-11-20T06:51:30.000Z") + } + } +} diff --git a/Tests/TraktKitTests/UserTests.swift b/Tests/TraktKitTests/UserTests.swift index e819527..42fa49e 100644 --- a/Tests/TraktKitTests/UserTests.swift +++ b/Tests/TraktKitTests/UserTests.swift @@ -101,7 +101,7 @@ final class UserTests: TraktTestCase { func test_get_saved_filters() async throws { try mock(.GET, "https://api.trakt.tv/users/saved_filters", result: .success(jsonData(named: "test_get_saved_filters"))) - let filters = try await traktManager.currentUser().savedFilters().perform() + let filters = try await traktManager.currentUser().savedFilters().perform().object XCTAssertEqual(filters.count, 4) let firstFilter = try XCTUnwrap(filters.first) @@ -114,7 +114,7 @@ final class UserTests: TraktTestCase { try mock(.GET, "https://api.trakt.tv/users/hidden/progress_watched?page=1&limit=10&type=show&extended=min", result: .success(jsonData(named: "test_get_hidden_items"))) let expectation = XCTestExpectation(description: "HiddenItems") - traktManager.hiddenItems(section: .ProgressWatched, type: .Show, pagination: Pagination(page: 1, limit: 10)) { result in + traktManager.hiddenItems(section: HiddenItemSection.progressWatched, type: .Show, pagination: Pagination(page: 1, limit: 10)) { result in if case .success(let hiddenShows, _, _) = result { XCTAssertEqual(hiddenShows.count, 2) expectation.fulfill() @@ -135,7 +135,7 @@ final class UserTests: TraktTestCase { try mock(.POST, "https://api.trakt.tv/users/hidden/calendar", result: .success(jsonData(named: "test_add_hidden_item"))) let expectation = XCTestExpectation(description: "Add hidden item") - try! traktManager.hide(from: .Calendar) { result in + try! traktManager.hide(from: HiddenItemSection.calendar) { result in if case .success(let result) = result { XCTAssertEqual(result.added.movies, 1) XCTAssertEqual(result.added.shows, 2) @@ -159,7 +159,7 @@ final class UserTests: TraktTestCase { try mock(.GET, "https://api.trakt.tv/users/hidden/calendar/remove", result: .success(jsonData(named: "test_post_remove_hidden_items"))) let expectation = XCTestExpectation(description: "Remove hidden items") - try! traktManager.unhide(from: .Calendar) { result in + try! traktManager.unhide(from: HiddenItemSection.calendar) { result in if case .success(let result) = result { XCTAssertEqual(result.deleted.movies, 1) XCTAssertEqual(result.deleted.shows, 2) From f5b2a6daf45693b0dd593ffaebc43a62804acac1 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 1 Mar 2025 09:18:09 -0500 Subject: [PATCH 17/38] Fix decoding account settings due to optional values --- Common/Models/Users/AccountSettings.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Common/Models/Users/AccountSettings.swift b/Common/Models/Users/AccountSettings.swift index 896b218..e8d578a 100644 --- a/Common/Models/Users/AccountSettings.swift +++ b/Common/Models/Users/AccountSettings.swift @@ -35,9 +35,9 @@ public struct AccountSettings: TraktObject { } public struct SharingText: TraktObject { - public let watching: String - public let watched: String - public let rated: String + public let watching: String? + public let watched: String? + public let rated: String? } public struct Limits: TraktObject { From 579b1e4402ec8acd188a34a540321f83202caf52 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 1 Mar 2025 09:18:26 -0500 Subject: [PATCH 18/38] Mark hidden item sections as public --- Common/Wrapper/Enums.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Common/Wrapper/Enums.swift b/Common/Wrapper/Enums.swift index 7e0418a..5760ee2 100644 --- a/Common/Wrapper/Enums.swift +++ b/Common/Wrapper/Enums.swift @@ -295,19 +295,19 @@ public enum Period: String, Sendable, CustomStringConvertible { public struct HiddenItemSection: Sendable { /// Can hide movie, show objects - static let calendar = "calendar" + public static let calendar = "calendar" /// Can hide show, season objects - static let progressWatched = "progress_watched" + public static let progressWatched = "progress_watched" /// Can hide show, season objects - static let progressWatchedReset = "progress_watched_reset" + public static let progressWatchedReset = "progress_watched_reset" /// Can hide show, season objects - static let progressCollected = "progress_collected" + public static let progressCollected = "progress_collected" /// Can hide movie, show objects - static let recommendations = "recommendations" + public static let recommendations = "recommendations" // Can hide users - static let comments = "comments" + public static let comments = "comments" // Can hide shows - static let dropped = "dropped" + public static let dropped = "dropped" } public enum HiddenItemsType: String, Sendable { From c7412045c60d4c6101f38b4ac13e1482532678be Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sun, 2 Mar 2025 15:14:49 -0500 Subject: [PATCH 19/38] Add more sync endpoints, update models and tests --- Common/Models/Shows/TraktWatchedShow.swift | 18 +- Common/Models/Sync/WatchlistUpdate.swift | 20 + Common/Models/Users/ListItemPostResult.swift | 16 +- Common/Models/Users/TraktListItem.swift | 4 + Common/Wrapper/Resources/SyncResource.swift | 376 ++++++++++++++++++ Common/Wrapper/Sync.swift | 21 +- .../Sync/test_add_items_to_watchlist.json | 58 ++- .../Models/Sync/test_get_watchlist.json | 62 +-- .../Users/test_get_items_on_custom_list.json | 193 ++++----- .../Models/Users/test_get_user_watchlist.json | 133 +++++-- Tests/TraktKitTests/SyncTests+Async.swift | 38 ++ Tests/TraktKitTests/SyncTests.swift | 5 +- Tests/TraktKitTests/UserTests.swift | 12 +- 13 files changed, 749 insertions(+), 207 deletions(-) create mode 100644 Common/Models/Sync/WatchlistUpdate.swift diff --git a/Common/Models/Shows/TraktWatchedShow.swift b/Common/Models/Shows/TraktWatchedShow.swift index 4d75afb..89e375c 100644 --- a/Common/Models/Shows/TraktWatchedShow.swift +++ b/Common/Models/Shows/TraktWatchedShow.swift @@ -11,25 +11,21 @@ import Foundation public struct TraktWatchedShow: TraktObject { // Extended: Min - public let plays: Int // Total number of plays - public let lastWatchedAt: Date? - public let lastUpdatedAt: Date? + + public let plays: Int + public let lastWatchedAt: Date + public let lastUpdatedAt: Date + public let resetAt: Date? public let show: TraktShow + /// nil if you use `?extended=noseasons` public let seasons: [TraktWatchedSeason]? enum CodingKeys: String, CodingKey { case plays case lastWatchedAt = "last_watched_at" case lastUpdatedAt = "last_updated_at" + case resetAt = "reset_at" case show case seasons } - - public init(plays: Int, lastWatchedAt: Date?, lastUpdatedAt: Date?, show: TraktShow, seasons: [TraktWatchedSeason]?) { - self.plays = plays - self.lastWatchedAt = lastWatchedAt - self.lastUpdatedAt = lastUpdatedAt - self.show = show - self.seasons = seasons - } } diff --git a/Common/Models/Sync/WatchlistUpdate.swift b/Common/Models/Sync/WatchlistUpdate.swift new file mode 100644 index 0000000..1933562 --- /dev/null +++ b/Common/Models/Sync/WatchlistUpdate.swift @@ -0,0 +1,20 @@ +// +// WatchlistUpdate.swift +// TraktKit +// +// Created by Maximilian Litteral on 3/2/25. +// + +extension TraktManager { + struct WatchlistUpdate: TraktObject { + let description: String? + let sortBy: String? + let sortHow: String? + + enum CodingKeys: String, CodingKey { + case description + case sortBy = "sort_by" + case sortHow = "sort_how" + } + } +} diff --git a/Common/Models/Users/ListItemPostResult.swift b/Common/Models/Users/ListItemPostResult.swift index eea305d..2ed0e6d 100644 --- a/Common/Models/Users/ListItemPostResult.swift +++ b/Common/Models/Users/ListItemPostResult.swift @@ -40,7 +40,8 @@ public struct WatchlistItemPostResult: TraktObject { public let added: ObjectCount public let existing: ObjectCount public let notFound: NotFound - + public let list: List + public struct ObjectCount: TraktObject { public let movies: Int public let shows: Int @@ -54,11 +55,22 @@ public struct WatchlistItemPostResult: TraktObject { public let seasons: [NotFoundIds] public let episodes: [NotFoundIds] } - + + public struct List: TraktObject { + public let updatedAt: Date + public let itemCount: Int + + enum CodingKeys: String, CodingKey { + case updatedAt = "updated_at" + case itemCount = "item_count" + } + } + enum CodingKeys: String, CodingKey { case added case existing case notFound = "not_found" + case list } } diff --git a/Common/Models/Users/TraktListItem.swift b/Common/Models/Users/TraktListItem.swift index 8682185..fc06299 100644 --- a/Common/Models/Users/TraktListItem.swift +++ b/Common/Models/Users/TraktListItem.swift @@ -10,7 +10,9 @@ import Foundation public struct TraktListItem: TraktObject { public let rank: Int + public let id: Int public let listedAt: Date + public let notes: String? public let type: String public var show: TraktShow? = nil public var season: TraktSeason? = nil @@ -20,7 +22,9 @@ public struct TraktListItem: TraktObject { enum CodingKeys: String, CodingKey { case rank + case id case listedAt = "listed_at" + case notes case type case show case season diff --git a/Common/Wrapper/Resources/SyncResource.swift b/Common/Wrapper/Resources/SyncResource.swift index 846bb85..2ff209c 100644 --- a/Common/Wrapper/Resources/SyncResource.swift +++ b/Common/Wrapper/Resources/SyncResource.swift @@ -55,5 +55,381 @@ extension TraktManager { public func lastActivities() -> Route { Route(paths: [path, "last_activities"], method: .GET, requiresAuthentication: true, traktManager: traktManager) } + + // MARK: - Playback + + public func playback(type: String) -> Route<[PlaybackProgress]> { + Route(paths: [path, "playback", type], method: .GET, requiresAuthentication: true, traktManager: traktManager) + } + + /** + Remove a playback item from a user's playback progress list. A `404` will be returned if the `id` is invalid. + */ + public func removePlayback(id: CustomStringConvertible) -> EmptyRoute { + EmptyRoute(paths: [path, "playback", id], method: .DELETE, requiresAuthentication: true, traktManager: traktManager) + } + + // MARK: - Collections + + /** + Get all collected items in a user's collection. A collected item indicates availability to watch digitally or on physical media. + + Each `movie` object contains `collected_at` and `updated_at` timestamps. Since users can set custom dates when they collected movies, it is possible for `collected_at` to be in the past. We also include `updated_at` to help sync Trakt data with your app. Cache this timestamp locally and only re-process the movie if you see a newer timestamp. + + Each `show` object contains `last_collected_at` and `last_updated_at` timestamps. Since users can set custom dates when they collected episodes, it is possible for `last_collected_at` to be in the past. We also include `last_updated_at` to help sync Trakt data with your app. Cache this timestamp locally and only re-process the show if you see a newer timestamp. + + If you add `?extended=metadata` to the URL, it will return the additional `media_type`, `resolution`, `hdr`, `audio`, `audio_channels` and `3d` metadata. It will use `null` if the metadata isn't set for an item. + + 🔒 OAuth Required ✨ Extended Info + + - parameter type: Possible values: `movies` , `shows` . + */ + public func collection(type: String) -> Route<[TraktCollectedItem]> { + Route(paths: [path, "collection", type], method: .GET, requiresAuthentication: true, traktManager: traktManager) + } + + /** + Add items to a user's collection. Accepts shows, seasons, episodes and movies. If only a show is passed, all episodes for the show will be collected. If seasons are specified, all episodes in those seasons will be collected. + + Send a `collected_at` UTC datetime to mark items as collected in the past. You can also send additional metadata about the media itself to have a very accurate collection. Showcase what is available to watch from your epic HD DVD collection down to your downloaded iTunes movies. + + **Limits** + + If the user's collection limit is exceeded, a `420` HTTP error code is returned. Use the `/users/settings` method to get all limits for a user account. In most cases, upgrading to Trakt VIP will increase the limits. + + > note: You can resend items already in your collection and they will be updated with any new values. This includes the `collected_at` and any other metadata. + */ + public func addToCollection(movies: [CollectionId]? = nil, shows: [CollectionId]? = nil, seasons: [CollectionId]? = nil, episodes: [CollectionId]? = nil) -> Route { + Route( + paths: [path, "collection"], + body: TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes), + method: .POST, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + /** + Remove one or more items from a user's collection. + + - parameter movies: Array of movie Trakt ids + - parameter shows: Array of show Trakt ids + - parameter seasons: Array of season Trakt ids + - parameter episodes: Array of episode Trakt ids + + 🔒 OAuth Required + */ + public func removeFromCollection(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil) -> Route { + Route( + paths: [path, "collection", "remove"], + body: TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes), + method: .POST, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + // MARK: - Watched + + /** + Returns all movies or shows a user has watched sorted by most plays. + + If type is set to `shows` and you add `?extended=noseasons` to the URL, it won't return season or episode info. + + Each `movie` and `show` object contains `last_watched_at` and `last_updated_at` timestamps. Since users can set custom dates when they watched movies and episodes, it is possible for `last_watched_at` to be in the past. We also include `last_updated_at` to help sync Trakt data with your app. Cache this timestamp locally and only re-process the movies and shows if you see a newer timestamp. + + Each show object contains a `reset_at` timestamp. If not `null`, this is when the user started re-watching the show. Your app can adjust the progress by ignoring episodes with a `last_watched_at` prior to the `reset_at`. + + 🔒 OAuth Required ✨ Extended Info + + - parameter type: Possible values: `movies` , `shows` . + */ + private func watched(type: String) -> Route<[T]> { + Route(paths: [path, "watched", type], method: .GET, requiresAuthentication: true, traktManager: traktManager) + } + + /** + Returns all shows a user has watched sorted by most plays. + + You add `?extended=noseasons` to the URL, it won't return season or episode info. + + Each `show` object contains `last_watched_at` and `last_updated_at` timestamps. Since users can set custom dates when they watched episodes, it is possible for `last_watched_at` to be in the past. We also include `last_updated_at` to help sync Trakt data with your app. Cache this timestamp locally and only re-process the movies and shows if you see a newer timestamp. + + Each show object contains a `reset_at` timestamp. If not `null`, this is when the user started re-watching the show. Your app can adjust the progress by ignoring episodes with a `last_watched_at` prior to the `reset_at`. + + 🔒 OAuth Required ✨ Extended Info + */ + public func watchedShows() -> Route<[TraktWatchedShow]> { + watched(type: "shows") + } + + /** + Returns all movies a user has watched sorted by most plays. + + Each `movie` object contains `last_watched_at` and `last_updated_at` timestamps. Since users can set custom dates when they watched movies, it is possible for `last_watched_at` to be in the past. We also include `last_updated_at` to help sync Trakt data with your app. Cache this timestamp locally and only re-process the movies and shows if you see a newer timestamp. + + 🔒 OAuth Required ✨ Extended Info + */ + public func watchedMovies() -> Route<[TraktWatchedMovie]> { + watched(type: "movies") + } + + // MARK: - History + + /** + Returns movies and episodes that a user has watched, sorted by most recent. You can optionally limit the `type` to `movies` or `episodes`. The `id` (64-bit integer) in each history item uniquely identifies the event and can be used to remove individual events by using the `/sync/history/remove` method. The `action` will be set to `scrobble`, `checkin`, or `watch`. + + Specify a `type` and trakt `id` to limit the history for just that item. If the `id` is valid, but there is no history, an empty array will be returned. + + 🔒 OAuth Required 📄 Pagination ✨ Extended Info + + - Parameters: + - type: Possible values: `movies` , `shows` , `seasons` , `episodes`. + - mediaId: Trakt ID for a specific item. + - startingAt: Starting date. + - endingAt: Ending date. + */ + public func history( + type: String? = nil, + mediaId: CustomStringConvertible? = nil, + startingAt: Date? = nil, + endingAt: Date? = nil + ) -> Route> { + Route( + paths: [path, "history", type, mediaId], + queryItems: [ + "start_at": startingAt?.UTCDateString(), + "end_at": endingAt?.UTCDateString() + ].compactMapValues { $0 }, + method: .GET, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + /** + Add items to a user's watch history. Accepts shows, seasons, episodes and movies. If only a show is passed, all episodes for the show will be added. If seasons are specified, only episodes in those seasons will be added. + + Send a `watched_at` UTC datetime to mark items as watched in the past. This is useful for syncing past watches from a media center. + + > important: Please be careful with sending duplicate data. We don't verify the `item` + `watched_at` to ensure it's unique, it is up to your app to veify this and not send duplicate plays. + + 🔒 OAuth Required + + - parameters: + - movies: array of `movie` objects + - shows: array of `show` objects + - seasons: array of `season` objects + - episodes: array of `episode` objects + */ + public func addToHistory( + movies: [AddToHistoryId]? = nil, + shows: [AddToHistoryId]? = nil, + seasons: [AddToHistoryId]? = nil, + episodes: [AddToHistoryId]? = nil + ) -> Route { + Route( + paths: [path, "history"], + body: TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes), + method: .POST, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + /** + Remove items from a user's watch history including all watches, scrobbles, and checkins. Accepts shows, seasons, episodes and movies. If only a show is passed, all episodes for the show will be removed. If seasons are specified, only episodes in those seasons will be removed. + + You can also send a list of raw history `ids` (64-bit integers) to delete single plays from the watched history. The ``TraktManager/SyncResource/history(type:mediaId:startingAt:endingAt:)`` method will return an individual `id` (64-bit integer) for each history item. + + 🔒 OAuth Required + */ + public func removeFromHistory( + movies: [SyncId]? = nil, + shows: [SyncId]? = nil, + seasons: [SyncId]? = nil, + episodes: [SyncId]? = nil, + historyIDs: [Int]? = nil + ) -> Route { + Route( + paths: [path, "history", "remove"], + body: TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes, ids: historyIDs), + method: .POST, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + // MARK: - Ratings + + /** + Get a user's ratings filtered by `type`. You can optionally filter for a specific `rating` between 1 and 10. Send a comma separated string for `rating` if you need multiple ratings. + + 🔒 OAuth Required 📄 Pagination Optional ✨ Extended Info + */ + public func ratings(type: String? = nil, rating: CustomStringConvertible? = nil) -> Route> { + Route( + paths: [path, "ratings", type, rating], + method: .GET, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + /** + Rate one or more items. Accepts shows, seasons, episodes and movies. If only a show is passed, only the show itself will be rated. If seasons are specified, all of those seasons will be rated. + + Send a ``RatingId/ratedAt`` datetime to mark items as rated in the past. This is useful for syncing ratings from a media center. + + 🔒 OAuth Required + + - parameters: + - movies: Array of movie Trakt ids + - shows: Array of show Trakt ids + - seasons: Array of season Trakt ids + - episodes: Array of episode Trakt ids + */ + public func addRatings( + movies: [RatingId]? = nil, + shows: [RatingId]? = nil, + seasons: [RatingId]? = nil, + episodes: [RatingId]? = nil + ) -> Route { + Route( + paths: [path, "ratings"], + body: TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes), + method: .POST, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + /** + Remove ratings for one or more items. + + 🔒 OAuth Required + */ + public func removeRatings( + movies: [RatingId]? = nil, + shows: [RatingId]? = nil, + seasons: [RatingId]? = nil, + episodes: [RatingId]? = nil + ) -> Route { + Route( + paths: [path, "ratings", "remove"], + body: TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes), + method: .POST, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + // MARK: - Watchlist + + /** + Returns all items in a user's watchlist filtered by type. + + > Important: The watchlist should not be used as a list of what the user is actively watching. Use a combination of the ``TraktManager/SyncResource/watchedShows()`` and ``TraktManager/ShowResource/watchedProgress(includeHiddenSeasons:includeSpecials:progressCountsSpecials:)`` methods to get what the user is actively watching. + + --- + **Notes** + + Each watchlist item contains a `notes` field with text entered by the user. + + --- + **Sorting Headers** + + By default, all list items are sorted by `rank` `asc`. We send `X-Applied-Sort-By` and `X-Applied-Sort-How` headers to indicate how the results are actually being sorted. + + We also send `X-Sort-By` and `X-Sort-How` headers which indicate the user's sort preference. Use these to custom sort the watchlist **in your app** for more advanced sort abilities we can't do in the API. Values for `X-Sort-By` include `rank`, `added`, `title`, `released`, `runtime`, `popularity`, `percentage`, and `votes`. Values for `X-Sort-How` include `asc` and `desc`. + + --- + **Auto Removal** + + When an item is watched, it will be automatically removed from the watchlist. For shows and seasons, watching 1 episode will remove the entire show or season. + + 🔒 OAuth Required 📄 Pagination Optional ✨ Extended Info 😁 Emojis + + - parameters: + - type: Filter for a specific item type. Possible values: `movies` , `shows` , `seasons` , `episodes` . + - sort: How to sort (only if type is also sent). Possible values: `rank` , `added` , `released` , `title` . + */ + public func watchlist(type: String? = nil, sort: String? = nil) -> Route> { + Route( + paths: [path, "watchlist", type, sort], + method: .GET, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + /** + Update the watchlist by sending 1 or more parameters. + + 🔒 OAuth Required + */ + public func updateWatchlist(description: String? = nil, sortBy: String? = nil, sortHow: String? = nil) -> Route { + Route( + paths: [path, "watchlist"], + body: WatchlistUpdate(description: description, sortBy: sortBy, sortHow: sortHow), + method: .PUT, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + /** + Add one of more items to a user's watchlist. Accepts shows, seasons, episodes and movies. If only a show is passed, only the show itself will be added. If seasons are specified, all of those seasons will be added. + + --- + **Notes** + + Each watchlist item can optionally accept a `notes` (500 maximum characters) field with custom text. The user must be a Trakt VIP to send `notes`. + + --- + **Limits** + + If the user's watchlist limit is exceeded, a `420` HTTP error code is returned. Use the ``TraktManager/CurrentUserResource/settings()`` method to get all limits for a user account. In most cases, upgrading to Trakt VIP will increase the limits. + + 🔥 VIP Enhanced 🔒 OAuth Required 😁 Emojis + + - parameters: + - movies: Array of movie Trakt ids + - shows: Array of show Trakt ids + - seasons: Array of season Trakt ids + - episodes: Array of episode Trakt ids + */ + public func addToWatchlist(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil) -> Route { + Route( + paths: [path, "watchlist"], + body: TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes), + method: .POST, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + /** + Remove one or more items from a user's watchlist. + + 🔒 OAuth Required + + - parameters: + - movies: Array of movie Trakt ids + - shows: Array of show Trakt ids + - seasons: Array of season Trakt ids + - episodes: Array of episode Trakt ids + */ + public func removeFromWatchlist(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil) -> Route { + Route( + paths: [path, "watchlist", "remove"], + body: TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes), + method: .POST, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + // MARK: - Favorites } } diff --git a/Common/Wrapper/Sync.swift b/Common/Wrapper/Sync.swift index 53954f7..2316d5e 100644 --- a/Common/Wrapper/Sync.swift +++ b/Common/Wrapper/Sync.swift @@ -97,15 +97,15 @@ extension TraktManager { /** Add items to a user's collection. Accepts shows, seasons, episodes and movies. If only a show is passed, all episodes for the show will be collected. If seasons are specified, all episodes in those seasons will be collected. - + Send a `collected_at` UTC datetime to mark items as collected in the past. You can also send additional metadata about the media itself to have a very accurate collection. Showcase what is available to watch from your epic HD DVD collection down to your downloaded iTunes movies. - - **Note**: You can resend items already in your collection and they will be updated with any new values. This includes the `collected_at` and any other metadata. - + + > Note: You can resend items already in your collection and they will be updated with any new values. This includes the `collected_at` and any other metadata. + Status Code: 201 - + 🔒 OAuth: Required - + - parameter movies: Array of movie Trakt ids - parameter shows: Array of show Trakt ids - parameter seasons: Array of season Trakt ids @@ -248,10 +248,11 @@ extension TraktManager { 🔒 OAuth: Required - - parameter movies: array of movie objects - - parameter shows: array of show objects - - parameter episodes: array of episode objects - - parameter completion: completion handler + - parameters: + - movies: array of `movie` objects + - shows: array of `show` objects + - seasons: array of `season` objects + - episodes: array of `episode` objects */ @discardableResult public func addToHistory(movies: [AddToHistoryId]? = nil, shows: [AddToHistoryId]? = nil, seasons: [AddToHistoryId]? = nil, episodes: [AddToHistoryId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { diff --git a/Tests/TraktKitTests/Models/Sync/test_add_items_to_watchlist.json b/Tests/TraktKitTests/Models/Sync/test_add_items_to_watchlist.json index e61b61a..157b83c 100644 --- a/Tests/TraktKitTests/Models/Sync/test_add_items_to_watchlist.json +++ b/Tests/TraktKitTests/Models/Sync/test_add_items_to_watchlist.json @@ -1,32 +1,30 @@ { - "added" : { - "seasons" : 1, - "movies" : 1, - "shows" : 1, - "episodes" : 2 - }, - "existing" : { - "seasons" : 0, - "movies" : 0, - "shows" : 0, - "episodes" : 0 - }, - "not_found" : { - "seasons" : [ - - ], - "movies" : [ - { - "ids" : { - "imdb" : "tt0000111" - } - } - ], - "shows" : [ - - ], - "episodes" : [ - - ] - } + "added": { + "movies": 1, + "shows": 1, + "seasons": 1, + "episodes": 2 + }, + "existing": { + "movies": 0, + "shows": 0, + "seasons": 0, + "episodes": 0 + }, + "not_found": { + "movies": [ + { + "ids": { + "imdb": "tt0000111" + } + } + ], + "shows": [], + "seasons": [], + "episodes": [] + }, + "list": { + "updated_at": "2022-04-27T21:40:41.000Z", + "item_count": 5 + } } diff --git a/Tests/TraktKitTests/Models/Sync/test_get_watchlist.json b/Tests/TraktKitTests/Models/Sync/test_get_watchlist.json index 2569547..c4d75cf 100644 --- a/Tests/TraktKitTests/Models/Sync/test_get_watchlist.json +++ b/Tests/TraktKitTests/Models/Sync/test_get_watchlist.json @@ -1,32 +1,36 @@ [ - { - "rank" : 1, - "type" : "movie", - "movie" : { - "title" : "TRON: Legacy", - "year" : 2010, - "ids" : { - "tmdb" : 20526, - "slug" : "tron-legacy-2010", - "trakt" : 1, - "imdb" : "tt1104001" - } + { + "rank": 1, + "id": 101, + "listed_at": "2014-09-01T09:10:11.000Z", + "notes": "Need to catch up before TRON 3 is out.", + "type": "movie", + "movie": { + "title": "TRON: Legacy", + "year": 2010, + "ids": { + "trakt": 1, + "slug": "tron-legacy-2010", + "imdb": "tt1104001", + "tmdb": 20526 + } + } }, - "listed_at" : "2014-09-01T09:10:11.000Z" - }, - { - "rank" : 2, - "type" : "movie", - "movie" : { - "title" : "The Dark Knight", - "year" : 2008, - "ids" : { - "tmdb" : 155, - "slug" : "the-dark-knight-2008", - "trakt" : 6, - "imdb" : "tt0468569" - } - }, - "listed_at" : "2014-09-01T09:10:11.000Z" - } + { + "rank": 2, + "id": 102, + "listed_at": "2014-09-01T09:10:11.000Z", + "notes": "Really need to check out Heath Ledger's performance in this.", + "type": "movie", + "movie": { + "title": "The Dark Knight", + "year": 2008, + "ids": { + "trakt": 6, + "slug": "the-dark-knight-2008", + "imdb": "tt0468569", + "tmdb": 155 + } + } + } ] diff --git a/Tests/TraktKitTests/Models/Users/test_get_items_on_custom_list.json b/Tests/TraktKitTests/Models/Users/test_get_items_on_custom_list.json index 8ac9402..63b4202 100644 --- a/Tests/TraktKitTests/Models/Users/test_get_items_on_custom_list.json +++ b/Tests/TraktKitTests/Models/Users/test_get_items_on_custom_list.json @@ -1,99 +1,108 @@ [ - { - "listed_at" : "2014-06-16T06:07:12.000Z", - "movie" : { - "title" : "Star Wars: Episode IV - A New Hope", - "year" : 1977, - "ids" : { - "tmdb" : 11, - "slug" : "star-wars-episode-iv-a-new-hope-1977", - "trakt" : 12, - "imdb" : "tt0076759" - } + { + "rank": 1, + "id": 101, + "listed_at": "2014-06-16T06:07:12.000Z", + "notes": "You are part of the rebel alliance and a spy!", + "type": "movie", + "movie": { + "title": "Star Wars: Episode IV - A New Hope", + "year": 1977, + "ids": { + "trakt": 12, + "slug": "star-wars-episode-iv-a-new-hope-1977", + "imdb": "tt0076759", + "tmdb": 11 + } + } }, - "rank" : 1, - "type" : "movie" - }, - { - "listed_at" : "2014-06-16T06:07:12.000Z", - "show" : { - "title" : "The Walking Dead", - "year" : 2010, - "ids" : { - "tmdb" : 1402, - "slug" : "the-walking-dead", - "tvdb" : 153021, - "trakt" : 2, - "imdb" : "tt1520211" - } + { + "rank": 2, + "id": 102, + "listed_at": "2014-06-16T06:07:12.000Z", + "notes": null, + "type": "show", + "show": { + "title": "The Walking Dead", + "year": 2010, + "ids": { + "trakt": 2, + "slug": "the-walking-dead", + "tvdb": 153021, + "imdb": "tt1520211", + "tmdb": 1402 + } + } }, - "rank" : 2, - "type" : "show" - }, - { - "listed_at" : "2014-06-16T06:07:12.000Z", - "season" : { - "number" : 1, - "ids" : { - "tvdb" : 30272, - "tmdb" : 3572, - "trakt": 1 - } + { + "rank": 3, + "id": 103, + "listed_at": "2014-06-16T06:07:12.000Z", + "notes": null, + "type": "season", + "season": { + "number": 1, + "ids": { + "trakt": 12345, + "tvdb": 30272, + "tmdb": 3572 + } + }, + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "slug": "breaking-bad", + "tvdb": 81189, + "imdb": "tt0903747", + "tmdb": 1396 + } + } }, - "show" : { - "title" : "Breaking Bad", - "year" : 2008, - "ids" : { - "tmdb" : 1396, - "slug" : "breaking-bad", - "tvdb" : 81189, - "trakt" : 1, - "imdb" : "tt0903747" - } + { + "rank": 4, + "id": 104, + "listed_at": "2014-06-17T06:52:03.000Z", + "notes": null, + "type": "episode", + "episode": { + "season": 0, + "number": 2, + "title": "Wedding Day", + "ids": { + "trakt": 2, + "tvdb": 3859791, + "imdb": null, + "tmdb": 62133 + } + }, + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "slug": "breaking-bad", + "tvdb": 81189, + "imdb": "tt0903747", + "tmdb": 1396 + } + } }, - "rank" : 3, - "type" : "season" - }, - { - "listed_at" : "2014-06-17T06:52:03.000Z", - "show" : { - "title" : "Breaking Bad", - "year" : 2008, - "ids" : { - "tmdb" : 1396, - "slug" : "breaking-bad", - "tvdb" : 81189, - "trakt" : 1, - "imdb" : "tt0903747" - } - }, - "rank" : 4, - "type" : "episode", - "episode" : { - "number" : 2, - "season" : 0, - "title" : "Wedding Day", - "ids" : { - "tmdb" : 62133, - "tvdb" : 3859791, - "trakt" : 2, - "imdb" : null - } - } - }, - { - "listed_at" : "2014-06-17T06:52:03.000Z", - "rank" : 5, - "type" : "person", - "person" : { - "name" : "Garrett Hedlund", - "ids" : { - "tmdb" : 9828, - "slug" : "garrett-hedlund", - "trakt" : 1, - "imdb" : "nm1330560" - } + { + "rank": 5, + "id": 105, + "listed_at": "2014-06-17T06:52:03.000Z", + "notes": null, + "type": "person", + "person": { + "name": "Garrett Hedlund", + "ids": { + "trakt": 1, + "slug": "garrett-hedlund", + "imdb": "nm1330560", + "tmdb": 9828 + } + } } - } ] - diff --git a/Tests/TraktKitTests/Models/Users/test_get_user_watchlist.json b/Tests/TraktKitTests/Models/Users/test_get_user_watchlist.json index eb82778..b4420b5 100644 --- a/Tests/TraktKitTests/Models/Users/test_get_user_watchlist.json +++ b/Tests/TraktKitTests/Models/Users/test_get_user_watchlist.json @@ -1,33 +1,108 @@ [ - { - "rank" : 1, - "type" : "movie", - "movie" : { - "title" : "TRON: Legacy", - "year" : 2010, - "ids" : { - "tmdb" : 20526, - "slug" : "tron-legacy-2010", - "trakt" : 1, - "imdb" : "tt1104001" - } + { + "rank": 1, + "id": 101, + "listed_at": "2014-06-16T06:07:12.000Z", + "notes": "You are part of the rebel alliance and a spy!", + "type": "movie", + "movie": { + "title": "Star Wars: Episode IV - A New Hope", + "year": 1977, + "ids": { + "trakt": 12, + "slug": "star-wars-episode-iv-a-new-hope-1977", + "imdb": "tt0076759", + "tmdb": 11 + } + } }, - "listed_at" : "2014-09-01T09:10:11.000Z" - }, - { - "rank" : 2, - "type" : "movie", - "movie" : { - "title" : "The Dark Knight", - "year" : 2008, - "ids" : { - "tmdb" : 155, - "slug" : "the-dark-knight-2008", - "trakt" : 6, - "imdb" : "tt0468569" - } + { + "rank": 2, + "id": 102, + "listed_at": "2014-06-16T06:07:12.000Z", + "notes": null, + "type": "show", + "show": { + "title": "The Walking Dead", + "year": 2010, + "ids": { + "trakt": 2, + "slug": "the-walking-dead", + "tvdb": 153021, + "imdb": "tt1520211", + "tmdb": 1402 + } + } }, - "listed_at" : "2014-09-01T09:10:11.000Z" - } + { + "rank": 3, + "id": 103, + "listed_at": "2014-06-16T06:07:12.000Z", + "notes": null, + "type": "season", + "season": { + "number": 1, + "ids": { + "trakt": 3954, + "tvdb": 30272, + "tmdb": 3572 + } + }, + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "slug": "breaking-bad", + "tvdb": 81189, + "imdb": "tt0903747", + "tmdb": 1396 + } + } + }, + { + "rank": 4, + "id": 104, + "listed_at": "2014-06-17T06:52:03.000Z", + "notes": null, + "type": "episode", + "episode": { + "season": 0, + "number": 2, + "title": "Wedding Day", + "ids": { + "trakt": 2, + "tvdb": 3859791, + "imdb": null, + "tmdb": 62133 + } + }, + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "slug": "breaking-bad", + "tvdb": 81189, + "imdb": "tt0903747", + "tmdb": 1396 + } + } + }, + { + "rank": 5, + "id": 105, + "listed_at": "2014-06-17T06:52:03.000Z", + "notes": null, + "type": "person", + "person": { + "name": "Garrett Hedlund", + "ids": { + "trakt": 1, + "slug": "garrett-hedlund", + "imdb": "nm1330560", + "tmdb": 9828 + } + } + } ] - diff --git a/Tests/TraktKitTests/SyncTests+Async.swift b/Tests/TraktKitTests/SyncTests+Async.swift index 05a05f6..a9b3fe5 100644 --- a/Tests/TraktKitTests/SyncTests+Async.swift +++ b/Tests/TraktKitTests/SyncTests+Async.swift @@ -41,5 +41,43 @@ extension TraktTestSuite { #expect(dateFormatter.string(from: lastActivities.episodes.commentedAt) == "2014-11-20T06:51:30.000Z") #expect(dateFormatter.string(from: lastActivities.episodes.pausedAt) == "2014-11-20T06:51:30.000Z") } + + // MARK: - Watchlist + + @Test func getWatchlist() async throws { + try mock(.GET, "https://api.trakt.tv/sync/watchlist/movies?extended=min", result: .success(jsonData(named: "test_get_watchlist"))) + + let watchlist = try await traktManager.sync() + .watchlist(type: "movies") + .extend(.Min) + .perform() + .object + + #expect(watchlist.count == 2) + + let firstItem = try #require(watchlist.first) + #expect(firstItem.rank == 1) + #expect(firstItem.id == 101) + #expect(firstItem.type == "movie") + #expect(firstItem.movie?.title == "TRON: Legacy") + #expect(firstItem.notes == "Need to catch up before TRON 3 is out.") + } + + @Test func addToWatchlist() async throws { + try mock(.POST, "https://api.trakt.tv/sync/watchlist", result: .success(jsonData(named: "test_add_items_to_watchlist"))) + + let result = try await traktManager.sync() + .addToWatchlist() + .perform() + + #expect(result.added.movies == 1) + #expect(result.added.shows == 1) + #expect(result.added.seasons == 1) + #expect(result.added.episodes == 2) + + #expect(result.notFound.movies.count == 1) + + #expect(result.list.itemCount == 5) + } } } diff --git a/Tests/TraktKitTests/SyncTests.swift b/Tests/TraktKitTests/SyncTests.swift index d1ba614..2e7b921 100644 --- a/Tests/TraktKitTests/SyncTests.swift +++ b/Tests/TraktKitTests/SyncTests.swift @@ -420,8 +420,11 @@ final class SyncTests: TraktTestCase { let expectation = XCTestExpectation(description: "Add items to watchlist") try traktManager.addToWatchlist(movies: [], shows: [], episodes: []) { result in - if case .success = result { + switch result { + case .success: expectation.fulfill() + case .error(let error): + XCTFail("Failed to add to watchlist: \(error)") } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) diff --git a/Tests/TraktKitTests/UserTests.swift b/Tests/TraktKitTests/UserTests.swift index 42fa49e..344133a 100644 --- a/Tests/TraktKitTests/UserTests.swift +++ b/Tests/TraktKitTests/UserTests.swift @@ -524,9 +524,12 @@ final class UserTests: TraktTestCase { let expectation = XCTestExpectation(description: "Get custom list items") traktManager.getItemsForCustomList(username: "sean", listID: "star-wars-in-machete-order") { result in - if case .success(let listItems) = result { + switch result { + case .success(let listItems): XCTAssertEqual(listItems.count, 5) expectation.fulfill() + case .error(let error): + XCTFail("Failed to get items on custom list: \(error)") } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) @@ -800,9 +803,12 @@ final class UserTests: TraktTestCase { let expectation = XCTestExpectation(description: "Get user watchlist") traktManager.getUserWatchlist(username: "sean", type: .Movies, extended: [.Min]) { result in - if case .success(let watchlist) = result { - XCTAssertEqual(watchlist.count, 2) + switch result { + case .success(let watchlist): + XCTAssertEqual(watchlist.count, 5) expectation.fulfill() + case .error(let error): + XCTFail("Failed to get user watchlist: \(error)") } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) From beaa25e957bad0851a8080f9d5d66ccd523d24b2 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Wed, 5 Mar 2025 08:25:48 -0500 Subject: [PATCH 20/38] Add functions for paginated requests to fetch all pages, or stream each page with structured concurrency --- .../xcshareddata/xcschemes/TraktKit.xcscheme | 14 +- .../Authentication/AuthenticationInfo.swift | 3 - Common/Models/Authentication/DeviceCode.swift | 28 +-- Common/Models/Authentication/OAuthBody.swift | 42 +++++ Common/Wrapper/CompletionHandlers.swift | 51 +++-- Common/Wrapper/HTTPHeader.swift | 6 + Common/Wrapper/Route.swift | 175 +++++++++++++++++- Common/Wrapper/TraktManager.swift | 105 ++++------- .../NetworkMocking/RequestMocking.swift | 7 + Tests/TraktKitTests/OAuthTests.swift | 84 +++++++++ Tests/TraktKitTests/RouteTests.swift | 20 ++ Tests/TraktKitTests/TraktManagerTests.swift | 89 ++++++++- Tests/TraktKitTests/TraktTestSuite.swift | 8 +- 13 files changed, 515 insertions(+), 117 deletions(-) create mode 100644 Common/Models/Authentication/OAuthBody.swift create mode 100644 Tests/TraktKitTests/OAuthTests.swift create mode 100644 Tests/TraktKitTests/RouteTests.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/TraktKit.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/TraktKit.xcscheme index 9258b6a..83eb193 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/TraktKit.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/TraktKit.xcscheme @@ -26,7 +26,19 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + systemAttachmentLifetime = "keepNever" + codeCoverageEnabled = "YES" + onlyGenerateCoverageForSpecifiedTargets = "YES"> + + + + diff --git a/Common/Models/Authentication/AuthenticationInfo.swift b/Common/Models/Authentication/AuthenticationInfo.swift index 86d5bea..bf8d8da 100644 --- a/Common/Models/Authentication/AuthenticationInfo.swift +++ b/Common/Models/Authentication/AuthenticationInfo.swift @@ -8,9 +8,6 @@ import Foundation -public typealias TraktObject = Codable & Hashable & Sendable -public typealias EncodableTraktObject = Encodable & Hashable & Sendable - public struct AuthenticationInfo: TraktObject { public let accessToken: String public let tokenType: String diff --git a/Common/Models/Authentication/DeviceCode.swift b/Common/Models/Authentication/DeviceCode.swift index c8504aa..c6f6b97 100644 --- a/Common/Models/Authentication/DeviceCode.swift +++ b/Common/Models/Authentication/DeviceCode.swift @@ -5,10 +5,6 @@ // Copyright © 2020 Maximilian Litteral. All rights reserved. // -#if canImport(UIKit) -import UIKit -#endif - public struct DeviceCode: TraktObject { public let deviceCode: String public let userCode: String @@ -16,7 +12,20 @@ public struct DeviceCode: TraktObject { public let expiresIn: TimeInterval public let interval: TimeInterval + enum CodingKeys: String, CodingKey { + case deviceCode = "device_code" + case userCode = "user_code" + case verificationURL = "verification_url" + case expiresIn = "expires_in" + case interval + } +} + #if canImport(UIKit) && canImport(CoreImage) +import UIKit +import CoreImage + +extension DeviceCode { public func getQRCode(scale: CGFloat = 3) -> UIImage? { guard let data = "\(verificationURL)/\(userCode)".data(using: .ascii), @@ -31,13 +40,6 @@ public struct DeviceCode: TraktObject { return UIImage(ciImage: output) } -#endif - - enum CodingKeys: String, CodingKey { - case deviceCode = "device_code" - case userCode = "user_code" - case verificationURL = "verification_url" - case expiresIn = "expires_in" - case interval - } } + +#endif diff --git a/Common/Models/Authentication/OAuthBody.swift b/Common/Models/Authentication/OAuthBody.swift new file mode 100644 index 0000000..aae8fc6 --- /dev/null +++ b/Common/Models/Authentication/OAuthBody.swift @@ -0,0 +1,42 @@ +// +// OAuthBody.swift +// TraktKit +// +// Created by Maximilian Litteral on 3/5/25. +// + +struct OAuthBody: TraktObject { + let code: String? + let refreshToken: String? + + let clientId: String? + let clientSecret: String? + + let redirectURI: String? + let grantType: String? + + enum CodingKeys: String, CodingKey { + case code + case refreshToken = "refresh_token" + case clientId = "client_id" + case clientSecret = "client_secret" + case redirectURI = "redirect_uri" + case grantType = "grant_type" + } + + init( + code: String? = nil, + refreshToken: String? = nil, + clientId: String? = nil, + clientSecret: String? = nil, + redirectURI: String? = nil, + grantType: String? = nil + ) { + self.code = code + self.refreshToken = refreshToken + self.clientId = clientId + self.clientSecret = clientSecret + self.redirectURI = redirectURI + self.grantType = grantType + } +} diff --git a/Common/Wrapper/CompletionHandlers.swift b/Common/Wrapper/CompletionHandlers.swift index e407c2c..f09f170 100644 --- a/Common/Wrapper/CompletionHandlers.swift +++ b/Common/Wrapper/CompletionHandlers.swift @@ -505,27 +505,46 @@ extension TraktManager { // MARK: - Async await - func fetchData(request: URLRequest) async throws -> (Data, URLResponse) { - let (data, response) = try await session.data(for: request) - try handleResponse(response: response) - do throws(TraktError) { - try self.handleResponse(response: response) - return (data, response) - } catch { - switch error { - case .retry(let after): - try await Task.sleep(for: .seconds(after)) - try Task.checkCancellation() - return try await fetchData(request: request) - default: + /** + Downloads the contents of a URL based on the specified URL request. Handles ``TraktError/retry(after:)`` up to the specified `retryLimit` + */ + func fetchData(request: URLRequest, retryLimit: Int = 3) async throws -> (Data, URLResponse) { + var retryCount = 0 + + while true { + do { + let (data, response) = try await session.data(for: request) + try handleResponse(response: response) + return (data, response) + } catch let error as TraktError { + switch error { + case .retry(let retryDelay): + retryCount += 1 + if retryCount >= retryLimit { + throw error + } + print("Retrying after delay: \(retryDelay)") + try await Task.sleep(for: .seconds(retryDelay)) + try Task.checkCancellation() + default: + throw error + } + } catch { throw error } } } - func perform(request: URLRequest) async throws -> T { - let (data, response) = try await session.data(for: request) - try handleResponse(response: response) + /** + Downloads the contents of a URL based on the specified URL request, and decodes the data into a `TraktObject` + */ + func perform(request: URLRequest, retryLimit: Int = 3) async throws -> T { + let (data, response) = try await fetchData(request: request, retryLimit: retryLimit) + return try decodeTraktObject(from: data, response: response) + } + + /// Decodes data into a TraktObject. If the `TraktObject` type is `PagedObject` the headers will be extracted from the response. + private func decodeTraktObject(from data: Data, response: URLResponse) throws -> T { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom(customDateDecodingStrategy) diff --git a/Common/Wrapper/HTTPHeader.swift b/Common/Wrapper/HTTPHeader.swift index 87d8abe..2d41e68 100644 --- a/Common/Wrapper/HTTPHeader.swift +++ b/Common/Wrapper/HTTPHeader.swift @@ -4,6 +4,7 @@ // // Created by Maximilian Litteral on 2/16/25. // +import Foundation public enum HTTPHeader { case contentType @@ -11,6 +12,7 @@ public enum HTTPHeader { case apiKey(String) case page(Int) case pageCount(Int) + case retry(TimeInterval) public var key: String { switch self { @@ -24,6 +26,8 @@ public enum HTTPHeader { "X-Pagination-Page" case .pageCount: "X-Pagination-Page-Count" + case .retry: + "retry-after" } } @@ -39,6 +43,8 @@ public enum HTTPHeader { page.description case .pageCount(let pageCount): pageCount.description + case .retry(let delay): + delay.description } } } diff --git a/Common/Wrapper/Route.swift b/Common/Wrapper/Route.swift index 257c69c..3cad774 100644 --- a/Common/Wrapper/Route.swift +++ b/Common/Wrapper/Route.swift @@ -98,12 +98,12 @@ public struct Route: Sendable { // MARK: - Perform - public func perform() async throws -> T { - let request = try makeRequest(traktManager: traktManager) - return try await traktManager.perform(request: request) + public func perform(retryLimit: Int = 3) async throws -> T { + let request = try createRequest() + return try await traktManager.perform(request: request, retryLimit: retryLimit) } - private func makeRequest(traktManager: TraktManager) throws -> URLRequest { + private func createRequest() throws -> URLRequest { var query: [String: String] = queryItems if !extended.isEmpty { @@ -146,6 +146,8 @@ public struct Route: Sendable { } } +// MARK: - No data response + public struct EmptyRoute: Sendable { internal var path: String internal let method: Method @@ -170,32 +172,185 @@ public struct EmptyRoute: Sendable { // MARK: - Perform - public func perform() async throws { + public func perform(retryLimit: Int = 3) async throws { let request = try traktManager.mutableRequest( forPath: path, withQuery: [:], isAuthorized: requiresAuthentication, withHTTPMethod: method ) - let _ = try await traktManager.fetchData(request: request) + let _ = try await traktManager.fetchData(request: request, retryLimit: retryLimit) } } +// MARK: - Pagination + public protocol PagedObjectProtocol { static var objectType: Decodable.Type { get } static func createPagedObject(with object: Decodable, currentPage: Int, pageCount: Int) -> Self } -public struct PagedObject: PagedObjectProtocol, TraktObject { - public let object: T +public struct PagedObject: PagedObjectProtocol, TraktObject { + public let object: TraktModel public let currentPage: Int public let pageCount: Int public static var objectType: any Decodable.Type { - T.self + TraktModel.self } public static func createPagedObject(with object: Decodable, currentPage: Int, pageCount: Int) -> Self { - return PagedObject(object: object as! T, currentPage: currentPage, pageCount: pageCount) + return PagedObject(object: object as! TraktModel, currentPage: currentPage, pageCount: pageCount) + } +} + +extension Route where T: PagedObjectProtocol { + + /// Fetches all pages for a paginated endpoint, and returns the data in a Set. + func fetchAllPages() async throws -> Set where T.Type == PagedObject<[Element]>.Type { + // Fetch first page + let firstPage = try await self.page(1).perform() + var resultSet = Set(firstPage.object) + + // Return early if there's only one page + guard firstPage.pageCount > 1 else { return resultSet } + resultSet = await withTaskGroup(of: [Element].self, returning: Set.self) { group in + let maxConcurrentRequests = min(firstPage.pageCount - 1, 10) + let pages = 2...firstPage.pageCount + + let indexStream = AsyncStream { continuation in + for i in pages { + continuation.yield(i) + } + continuation.finish() + } + var indexIterator = indexStream.makeAsyncIterator() + var results = resultSet + + // Start with the initial batch of tasks + for _ in 0.. + if let index = await indexIterator.next() { + group.addTask { + (try? await self.page(index).perform())?.object ?? [] + } + } + } + + return results + } + + return resultSet + } + + /// Stream paged results one at a time + func pagedResults() -> AsyncThrowingStream<[Element], Error> where T.Type == PagedObject<[Element]>.Type { + AsyncThrowingStream { continuation in + let task = Task { + do { + // Fetch first page + let firstPage = try await self.page(1).perform() + continuation.yield(firstPage.object) + + guard firstPage.pageCount > 1 else { + continuation.finish() + return + } + + // Use a semaphore to limit concurrency + let semaphore = AsyncSemaphore(value: 10) + let pages = 2...firstPage.pageCount + + try await withThrowingTaskGroup(of: (Int, [Element]).self) { group in + for pageIndex in pages { + // Acquire permit before starting new task + try await semaphore.acquire() + + group.addTask { + do { + let pageResult = try await self.page(pageIndex).perform().object + await semaphore.release() + return (pageIndex, pageResult) + } catch { + await semaphore.release() + throw error + } + } + } + + // Process results in order + var nextPage = 2 + var buffer = [Int: [Element]]() + + while let result = try await group.next() { + let (pageIndex, items) = result + + if pageIndex == nextPage { + // We got the next page we need + continuation.yield(items) + nextPage += 1 + + // Check if we have any buffered pages that can be yielded now + while let bufferedItems = buffer[nextPage] { + continuation.yield(bufferedItems) + buffer[nextPage] = nil + nextPage += 1 + } + } else { + // Buffer out-of-order results + buffer[pageIndex] = items + } + } + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } +} + +// A simple AsyncSemaphore implementation +actor AsyncSemaphore { + private var value: Int + private var waiters: [CheckedContinuation] = [] + + init(value: Int) { + self.value = value + } + + func acquire() async throws { + if value > 0 { + value -= 1 + return + } + + try await withCheckedThrowingContinuation { continuation in + waiters.append(continuation) + } + } + + func release() { + if let waiter = waiters.first { + waiters.removeFirst() + waiter.resume() + } else { + value += 1 + } } } diff --git a/Common/Wrapper/TraktManager.swift b/Common/Wrapper/TraktManager.swift index 11fd0f0..14a2b18 100644 --- a/Common/Wrapper/TraktManager.swift +++ b/Common/Wrapper/TraktManager.swift @@ -13,6 +13,9 @@ public extension Notification.Name { static let TraktAccountStatusDidChange = Notification.Name(rawValue: "signedInToTrakt") } +public typealias TraktObject = Codable & Hashable & Sendable +public typealias EncodableTraktObject = Encodable & Hashable & Sendable + public final class TraktManager: Sendable { // MARK: - Types @@ -215,26 +218,13 @@ public final class TraktManager: Sendable { UserDefaults.standard.removeObject(forKey: Constants.tokenExpirationDefaultsKey) } - internal func mutableRequestForURL(_ url: URL, authorization: Bool, HTTPMethod: Method) throws -> URLRequest { - var request = URLRequest(url: url) - request.httpMethod = HTTPMethod.rawValue - - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - request.addValue("2", forHTTPHeaderField: "trakt-api-version") - request.addValue(clientId, forHTTPHeaderField: "trakt-api-key") - - if authorization { - if let accessToken { - request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - } else { - throw TraktKitError.userNotAuthorized - } - } - - return request - } - - internal func mutableRequest(forPath path: String, withQuery query: [String: String], isAuthorized authorized: Bool, withHTTPMethod httpMethod: Method, body: Encodable? = nil) throws -> URLRequest { + internal func mutableRequest( + forPath path: String, + withQuery query: [String: String] = [:], + isAuthorized authorized: Bool, + withHTTPMethod httpMethod: Method, + body: Encodable? = nil + ) throws -> URLRequest { // Build URL let urlString = "https://\(apiHost)/" + path guard var components = URLComponents(string: urlString) else { throw TraktKitError.malformedURL } @@ -262,6 +252,8 @@ public final class TraktManager: Sendable { if authorized { if let accessToken { request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + } else { + throw TraktKitError.userNotAuthorized } } @@ -307,21 +299,15 @@ public final class TraktManager: Sendable { // MARK: - Authentication public func getToken(authorizationCode code: String) async throws -> AuthenticationInfo { - let urlString = "https://\(apiHost)/oauth/token" - guard let url = URL(string: urlString) else { - throw TraktKitError.malformedURL - } - var request = try mutableRequestForURL(url, authorization: false, HTTPMethod: .POST) - - let json = [ - "code": code, - "client_id": clientId, - "client_secret": clientSecret, - "redirect_uri": redirectURI, - "grant_type": "authorization_code", - ] - request.httpBody = try JSONSerialization.data(withJSONObject: json, options: []) - + let body = OAuthBody( + code: code, + clientId: clientId, + clientSecret: clientSecret, + redirectURI: redirectURI, + grantType: "authorization_code" + ) + + let request = try mutableRequest(forPath: "/oauth/token", isAuthorized: false, withHTTPMethod: .POST, body: body) let authenticationInfo: AuthenticationInfo = try await perform(request: request) await saveCredentials(for: authenticationInfo, postAccountStatusChange: true) return authenticationInfo @@ -329,17 +315,16 @@ public final class TraktManager: Sendable { // MARK: - Authentication - Devices - public func getAppCode() async throws -> DeviceCode { - let urlString = "https://\(apiHost)/oauth/device/code/" - - guard let url = URL(string: urlString) else { - throw TraktKitError.malformedURL - } - let json = ["client_id": clientId] + /** + Generate new codes to start the device authentication process. The `device_code` and interval will be used later to poll for the `access_token`. The `user_code` and `verification_url` should be presented to the user as mentioned in the flow steps above. - var request = try mutableRequestForURL(url, authorization: false, HTTPMethod: .POST) - request.httpBody = try JSONSerialization.data(withJSONObject: json, options: []) + **QR Code** + You might consider generating a QR code for the user to easily scan on their mobile device. The QR code should be a URL that redirects to the `verification_url` and appends the `user_code`. For example, `https://trakt.tv/activate/5055CC52` would load the Trakt hosted `verification_url` and pre-fill in the `user_code`. + */ + public func getAppCode() async throws -> DeviceCode { + let body = OAuthBody(clientId: clientId) + let request = try mutableRequest(forPath: "/oauth/device/code", isAuthorized: false, withHTTPMethod: .POST, body: body) return try await perform(request: request) } @@ -382,19 +367,8 @@ public final class TraktManager: Sendable { } private func requestAccessToken(code: String) async throws -> (AuthenticationInfo?, Int) { - // Build request - let urlString = "https://\(apiHost)/oauth/device/token" - guard let url = URL(string: urlString) else { - throw TraktKitError.malformedURL - } - var request = try mutableRequestForURL(url, authorization: false, HTTPMethod: .POST) - - let json = [ - "code": code, - "client_id": clientId, - "client_secret": clientSecret, - ] - request.httpBody = try JSONSerialization.data(withJSONObject: json, options: []) + let body = OAuthBody(code: code, clientId: clientId, clientSecret: clientSecret) + let request = try mutableRequest(forPath: "/oauth/device/token", isAuthorized: false, withHTTPMethod: .POST, body: body) // Make response let (data, response) = try await session.data(for: request) @@ -415,7 +389,7 @@ public final class TraktManager: Sendable { // Save expiration date let expiresDate = Date(timeIntervalSinceNow: authInfo.expiresIn) - UserDefaults.standard.set(expiresDate, forKey: "accessTokenExpirationDate") + UserDefaults.standard.set(expiresDate, forKey: Constants.tokenExpirationDefaultsKey) UserDefaults.standard.synchronize() // Post notification @@ -446,19 +420,8 @@ public final class TraktManager: Sendable { guard let refreshToken else { throw TraktKitError.invalidRefreshToken } // Create request - guard let url = URL(string: "https://\(apiHost)/oauth/token") else { - throw TraktKitError.malformedURL - } - var request = try mutableRequestForURL(url, authorization: false, HTTPMethod: .POST) - - let json = [ - "refresh_token": refreshToken, - "client_id": clientId, - "client_secret": clientSecret, - "redirect_uri": redirectURI, - "grant_type": "refresh_token", - ] - request.httpBody = try JSONSerialization.data(withJSONObject: json, options: []) + let body = OAuthBody(refreshToken: refreshToken, clientId: clientId, clientSecret: clientSecret, redirectURI: redirectURI, grantType: "refresh_token") + let request = try mutableRequest(forPath: "/oauth/token", isAuthorized: false, withHTTPMethod: .POST, body: body) // Make request and handle response do { diff --git a/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift b/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift index 27e46fd..d84d696 100644 --- a/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift +++ b/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift @@ -32,6 +32,13 @@ extension RequestMocking { } } + static func replace(mock: MockedResponse) { + lock.withLock { + container.mocks.removeAll(where: { $0.url == mock.url }) + container.mocks.append(mock) + } + } + static func removeAllMocks() { lock.withLock { container.mocks.removeAll() diff --git a/Tests/TraktKitTests/OAuthTests.swift b/Tests/TraktKitTests/OAuthTests.swift new file mode 100644 index 0000000..1d48a5c --- /dev/null +++ b/Tests/TraktKitTests/OAuthTests.swift @@ -0,0 +1,84 @@ +// +// OAuthTests.swift +// TraktKit +// +// Created by Maximilian Litteral on 3/5/25. +// + +import Foundation +import Testing +@testable import TraktKit + +extension TraktTestSuite { + @Suite + struct OAuthTests { + + @Test func decodeOAuthTokenBody() throws { + let json: [String: String] = [ + "code": "fd0847dbb559752d932dd3c1ac34ff98d27b11fe2fea5a864f44740cd7919ad0", + "client_id": "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f", + "client_secret": "d6ea27703957b69939b8104ed4524595e210cd2e79af587744a7eb6e58f5b3d2", + "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", + "grant_type": "authorization_code" + ] + let data = try JSONSerialization.data(withJSONObject: json) + + let oauthBody = try JSONDecoder().decode(OAuthBody.self, from: data) + #expect(oauthBody.code == "fd0847dbb559752d932dd3c1ac34ff98d27b11fe2fea5a864f44740cd7919ad0") + #expect(oauthBody.refreshToken == nil) + #expect(oauthBody.clientId == "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f") + #expect(oauthBody.clientSecret == "d6ea27703957b69939b8104ed4524595e210cd2e79af587744a7eb6e58f5b3d2") + #expect(oauthBody.redirectURI == "urn:ietf:wg:oauth:2.0:oob") + #expect(oauthBody.grantType == "authorization_code") + + // To ensure `nil` keys are not encoded as empty or `null` + let backToData = try JSONEncoder().encode(oauthBody) + let backToJSON = try #require(JSONSerialization.jsonObject(with: backToData) as? [String: String]) + #expect(backToJSON == json) + } + + @Test func decodeRefreshTokenBody() throws { + let json: [String: String] = [ + "refresh_token": "fd0847dbb559752d932dd3c1ac34ff98d27b11fe2fea5a864f44740cd7919ad0", + "client_id": "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f", + "client_secret": "d6ea27703957b69939b8104ed4524595e210cd2e79af587744a7eb6e58f5b3d2", + "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", + "grant_type": "refresh_token" + ] + let data = try JSONSerialization.data(withJSONObject: json) + + let oauthBody = try JSONDecoder().decode(OAuthBody.self, from: data) + #expect(oauthBody.code == nil) + #expect(oauthBody.refreshToken == "fd0847dbb559752d932dd3c1ac34ff98d27b11fe2fea5a864f44740cd7919ad0") + #expect(oauthBody.clientId == "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f") + #expect(oauthBody.clientSecret == "d6ea27703957b69939b8104ed4524595e210cd2e79af587744a7eb6e58f5b3d2") + #expect(oauthBody.redirectURI == "urn:ietf:wg:oauth:2.0:oob") + #expect(oauthBody.grantType == "refresh_token") + + // To ensure `nil` keys are not encoded as empty or `null` + let backToData = try JSONEncoder().encode(oauthBody) + let backToJSON = try #require(JSONSerialization.jsonObject(with: backToData) as? [String: String]) + #expect(backToJSON == json) + } + + @Test func decodeDeviceTokenBody() throws { + let json: [String: String] = [ + "client_id": "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f" + ] + let data = try JSONSerialization.data(withJSONObject: json) + + let oauthBody = try JSONDecoder().decode(OAuthBody.self, from: data) + #expect(oauthBody.code == nil) + #expect(oauthBody.refreshToken == nil) + #expect(oauthBody.clientId == "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f") + #expect(oauthBody.clientSecret == nil) + #expect(oauthBody.redirectURI == nil) + #expect(oauthBody.grantType == nil) + + // To ensure `nil` keys are not encoded as empty or `null` + let backToData = try JSONEncoder().encode(oauthBody) + let backToJSON = try #require(JSONSerialization.jsonObject(with: backToData) as? [String: String]) + #expect(backToJSON == json) + } + } +} diff --git a/Tests/TraktKitTests/RouteTests.swift b/Tests/TraktKitTests/RouteTests.swift new file mode 100644 index 0000000..19f84d9 --- /dev/null +++ b/Tests/TraktKitTests/RouteTests.swift @@ -0,0 +1,20 @@ +// +// RouteTests.swift +// TraktKit +// +// Created by Maximilian Litteral on 3/3/25. +// + +import Testing +@testable import TraktKit + +extension TraktTestSuite { + @Suite + struct RouteTests { + + @Test + func buildRequest() { + let route = traktManager.shows.trending() + } + } +} diff --git a/Tests/TraktKitTests/TraktManagerTests.swift b/Tests/TraktKitTests/TraktManagerTests.swift index fa73e1f..1e7e753 100644 --- a/Tests/TraktKitTests/TraktManagerTests.swift +++ b/Tests/TraktKitTests/TraktManagerTests.swift @@ -11,7 +11,7 @@ import Testing extension TraktTestSuite { @Suite(.serialized) - class TraktManagerTests { + struct TraktManagerTests { @Test func pollForAccessTokenInvalidDeviceCode() async throws { try mock(.GET, "https://api.trakt.tv/oauth/device/token", result: .success(.init()), httpCode: 404) @@ -29,5 +29,92 @@ extension TraktTestSuite { try await traktManager.pollForAccessToken(deviceCode: deviceCode) }) } + + @Test + func retryRequestSuccess() async throws { + let urlString = "https://api.trakt.tv/retry_test" + try mock(.GET, urlString, result: .success(Data()), httpCode: 429, headers: [.retry(5)]) + + // Update mock in 2 seconds + Task.detached { + try await Task.sleep(for: .seconds(2)) + try mock(.GET, urlString, result: .success(Data()), httpCode: 201, replace: true) + } + + let request = URLRequest(url: URL(string: urlString)!) + let (_, response) = try await traktManager.fetchData(request: request, retryLimit: 2) + let httpHeader = try #require(response as? HTTPURLResponse) + #expect(httpHeader.statusCode == 201) + } + + @Test func retryRequestFailed() async throws { + let urlString = "https://api.trakt.tv/retry_test_failed" + try mock(.GET, urlString, result: .success(Data()), httpCode: 429, headers: [.retry(3)]) + + // Update mock in 2 seconds + Task.detached { + try await Task.sleep(for: .seconds(1)) + try mock(.GET, urlString, result: .success(Data()), httpCode: 405, replace: true) + } + + let request = URLRequest(url: URL(string: urlString)!) + + await #expect(throws: TraktManager.TraktError.noMethodFound, performing: { + try await traktManager.fetchData(request: request, retryLimit: 2) + }) + } + + @Test func fetchPaginatedResults() async throws { + for p in 1...2 { + let urlString = "https://api.trakt.tv/shows/trending?page=\(p)&limit=10" + let json: [[String: Any]] = (0..<10).map { i in + [ + "watchers": Int.random(in: 0...100), + "show": [ + "title": "Random show \(i)", + "year": 2025, + "ids": [ + "trakt": Int.random(in: 0...1000), + "slug": "Random_show_\(i)" + ] + ] + ] + } + let data = try JSONSerialization.data(withJSONObject: json) + try mock(.GET, urlString, result: .success(data), headers: [.page(p), .pageCount(2)]) + } + + let trending = try await traktManager.shows.trending().limit(10).fetchAllPages() + #expect(trending.count == 20) + } + + @Test func streamPaginatedResults() async throws { + var watchersByPage = [[Int]]() + for p in 1...2 { + let urlString = "https://api.trakt.tv/shows/trending?page=\(p)&limit=10" + let json: [[String: Any]] = (0..<10).map { i in + [ + "watchers": Int.random(in: 0...100), + "show": [ + "title": "Random show \(i)", + "year": 2025, + "ids": [ + "trakt": Int.random(in: 0...1000), + "slug": "Random_show_\(i)" + ] + ] + ] + } + watchersByPage.append(json.map { $0["watchers"] as? Int ?? 0 }) + let data = try JSONSerialization.data(withJSONObject: json) + try mock(.GET, urlString, result: .success(data), headers: [.page(p), .pageCount(2)]) + } + + for try await page in traktManager.shows.trending().limit(10).pagedResults() { + let watchersForPage = watchersByPage.removeFirst() + #expect(page.map { $0.watchers } == watchersForPage) + #expect(page.count == 10) + } + } } } diff --git a/Tests/TraktKitTests/TraktTestSuite.swift b/Tests/TraktKitTests/TraktTestSuite.swift index c8c6fac..7ef3e40 100644 --- a/Tests/TraktKitTests/TraktTestSuite.swift +++ b/Tests/TraktKitTests/TraktTestSuite.swift @@ -17,8 +17,12 @@ final class TraktTestSuite { RequestMocking.removeAllMocks() } - static func mock(_ method: TraktKit.Method, _ urlString: String, result: Result, httpCode: Int? = nil, headers: [HTTPHeader] = [.contentType, .apiVersion, .apiKey("")]) throws { + static func mock(_ method: TraktKit.Method, _ urlString: String, result: Result, httpCode: Int? = nil, headers: [HTTPHeader] = [.contentType, .apiVersion, .apiKey("")], replace: Bool = false) throws { let mock = try RequestMocking.MockedResponse(urlString: urlString, result: result, httpCode: httpCode ?? method.expectedResult, headers: headers) - RequestMocking.add(mock: mock) + if replace { + RequestMocking.replace(mock: mock) + } else { + RequestMocking.add(mock: mock) + } } } From 54d7ec3adcfb56ff7502cc5a6317914d91a95d6f Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Thu, 6 Mar 2025 02:33:44 -0500 Subject: [PATCH 21/38] Moved completion handler endpoints into a folder --- Common/Wrapper/{ => CompletionHandlerEndpoints}/Calendars.swift | 0 .../Wrapper/{ => CompletionHandlerEndpoints}/Certifications.swift | 0 Common/Wrapper/{ => CompletionHandlerEndpoints}/Checkin.swift | 0 Common/Wrapper/{ => CompletionHandlerEndpoints}/Comments.swift | 0 Common/Wrapper/{ => CompletionHandlerEndpoints}/Episodes.swift | 0 Common/Wrapper/{ => CompletionHandlerEndpoints}/Genres.swift | 0 Common/Wrapper/{ => CompletionHandlerEndpoints}/Languages.swift | 0 Common/Wrapper/{ => CompletionHandlerEndpoints}/Lists.swift | 0 Common/Wrapper/{ => CompletionHandlerEndpoints}/Movies.swift | 0 Common/Wrapper/{ => CompletionHandlerEndpoints}/People.swift | 0 .../{ => CompletionHandlerEndpoints}/Recommendations.swift | 0 Common/Wrapper/{ => CompletionHandlerEndpoints}/Scrobble.swift | 0 Common/Wrapper/{ => CompletionHandlerEndpoints}/Search.swift | 0 Common/Wrapper/{ => CompletionHandlerEndpoints}/Seasons.swift | 0 .../{ => CompletionHandlerEndpoints}/SharedFunctions.swift | 0 Common/Wrapper/{ => CompletionHandlerEndpoints}/Shows.swift | 0 Common/Wrapper/{ => CompletionHandlerEndpoints}/Sync.swift | 0 Common/Wrapper/{ => CompletionHandlerEndpoints}/Users.swift | 0 18 files changed, 0 insertions(+), 0 deletions(-) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/Calendars.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/Certifications.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/Checkin.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/Comments.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/Episodes.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/Genres.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/Languages.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/Lists.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/Movies.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/People.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/Recommendations.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/Scrobble.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/Search.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/Seasons.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/SharedFunctions.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/Shows.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/Sync.swift (100%) rename Common/Wrapper/{ => CompletionHandlerEndpoints}/Users.swift (100%) diff --git a/Common/Wrapper/Calendars.swift b/Common/Wrapper/CompletionHandlerEndpoints/Calendars.swift similarity index 100% rename from Common/Wrapper/Calendars.swift rename to Common/Wrapper/CompletionHandlerEndpoints/Calendars.swift diff --git a/Common/Wrapper/Certifications.swift b/Common/Wrapper/CompletionHandlerEndpoints/Certifications.swift similarity index 100% rename from Common/Wrapper/Certifications.swift rename to Common/Wrapper/CompletionHandlerEndpoints/Certifications.swift diff --git a/Common/Wrapper/Checkin.swift b/Common/Wrapper/CompletionHandlerEndpoints/Checkin.swift similarity index 100% rename from Common/Wrapper/Checkin.swift rename to Common/Wrapper/CompletionHandlerEndpoints/Checkin.swift diff --git a/Common/Wrapper/Comments.swift b/Common/Wrapper/CompletionHandlerEndpoints/Comments.swift similarity index 100% rename from Common/Wrapper/Comments.swift rename to Common/Wrapper/CompletionHandlerEndpoints/Comments.swift diff --git a/Common/Wrapper/Episodes.swift b/Common/Wrapper/CompletionHandlerEndpoints/Episodes.swift similarity index 100% rename from Common/Wrapper/Episodes.swift rename to Common/Wrapper/CompletionHandlerEndpoints/Episodes.swift diff --git a/Common/Wrapper/Genres.swift b/Common/Wrapper/CompletionHandlerEndpoints/Genres.swift similarity index 100% rename from Common/Wrapper/Genres.swift rename to Common/Wrapper/CompletionHandlerEndpoints/Genres.swift diff --git a/Common/Wrapper/Languages.swift b/Common/Wrapper/CompletionHandlerEndpoints/Languages.swift similarity index 100% rename from Common/Wrapper/Languages.swift rename to Common/Wrapper/CompletionHandlerEndpoints/Languages.swift diff --git a/Common/Wrapper/Lists.swift b/Common/Wrapper/CompletionHandlerEndpoints/Lists.swift similarity index 100% rename from Common/Wrapper/Lists.swift rename to Common/Wrapper/CompletionHandlerEndpoints/Lists.swift diff --git a/Common/Wrapper/Movies.swift b/Common/Wrapper/CompletionHandlerEndpoints/Movies.swift similarity index 100% rename from Common/Wrapper/Movies.swift rename to Common/Wrapper/CompletionHandlerEndpoints/Movies.swift diff --git a/Common/Wrapper/People.swift b/Common/Wrapper/CompletionHandlerEndpoints/People.swift similarity index 100% rename from Common/Wrapper/People.swift rename to Common/Wrapper/CompletionHandlerEndpoints/People.swift diff --git a/Common/Wrapper/Recommendations.swift b/Common/Wrapper/CompletionHandlerEndpoints/Recommendations.swift similarity index 100% rename from Common/Wrapper/Recommendations.swift rename to Common/Wrapper/CompletionHandlerEndpoints/Recommendations.swift diff --git a/Common/Wrapper/Scrobble.swift b/Common/Wrapper/CompletionHandlerEndpoints/Scrobble.swift similarity index 100% rename from Common/Wrapper/Scrobble.swift rename to Common/Wrapper/CompletionHandlerEndpoints/Scrobble.swift diff --git a/Common/Wrapper/Search.swift b/Common/Wrapper/CompletionHandlerEndpoints/Search.swift similarity index 100% rename from Common/Wrapper/Search.swift rename to Common/Wrapper/CompletionHandlerEndpoints/Search.swift diff --git a/Common/Wrapper/Seasons.swift b/Common/Wrapper/CompletionHandlerEndpoints/Seasons.swift similarity index 100% rename from Common/Wrapper/Seasons.swift rename to Common/Wrapper/CompletionHandlerEndpoints/Seasons.swift diff --git a/Common/Wrapper/SharedFunctions.swift b/Common/Wrapper/CompletionHandlerEndpoints/SharedFunctions.swift similarity index 100% rename from Common/Wrapper/SharedFunctions.swift rename to Common/Wrapper/CompletionHandlerEndpoints/SharedFunctions.swift diff --git a/Common/Wrapper/Shows.swift b/Common/Wrapper/CompletionHandlerEndpoints/Shows.swift similarity index 100% rename from Common/Wrapper/Shows.swift rename to Common/Wrapper/CompletionHandlerEndpoints/Shows.swift diff --git a/Common/Wrapper/Sync.swift b/Common/Wrapper/CompletionHandlerEndpoints/Sync.swift similarity index 100% rename from Common/Wrapper/Sync.swift rename to Common/Wrapper/CompletionHandlerEndpoints/Sync.swift diff --git a/Common/Wrapper/Users.swift b/Common/Wrapper/CompletionHandlerEndpoints/Users.swift similarity index 100% rename from Common/Wrapper/Users.swift rename to Common/Wrapper/CompletionHandlerEndpoints/Users.swift From 89b3b94ec227aecc672180e9237496237d792ae2 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 8 Mar 2025 06:57:59 -0500 Subject: [PATCH 22/38] Refactored authentication into its own class for better testability and API customization Since the first version of TraktKit authentication was handled automatically by storing the `access_token` and `refresh_token` in the keychain, and the token expiration date in UserDefaults. This was good because consumers of the API didn't have to really worry about handling that, but it also wasn't very testable, and if you wanted to use your own storage or a different keychain implementation, you couldn't without forking the repo and changing it. This commit changes that by extracting the authentication handling from `TraktManager` into its own protocol `TraktAuthentication`, with a concrete implementation that matches the existing behavior `KeychainTraktAuthentication`. There is even a mock implementation now `TraktMockAuthStorage` that can be used for testing without overwriting your credentials in the keychain. You will now need to call `TraktManager.refreshCurrentAuthState()`, or use the async initializer before making requests to ensure `TraktManager` has the latest `access_token` if making authenticated requests. --- Common/MLKeychain.swift | 3 +- Common/Models/Authentication/OAuthBody.swift | 4 + Common/Wrapper/AuthStorage.swift | 144 ++++++++++ .../Resources/AuthenticationResource.swift | 130 +++++++++ .../Resources/TraktManager+Resources.swift | 6 + Common/Wrapper/Route.swift | 46 +++- Common/Wrapper/TraktManager.swift | 255 ++++++------------ Tests/TraktKitTests/MovieTests+Async.swift | 6 + .../NetworkMocking/RequestMocking.swift | 4 +- Tests/TraktKitTests/OAuthTests.swift | 235 +++++++++++----- Tests/TraktKitTests/RouteTests.swift | 20 -- Tests/TraktKitTests/SyncTests+Async.swift | 194 +++++++++++++ Tests/TraktKitTests/SyncTests.swift | 14 +- Tests/TraktKitTests/TraktManagerTests.swift | 39 ++- Tests/TraktKitTests/TraktTestCase.swift | 12 +- Tests/TraktKitTests/TraktTestSuite.swift | 14 +- 16 files changed, 837 insertions(+), 289 deletions(-) create mode 100644 Common/Wrapper/AuthStorage.swift create mode 100644 Common/Wrapper/Resources/AuthenticationResource.swift delete mode 100644 Tests/TraktKitTests/RouteTests.swift diff --git a/Common/MLKeychain.swift b/Common/MLKeychain.swift index bfeb400..4fc112b 100644 --- a/Common/MLKeychain.swift +++ b/Common/MLKeychain.swift @@ -22,7 +22,8 @@ let kSecAttrAccessibleValue = kSecAttrAccessible as String let kSecAttrAccessibleAfterFirstUnlockValue = kSecAttrAccessibleAfterFirstUnlock as String public class MLKeychain { - + + @discardableResult class func setString(value: String, forKey key: String) -> Bool { let data = value.data(using: String.Encoding.utf8, allowLossyConversion: false)! diff --git a/Common/Models/Authentication/OAuthBody.swift b/Common/Models/Authentication/OAuthBody.swift index aae8fc6..6c78e00 100644 --- a/Common/Models/Authentication/OAuthBody.swift +++ b/Common/Models/Authentication/OAuthBody.swift @@ -7,6 +7,7 @@ struct OAuthBody: TraktObject { let code: String? + let accessToken: String? let refreshToken: String? let clientId: String? @@ -17,6 +18,7 @@ struct OAuthBody: TraktObject { enum CodingKeys: String, CodingKey { case code + case accessToken = "token" case refreshToken = "refresh_token" case clientId = "client_id" case clientSecret = "client_secret" @@ -26,6 +28,7 @@ struct OAuthBody: TraktObject { init( code: String? = nil, + accessToken: String? = nil, refreshToken: String? = nil, clientId: String? = nil, clientSecret: String? = nil, @@ -33,6 +36,7 @@ struct OAuthBody: TraktObject { grantType: String? = nil ) { self.code = code + self.accessToken = accessToken self.refreshToken = refreshToken self.clientId = clientId self.clientSecret = clientSecret diff --git a/Common/Wrapper/AuthStorage.swift b/Common/Wrapper/AuthStorage.swift new file mode 100644 index 0000000..7046c29 --- /dev/null +++ b/Common/Wrapper/AuthStorage.swift @@ -0,0 +1,144 @@ +// +// AuthStorage.swift +// TraktKit +// +// Created by Maximilian Litteral on 3/6/25. +// +import Foundation + +public struct AuthenticationState: Sendable { + public let accessToken: String + public let refreshToken: String + public let expirationDate: Date + + public init(accessToken: String, refreshToken: String, expirationDate: Date) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.expirationDate = expirationDate + } +} + +public enum AuthenticationError: Error, Equatable { + /// Token was found, but is past the expiration date. + case tokenExpired(refreshToken: String) + /// Thrown if credentials could not be retreived. + case noStoredCredentials +} + +public protocol TraktAuthentication: Sendable { + /// Returns the current access token, refresh token, and expiration date. + func getCurrentState() async throws(AuthenticationError) -> AuthenticationState + /// Store the latest state + func updateState(_ state: AuthenticationState) async + /// Delete the data + func clear() async +} + +public actor KeychainTraktAuthentication: TraktAuthentication { + private enum Constants { + static let tokenExpirationDefaultsKey = "accessTokenExpirationDate" + static let accessTokenKey = "accessToken" + static let refreshTokenKey = "refreshToken" + } + + private var accessToken: String? + private var refreshToken: String? + private var expirationDate: Date? + + public init() { + + } + + public func load() throws(AuthenticationError) -> AuthenticationState { + guard + let accessTokenData = MLKeychain.loadData(forKey: Constants.accessTokenKey), + let accessTokenString = String(data: accessTokenData, encoding: .utf8), + let refreshTokenData = MLKeychain.loadData(forKey: Constants.refreshTokenKey), + let refreshTokenString = String(data: refreshTokenData, encoding: .utf8) + else { throw .noStoredCredentials } + + accessToken = accessTokenString + refreshToken = refreshTokenString + + // Refresh auth if expiration is not found. + guard + let expiration = UserDefaults.standard.object(forKey: Constants.tokenExpirationDefaultsKey) as? Date + else { throw .tokenExpired(refreshToken: refreshTokenString) } + + expirationDate = expiration + + return AuthenticationState(accessToken: accessTokenString, refreshToken: refreshTokenString, expirationDate: expiration) + } + + public func getCurrentState() throws(AuthenticationError) -> AuthenticationState { + guard + let accessToken, + let refreshToken, + let expirationDate + else { return try load() } + + guard expirationDate > .now else { throw .tokenExpired(refreshToken: refreshToken) } + + return AuthenticationState(accessToken: accessToken, refreshToken: refreshToken, expirationDate: expirationDate) + } + + public func updateState(_ state: AuthenticationState) { + // Keep in memory + accessToken = state.accessToken + refreshToken = state.refreshToken + + // Save to keychain + MLKeychain.setString(value: state.accessToken, forKey: Constants.accessTokenKey) + MLKeychain.setString(value: state.refreshToken, forKey: Constants.refreshTokenKey) + + UserDefaults.standard.set(state.expirationDate, forKey: Constants.tokenExpirationDefaultsKey) + } + + public func clear() { + accessToken = nil + refreshToken = nil + expirationDate = nil + + MLKeychain.deleteItem(forKey: Constants.accessTokenKey) + MLKeychain.deleteItem(forKey: Constants.refreshTokenKey) + + UserDefaults.standard.removeObject(forKey: Constants.tokenExpirationDefaultsKey) + } +} + +public actor TraktMockAuthStorage: TraktAuthentication { + + var accessToken: String? + var refreshToken: String? + var expirationDate: Date? + + public init(accessToken: String? = nil, refreshToken: String? = nil, expirationDate: Date? = nil) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.expirationDate = expirationDate + } + + public func getCurrentState() async throws(AuthenticationError) -> AuthenticationState { + guard + let accessToken, + let refreshToken, + let expirationDate + else { throw .noStoredCredentials } + + guard expirationDate > .now else { throw .tokenExpired(refreshToken: refreshToken) } + + return AuthenticationState(accessToken: accessToken, refreshToken: refreshToken, expirationDate: expirationDate) + } + + public func updateState(_ state: AuthenticationState) async { + accessToken = state.accessToken + refreshToken = state.refreshToken + expirationDate = state.expirationDate + } + + public func clear() async { + accessToken = nil + refreshToken = nil + expirationDate = nil + } +} diff --git a/Common/Wrapper/Resources/AuthenticationResource.swift b/Common/Wrapper/Resources/AuthenticationResource.swift new file mode 100644 index 0000000..aa8a5b3 --- /dev/null +++ b/Common/Wrapper/Resources/AuthenticationResource.swift @@ -0,0 +1,130 @@ +// +// AuthenticationResource.swift +// TraktKit +// +// Created by Maximilian Litteral on 3/6/25. +// + +import Foundation + +extension TraktManager { + /// Endpoints for authentication + public struct AuthenticationResource { + private let traktManager: TraktManager + private let path: String + + internal init(traktManager: TraktManager) { + self.traktManager = traktManager + self.path = "oauth" + } + + // MARK: - Authentication - OAuth + + /** + Use the authorization `code` GET parameter sent back to your `redirect_uri` to get an `access_token`. Save the `access_token` so your app can authenticate the user by sending the `Authorization` header. + + The `access_token` is valid for **24 hours**. Save and use the `refresh_token` to get a new `access_token` without asking the user to re-authenticate. + */ + public func getAccessToken(for code: String) -> Route { + let body = OAuthBody( + code: code, + clientId: traktManager.clientId, + clientSecret: traktManager.clientSecret, + redirectURI: traktManager.redirectURI, + grantType: "authorization_code" + ) + return Route( + paths: [path, "token"], + body: body, + method: .POST, + requiresAuthentication: false, + traktManager: traktManager + ) + } + + /** + Use the `refresh_token` to get a new `access_token` without asking the user to re-authenticate. The `access_token` is valid for **24 hours** before it needs to be refreshed again. + */ + public func getAccessToken(from refreshToken: String) -> Route { + let body = OAuthBody( + refreshToken: refreshToken, + clientId: traktManager.clientId, + clientSecret: traktManager.clientSecret, + redirectURI: traktManager.redirectURI, + grantType: "refresh_token" + ) + return Route( + paths: [path, "token"], + body: body, + method: .POST, + requiresAuthentication: false, + traktManager: traktManager + ) + } + + /** + An `access_token` can be revoked when a user signs out of their Trakt account in your app. This is not required, but might improve the user experience so the user doesn't have an unused app connection hanging around. + */ + public func revokeToken(_ accessToken: String) -> EmptyRoute { + let body = OAuthBody( + accessToken: accessToken, + clientId: traktManager.clientId, + clientSecret: traktManager.clientSecret + ) + return EmptyRoute( + paths: [path, "revoke"], + body: body, + method: .POST, + requiresAuthentication: false, + traktManager: traktManager + ) + } + + // MARK: - Authentication - Devices + + /** + Generate new codes to start the device authentication process. The `device_code` and interval will be used later to poll for the `access_token`. The `user_code` and `verification_url` should be presented to the user as mentioned in the flow steps above. + + **QR Code** + + You might consider generating a QR code for the user to easily scan on their mobile device. The QR code should be a URL that redirects to the `verification_url` and appends the `user_code`. For example, `https://trakt.tv/activate/5055CC52` would load the Trakt hosted `verification_url` and pre-fill in the `user_code`. + */ + public func generateDeviceCode() -> Route { + let body = OAuthBody(clientId: traktManager.clientId) + return Route( + paths: [path, "device", "code"], + body: body, + method: .POST, + requiresAuthentication: false, + traktManager: traktManager + ) + } + + /** + Use the `device_code` and poll at the `interval` (in seconds) to check if the user has authorized you app. Use `expires_in` to stop polling after that many seconds, and gracefully instruct the user to restart the process. **It is important to poll at the correct interval and also stop polling when expired.** + + When you receive a `200` success response, save the `access_token` so your app can authenticate the user in methods that require it. The `access_token` is valid for 24 hours. Save and use the `refresh_token` to get a new `access_token` without asking the user to re-authenticate. Check below for all the error codes that you should handle. + + > important: This function does **NOT** poll for the access token. This makes a single request to the endpoint and returns authentication info if available, and the status code used for polling. Use ``../TraktManager/pollForAccessToken(deviceCode:)`` to poll for the access token. + */ + public func requestAccessToken(code: String) async throws -> (AuthenticationInfo?, Int) { + let body = OAuthBody( + code: code, + clientId: traktManager.clientId, + clientSecret: traktManager.clientSecret + ) + let request = try traktManager.mutableRequest(forPath: "oauth/device/token", isAuthorized: false, withHTTPMethod: .POST, body: body) + + // Make response + let (data, response) = try await traktManager.session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + // Don't throw, we want to check the `responseCode` even if the token response cannot be decoded. + let tokenResponse = try? JSONDecoder().decode(AuthenticationInfo.self, from: data) + + return (tokenResponse, httpResponse.statusCode) + } + } +} diff --git a/Common/Wrapper/Resources/TraktManager+Resources.swift b/Common/Wrapper/Resources/TraktManager+Resources.swift index 0d8021c..6af0720 100644 --- a/Common/Wrapper/Resources/TraktManager+Resources.swift +++ b/Common/Wrapper/Resources/TraktManager+Resources.swift @@ -10,6 +10,12 @@ import Foundation extension TraktManager { + // MARK: - Authentication + + public func auth() -> AuthenticationResource { + AuthenticationResource(traktManager: self) + } + // MARK: - Search public func search() -> SearchResource { diff --git a/Common/Wrapper/Route.swift b/Common/Wrapper/Route.swift index 3cad774..c7b0dd9 100644 --- a/Common/Wrapper/Route.swift +++ b/Common/Wrapper/Route.swift @@ -32,7 +32,15 @@ public struct Route: Sendable { // MARK: - Lifecycle - public init(path: String, queryItems: [String: String] = [:], body: (any TraktObject)? = nil, method: Method, requiresAuthentication: Bool = false, resultType: T.Type = T.self, traktManager: TraktManager) { + public init( + path: String, + queryItems: [String: String] = [:], + body: (any TraktObject)? = nil, + method: Method, + requiresAuthentication: Bool = false, + resultType: T.Type = T.self, + traktManager: TraktManager + ) { self.path = path self.queryItems = queryItems self.body = body @@ -42,7 +50,15 @@ public struct Route: Sendable { self.traktManager = traktManager } - public init(paths: [CustomStringConvertible?], queryItems: [String: String] = [:], body: (any EncodableTraktObject)? = nil, method: Method, requiresAuthentication: Bool = false, resultType: T.Type = T.self, traktManager: TraktManager) { + public init( + paths: [CustomStringConvertible?], + queryItems: [String: String] = [:], + body: (any EncodableTraktObject)? = nil, + method: Method, + requiresAuthentication: Bool = false, + resultType: T.Type = T.self, + traktManager: TraktManager + ) { self.path = paths.compactMap { $0?.description }.joined(separator: "/") self.queryItems = queryItems self.body = body @@ -149,22 +165,39 @@ public struct Route: Sendable { // MARK: - No data response public struct EmptyRoute: Sendable { + private let traktManager: TraktManager + internal var path: String internal let method: Method internal let requiresAuthentication: Bool - private let traktManager: TraktManager + + private var body: (any EncodableTraktObject)? // MARK: - Lifecycle - public init(path: String, method: Method, requiresAuthentication: Bool = false, traktManager: TraktManager) { + public init( + path: String, + body: (any TraktObject)? = nil, + method: Method, + requiresAuthentication: Bool = false, + traktManager: TraktManager + ) { self.path = path + self.body = body self.method = method self.requiresAuthentication = requiresAuthentication self.traktManager = traktManager } - public init(paths: [CustomStringConvertible?], method: Method, requiresAuthentication: Bool = false, traktManager: TraktManager) { + public init( + paths: [CustomStringConvertible?], + body: (any TraktObject)? = nil, + method: Method, + requiresAuthentication: Bool = false, + traktManager: TraktManager + ) { self.path = paths.compactMap { $0?.description }.joined(separator: "/") + self.body = body self.method = method self.requiresAuthentication = requiresAuthentication self.traktManager = traktManager @@ -177,7 +210,8 @@ public struct EmptyRoute: Sendable { forPath: path, withQuery: [:], isAuthorized: requiresAuthentication, - withHTTPMethod: method + withHTTPMethod: method, + body: body ) let _ = try await traktManager.fetchData(request: request, retryLimit: retryLimit) } diff --git a/Common/Wrapper/TraktManager.swift b/Common/Wrapper/TraktManager.swift index 14a2b18..a256c89 100644 --- a/Common/Wrapper/TraktManager.swift +++ b/Common/Wrapper/TraktManager.swift @@ -20,14 +20,18 @@ public final class TraktManager: Sendable { // MARK: - Types + /// Errors related to the operation of TraktKit. public enum TraktKitError: Error { case missingClientInfo case malformedURL case userNotAuthorized case couldNotParseData + + // TODO: Move this enum case invalidRefreshToken } + /// Possible errors thrown when polling for an access code. public enum TraktTokenError: Error { // 404 case invalidDeviceCode @@ -43,47 +47,23 @@ public final class TraktManager: Sendable { case missingAccessCode } - public enum RefreshState { - case noTokens, validTokens, refreshTokens, expiredTokens - } - - /// Returns the local token state. This could be wrong if a user revokes application access from Trakt.tv - public var refreshState: RefreshState { - guard let expiredDate = UserDefaults.standard.object(forKey: Constants.tokenExpirationDefaultsKey) as? Date else { - return .noTokens - } - let refreshDate = expiredDate.addingTimeInterval(-Constants.oneMonth) - let now = Date() - - if now >= expiredDate { - return .expiredTokens - } - - if now >= refreshDate { - return .refreshTokens - } - - return .validTokens - } - // MARK: - Properties - private enum Constants { - static let tokenExpirationDefaultsKey = "accessTokenExpirationDate" - static let oneMonth: TimeInterval = 2629800 - static let accessTokenKey = "accessToken" - static let refreshTokenKey = "refreshToken" - } - static let logger = Logger(subsystem: "TraktKit", category: "TraktManager") // MARK: Internal private let staging: Bool - private let clientId: String - private let clientSecret: String - private let redirectURI: String + internal let clientId: String + internal let clientSecret: String + internal let redirectURI: String private let apiHost: String + private let authStorage: any TraktAuthentication + + private let authStateLock = NSLock() + nonisolated(unsafe) + private var cachedAuthState: AuthenticationState? + internal static let jsonEncoder: JSONEncoder = { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 @@ -96,7 +76,9 @@ public final class TraktManager: Sendable { public var isSignedIn: Bool { get { - return accessToken != nil + authStateLock.lock() + defer { authStateLock.unlock() } + return cachedAuthState != nil } } @@ -113,76 +95,20 @@ public final class TraktManager: Sendable { return urlComponents.url } - /// Cached access token so that we don't have to fetch from the keychain repeatedly. - nonisolated(unsafe) - private var _accessToken: String? - public var accessToken: String? { - get { - if _accessToken != nil { - return _accessToken - } - if let accessTokenData = MLKeychain.loadData(forKey: Constants.accessTokenKey) { - if let accessTokenString = String(data: accessTokenData, encoding: .utf8) { - _accessToken = accessTokenString - return accessTokenString - } - } - - return nil - } - set { - // Save somewhere secure - _accessToken = newValue - if newValue == nil { - // Remove from keychain - MLKeychain.deleteItem(forKey: Constants.accessTokenKey) - } else { - // Save to keychain - let succeeded = MLKeychain.setString(value: newValue!, forKey: Constants.accessTokenKey) - Self.logger.debug("Saved access token \(succeeded ? "successfully" : "failed")") - } - } - } - - /// Cached refresh token so that we don't have to fetch from the keychain repeatedly. - nonisolated(unsafe) - private var _refreshToken: String? - public var refreshToken: String? { - get { - if _refreshToken != nil { - return _refreshToken - } - if let refreshTokenData = MLKeychain.loadData(forKey: Constants.refreshTokenKey) { - if let refreshTokenString = String.init(data: refreshTokenData, encoding: .utf8) { - _refreshToken = refreshTokenString - return refreshTokenString - } - } - - return nil - } - set { - // Save somewhere secure - _refreshToken = newValue - if newValue == nil { - // Remove from keychain - MLKeychain.deleteItem(forKey: Constants.refreshTokenKey) - } else { - // Save to keychain - let succeeded = MLKeychain.setString(value: newValue!, forKey: Constants.refreshTokenKey) - Self.logger.debug("Saved refresh token \(succeeded ? "successfully" : "failed")") - } - } - } - // MARK: - Lifecycle + /** + Initializes the TraktManager. + + > important: Call `refreshCurrentAuthState` shortly after initializing `TraktManager`, otherwise authenticated calls will fail. + */ public init( session: URLSession = URLSession(configuration: .default), staging: Bool = false, clientId: String, clientSecret: String, - redirectURI: String + redirectURI: String, + authStorage: any TraktAuthentication = KeychainTraktAuthentication() ) { self.session = session self.staging = staging @@ -190,32 +116,47 @@ public final class TraktManager: Sendable { self.clientSecret = clientSecret self.redirectURI = redirectURI self.apiHost = staging ? "api-staging.trakt.tv" : "api.trakt.tv" + self.authStorage = authStorage } - // MARK: - Setup - - internal func createErrorWithStatusCode(_ statusCode: Int) -> NSError { - let message = if let traktMessage = StatusCodes.message(for: statusCode) { - traktMessage - } else { - "Request Failed: Gateway timed out (\(statusCode))" - } + /// Initialize TraktManager and refreshes the auth state + public init( + session: URLSession = URLSession(configuration: .default), + staging: Bool = false, + clientId: String, + clientSecret: String, + redirectURI: String, + authStorage: any TraktAuthentication = KeychainTraktAuthentication() + ) async { + self.session = session + self.staging = staging + self.clientId = clientId + self.clientSecret = clientSecret + self.redirectURI = redirectURI + self.apiHost = staging ? "api-staging.trakt.tv" : "api.trakt.tv" + self.authStorage = authStorage - let userInfo = [ - "title": "Error", - NSLocalizedDescriptionKey: message, - NSLocalizedFailureReasonErrorKey: "", - NSLocalizedRecoverySuggestionErrorKey: "" - ] - return NSError(domain: "com.litteral.TraktKit", code: statusCode, userInfo: userInfo) + try? await refreshCurrentAuthState() } // MARK: - Actions - public func signOut() { - accessToken = nil - refreshToken = nil - UserDefaults.standard.removeObject(forKey: Constants.tokenExpirationDefaultsKey) + /** + Gets the current authentication state from the authentication storage, and caches the result to make requests. + You should only have to call this once shortly after initializing the `TraktManager`, unless you use the async initializer, which calls this function automatically. + */ + public func refreshCurrentAuthState() async throws(AuthenticationError) { + let currentState = try await authStorage.getCurrentState() + authStateLock.withLock { + cachedAuthState = currentState + } + } + + public func signOut() async { + await authStorage.clear() + authStateLock.withLock { + cachedAuthState = nil + } } internal func mutableRequest( @@ -250,7 +191,7 @@ public final class TraktManager: Sendable { request.addValue(clientId, forHTTPHeaderField: "trakt-api-key") if authorized { - if let accessToken { + if let accessToken = cachedAuthState?.accessToken { request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } else { throw TraktKitError.userNotAuthorized @@ -299,16 +240,7 @@ public final class TraktManager: Sendable { // MARK: - Authentication public func getToken(authorizationCode code: String) async throws -> AuthenticationInfo { - let body = OAuthBody( - code: code, - clientId: clientId, - clientSecret: clientSecret, - redirectURI: redirectURI, - grantType: "authorization_code" - ) - - let request = try mutableRequest(forPath: "/oauth/token", isAuthorized: false, withHTTPMethod: .POST, body: body) - let authenticationInfo: AuthenticationInfo = try await perform(request: request) + let authenticationInfo = try await auth().getAccessToken(for: code).perform() await saveCredentials(for: authenticationInfo, postAccountStatusChange: true) return authenticationInfo } @@ -323,16 +255,14 @@ public final class TraktManager: Sendable { You might consider generating a QR code for the user to easily scan on their mobile device. The QR code should be a URL that redirects to the `verification_url` and appends the `user_code`. For example, `https://trakt.tv/activate/5055CC52` would load the Trakt hosted `verification_url` and pre-fill in the `user_code`. */ public func getAppCode() async throws -> DeviceCode { - let body = OAuthBody(clientId: clientId) - let request = try mutableRequest(forPath: "/oauth/device/code", isAuthorized: false, withHTTPMethod: .POST, body: body) - return try await perform(request: request) + try await auth().generateDeviceCode().perform() } public func pollForAccessToken(deviceCode: DeviceCode) async throws { let startTime = Date() while true { - let (tokenResponse, statusCode) = try await requestAccessToken(code: deviceCode.deviceCode) + let (tokenResponse, statusCode) = try await auth().requestAccessToken(code: deviceCode.deviceCode) switch statusCode { case 200: @@ -366,31 +296,19 @@ public final class TraktManager: Sendable { } } - private func requestAccessToken(code: String) async throws -> (AuthenticationInfo?, Int) { - let body = OAuthBody(code: code, clientId: clientId, clientSecret: clientSecret) - let request = try mutableRequest(forPath: "/oauth/device/token", isAuthorized: false, withHTTPMethod: .POST, body: body) - - // Make response - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - // Don't throw, we want to check the `responseCode` even if the token response cannot be decoded. - let tokenResponse = try? JSONDecoder().decode(AuthenticationInfo.self, from: data) - - return (tokenResponse, httpResponse.statusCode) - } - // TODO: Find replacement for posting `TraktAccountStatusDidChange` to alert apps of account change. private func saveCredentials(for authInfo: AuthenticationInfo, postAccountStatusChange: Bool = false) async { - self.accessToken = authInfo.accessToken - self.refreshToken = authInfo.refreshToken + let expiresDate = Date(timeIntervalSince1970: authInfo.createdAt).addingTimeInterval(authInfo.expiresIn) - // Save expiration date - let expiresDate = Date(timeIntervalSinceNow: authInfo.expiresIn) - UserDefaults.standard.set(expiresDate, forKey: Constants.tokenExpirationDefaultsKey) - UserDefaults.standard.synchronize() + let authenticationState = AuthenticationState( + accessToken: authInfo.accessToken, + refreshToken: authInfo.refreshToken, + expirationDate: expiresDate + ) + await authStorage.updateState(authenticationState) + authStateLock.withLock { + cachedAuthState = authenticationState + } // Post notification if postAccountStatusChange { @@ -403,31 +321,28 @@ public final class TraktManager: Sendable { // MARK: Refresh access token public func checkToRefresh() async throws { - switch refreshState { - case .refreshTokens: - try await getAccessTokenFromRefreshToken() - case .expiredTokens: - throw TraktKitError.invalidRefreshToken - default: - break + do throws(AuthenticationError) { + let currentState = try await authStorage.getCurrentState() + authStateLock.withLock { + cachedAuthState = currentState + } + } catch .tokenExpired(let refreshToken) { + try await refreshAccessToken(with: refreshToken) + } catch .noStoredCredentials { + throw TraktKitError.userNotAuthorized } } /** Use the `refresh_token` to get a new `access_token` without asking the user to re-authenticate. The `access_token` is valid for 24 hours before it needs to be refreshed again. */ - public func getAccessTokenFromRefreshToken() async throws { - guard let refreshToken else { throw TraktKitError.invalidRefreshToken } - - // Create request - let body = OAuthBody(refreshToken: refreshToken, clientId: clientId, clientSecret: clientSecret, redirectURI: redirectURI, grantType: "refresh_token") - let request = try mutableRequest(forPath: "/oauth/token", isAuthorized: false, withHTTPMethod: .POST, body: body) - - // Make request and handle response + @discardableResult + private func refreshAccessToken(with refreshToken: String) async throws -> AuthenticationInfo { do { - let authenticationInfo: AuthenticationInfo = try await perform(request: request) + let authenticationInfo = try await auth().getAccessToken(from: refreshToken).perform() await saveCredentials(for: authenticationInfo) - } catch TraktError.unauthorized { + return authenticationInfo + } catch TraktError.unauthorized { // 401 - Invalid refresh token throw TraktKitError.invalidRefreshToken } catch { throw error diff --git a/Tests/TraktKitTests/MovieTests+Async.swift b/Tests/TraktKitTests/MovieTests+Async.swift index b4a1b68..8dd4946 100644 --- a/Tests/TraktKitTests/MovieTests+Async.swift +++ b/Tests/TraktKitTests/MovieTests+Async.swift @@ -11,6 +11,7 @@ extension TraktTestSuite { @Suite(.serialized) struct MovieTestSuite { @Test func getTrendingMovies() async throws { + let traktManager = await authenticatedTraktManager() try mock(.GET, "https://api.trakt.tv/movies/trending?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_trending_movies")), headers: [.page(1), .pageCount(10)]) let result = try await traktManager.movies @@ -26,6 +27,7 @@ extension TraktTestSuite { } @Test func getPopularMovies() async throws { + let traktManager = await authenticatedTraktManager() try mock(.GET, "https://api.trakt.tv/movies/popular?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_popular_movies"))) let result = try await traktManager.movies @@ -39,6 +41,7 @@ extension TraktTestSuite { } @Test func getMostPlayedMovies() async throws { + let traktManager = await authenticatedTraktManager() try mock(.GET, "https://api.trakt.tv/movies/played/all?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_most_played_movies"))) let result = try await traktManager.movies @@ -52,6 +55,7 @@ extension TraktTestSuite { } @Test func getMostWatchedMovies() async throws { + let traktManager = await authenticatedTraktManager() try mock(.GET, "https://api.trakt.tv/movies/watched/all?extended=min&page=1&limit=10", result: .success(jsonData(named: "test_get_most_watched_movies"))) let result = try await traktManager.movies @@ -65,6 +69,7 @@ extension TraktTestSuite { } @Test func getPeopleInMovie() async throws { + let traktManager = await authenticatedTraktManager() try mock(.GET, "https://api.trakt.tv/movies/iron-man-2008/people?extended=min", result: .success(jsonData(named: "test_get_cast_and_crew"))) let castAndCrew = try await traktManager.movie(id: "iron-man-2008") @@ -87,6 +92,7 @@ extension TraktTestSuite { } @Test func getMovieStudios() async throws { + let traktManager = await authenticatedTraktManager() try mock(.GET, "https://api.trakt.tv/movies/tron-legacy-2010/studios", result: .success(jsonData(named: "test_get_movie_studios"))) let studios = try await traktManager.movie(id: "tron-legacy-2010") diff --git a/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift b/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift index d84d696..7e72a83 100644 --- a/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift +++ b/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift @@ -12,8 +12,8 @@ extension URLSession { static var mockedResponsesOnly: URLSession { let configuration = URLSessionConfiguration.default configuration.protocolClasses = [RequestMocking.self, RequestBlocking.self] - configuration.timeoutIntervalForRequest = 1 - configuration.timeoutIntervalForResource = 1 + configuration.timeoutIntervalForRequest = 2 + configuration.timeoutIntervalForResource = 2 return URLSession(configuration: configuration) } } diff --git a/Tests/TraktKitTests/OAuthTests.swift b/Tests/TraktKitTests/OAuthTests.swift index 1d48a5c..923cef1 100644 --- a/Tests/TraktKitTests/OAuthTests.swift +++ b/Tests/TraktKitTests/OAuthTests.swift @@ -9,76 +9,175 @@ import Foundation import Testing @testable import TraktKit -extension TraktTestSuite { - @Suite - struct OAuthTests { - - @Test func decodeOAuthTokenBody() throws { - let json: [String: String] = [ - "code": "fd0847dbb559752d932dd3c1ac34ff98d27b11fe2fea5a864f44740cd7919ad0", - "client_id": "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f", - "client_secret": "d6ea27703957b69939b8104ed4524595e210cd2e79af587744a7eb6e58f5b3d2", - "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", - "grant_type": "authorization_code" - ] - let data = try JSONSerialization.data(withJSONObject: json) - - let oauthBody = try JSONDecoder().decode(OAuthBody.self, from: data) - #expect(oauthBody.code == "fd0847dbb559752d932dd3c1ac34ff98d27b11fe2fea5a864f44740cd7919ad0") - #expect(oauthBody.refreshToken == nil) - #expect(oauthBody.clientId == "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f") - #expect(oauthBody.clientSecret == "d6ea27703957b69939b8104ed4524595e210cd2e79af587744a7eb6e58f5b3d2") - #expect(oauthBody.redirectURI == "urn:ietf:wg:oauth:2.0:oob") - #expect(oauthBody.grantType == "authorization_code") - - // To ensure `nil` keys are not encoded as empty or `null` - let backToData = try JSONEncoder().encode(oauthBody) - let backToJSON = try #require(JSONSerialization.jsonObject(with: backToData) as? [String: String]) - #expect(backToJSON == json) - } +@Suite(.serialized) +struct OAuthTests { - @Test func decodeRefreshTokenBody() throws { - let json: [String: String] = [ - "refresh_token": "fd0847dbb559752d932dd3c1ac34ff98d27b11fe2fea5a864f44740cd7919ad0", - "client_id": "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f", - "client_secret": "d6ea27703957b69939b8104ed4524595e210cd2e79af587744a7eb6e58f5b3d2", - "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", - "grant_type": "refresh_token" - ] - let data = try JSONSerialization.data(withJSONObject: json) - - let oauthBody = try JSONDecoder().decode(OAuthBody.self, from: data) - #expect(oauthBody.code == nil) - #expect(oauthBody.refreshToken == "fd0847dbb559752d932dd3c1ac34ff98d27b11fe2fea5a864f44740cd7919ad0") - #expect(oauthBody.clientId == "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f") - #expect(oauthBody.clientSecret == "d6ea27703957b69939b8104ed4524595e210cd2e79af587744a7eb6e58f5b3d2") - #expect(oauthBody.redirectURI == "urn:ietf:wg:oauth:2.0:oob") - #expect(oauthBody.grantType == "refresh_token") - - // To ensure `nil` keys are not encoded as empty or `null` - let backToData = try JSONEncoder().encode(oauthBody) - let backToJSON = try #require(JSONSerialization.jsonObject(with: backToData) as? [String: String]) - #expect(backToJSON == json) - } + @Test func decodeOAuthTokenBody() throws { + let json: [String: String] = [ + "code": "fd0847dbb559752d932dd3c1ac34ff98d27b11fe2fea5a864f44740cd7919ad0", + "client_id": "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f", + "client_secret": "d6ea27703957b69939b8104ed4524595e210cd2e79af587744a7eb6e58f5b3d2", + "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", + "grant_type": "authorization_code" + ] + let data = try JSONSerialization.data(withJSONObject: json) + + let oauthBody = try JSONDecoder().decode(OAuthBody.self, from: data) + #expect(oauthBody.code == "fd0847dbb559752d932dd3c1ac34ff98d27b11fe2fea5a864f44740cd7919ad0") + #expect(oauthBody.refreshToken == nil) + #expect(oauthBody.clientId == "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f") + #expect(oauthBody.clientSecret == "d6ea27703957b69939b8104ed4524595e210cd2e79af587744a7eb6e58f5b3d2") + #expect(oauthBody.redirectURI == "urn:ietf:wg:oauth:2.0:oob") + #expect(oauthBody.grantType == "authorization_code") + + // To ensure `nil` keys are not encoded as empty or `null` + let backToData = try JSONEncoder().encode(oauthBody) + let backToJSON = try #require(JSONSerialization.jsonObject(with: backToData) as? [String: String]) + #expect(backToJSON == json) + } + + @Test func decodeRefreshTokenBody() throws { + let json: [String: String] = [ + "refresh_token": "fd0847dbb559752d932dd3c1ac34ff98d27b11fe2fea5a864f44740cd7919ad0", + "client_id": "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f", + "client_secret": "d6ea27703957b69939b8104ed4524595e210cd2e79af587744a7eb6e58f5b3d2", + "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", + "grant_type": "refresh_token" + ] + let data = try JSONSerialization.data(withJSONObject: json) + + let oauthBody = try JSONDecoder().decode(OAuthBody.self, from: data) + #expect(oauthBody.code == nil) + #expect(oauthBody.refreshToken == "fd0847dbb559752d932dd3c1ac34ff98d27b11fe2fea5a864f44740cd7919ad0") + #expect(oauthBody.clientId == "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f") + #expect(oauthBody.clientSecret == "d6ea27703957b69939b8104ed4524595e210cd2e79af587744a7eb6e58f5b3d2") + #expect(oauthBody.redirectURI == "urn:ietf:wg:oauth:2.0:oob") + #expect(oauthBody.grantType == "refresh_token") + + // To ensure `nil` keys are not encoded as empty or `null` + let backToData = try JSONEncoder().encode(oauthBody) + let backToJSON = try #require(JSONSerialization.jsonObject(with: backToData) as? [String: String]) + #expect(backToJSON == json) + } + + @Test func decodeDeviceTokenBody() throws { + let json: [String: String] = [ + "client_id": "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f" + ] + let data = try JSONSerialization.data(withJSONObject: json) + + let oauthBody = try JSONDecoder().decode(OAuthBody.self, from: data) + #expect(oauthBody.code == nil) + #expect(oauthBody.refreshToken == nil) + #expect(oauthBody.clientId == "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f") + #expect(oauthBody.clientSecret == nil) + #expect(oauthBody.redirectURI == nil) + #expect(oauthBody.grantType == nil) + + // To ensure `nil` keys are not encoded as empty or `null` + let backToData = try JSONEncoder().encode(oauthBody) + let backToJSON = try #require(JSONSerialization.jsonObject(with: backToData) as? [String: String]) + #expect(backToJSON == json) + } + + // MARK: - Networking + + @Test func exchangeCodeForAccessCode() async throws { + let json: [String: Any] = [ + "access_token": "dbaf9757982a9e738f05d249b7b5b4a266b3a139049317c4909f2f263572c781", + "token_type": "bearer", + "expires_in": 86400, + "refresh_token": "76ba4c5c75c96f6087f58a4de10be6c00b29ea1ddc3b2022ee2016d1363e3a7c", + "scope": "public", + "created_at": Date.now.timeIntervalSince1970 + ] + let data = try JSONSerialization.data(withJSONObject: json) + try OAuthTests.mock(.POST, "https://api.trakt.tv/oauth/token", result: .success(data), replace: true) + + let authStorage = TraktMockAuthStorage() + + let traktManager = await TraktManager( + session: URLSession.mockedResponsesOnly, + clientId: "", + clientSecret: "", + redirectURI: "", + authStorage: authStorage + ) + await #expect(throws: AuthenticationError.noStoredCredentials, performing: { + try await authStorage.getCurrentState() + }) + + let code = "..." + let authInfo = try await traktManager.getToken(authorizationCode: code) + + #expect(authInfo.accessToken == "dbaf9757982a9e738f05d249b7b5b4a266b3a139049317c4909f2f263572c781") + #expect(authInfo.refreshToken == "76ba4c5c75c96f6087f58a4de10be6c00b29ea1ddc3b2022ee2016d1363e3a7c") + #expect(authInfo.scope == "public") + + let currentAuthState = try await authStorage.getCurrentState() + #expect(currentAuthState.accessToken == authInfo.accessToken) + #expect(traktManager.isSignedIn == true) + } + + @Test func exchangeRefreshTokenForAccessCode() async throws { + let refreshToken = "abcdefgh" + // Setup Trakt Manager with expired token + let authStorage = TraktMockAuthStorage(accessToken: "123456789", refreshToken: refreshToken, expirationDate: .distantPast) + + let traktManager = await TraktManager( + session: URLSession.mockedResponsesOnly, + clientId: "", + clientSecret: "", + redirectURI: "", + authStorage: authStorage + ) + await #expect(throws: AuthenticationError.tokenExpired(refreshToken: refreshToken), performing: { + try await authStorage.getCurrentState() + }) + + // Mock + let json: [String: Any] = [ + "access_token": "dbaf9757982a9e738f05d249b7b5b4a266b3a139049317c4909f2f263572c781", + "token_type": "bearer", + "expires_in": 86400, + "refresh_token": "76ba4c5c75c96f6087f58a4de10be6c00b29ea1ddc3b2022ee2016d1363e3a7c", + "scope": "public", + "created_at": Date.now.timeIntervalSince1970 + ] + let data = try JSONSerialization.data(withJSONObject: json) + try OAuthTests.mock(.POST, "https://api.trakt.tv/oauth/token", result: .success(data), replace: true) + + // Refresh token + try await traktManager.checkToRefresh() + + let currentAuthState = try await authStorage.getCurrentState() + #expect(currentAuthState.accessToken == "dbaf9757982a9e738f05d249b7b5b4a266b3a139049317c4909f2f263572c781") + #expect(traktManager.isSignedIn == true) + } + + @Test func signOut() async throws { + let authStorage = TraktMockAuthStorage(accessToken: "123456789", refreshToken: "abcdefgh", expirationDate: .distantFuture) + + let traktManager = await TraktManager( + session: URLSession.mockedResponsesOnly, + clientId: "", + clientSecret: "", + redirectURI: "", + authStorage: authStorage + ) + #expect(traktManager.isSignedIn == true) + await traktManager.signOut() + #expect(traktManager.isSignedIn == false) + } + + // MARK: - Helper - @Test func decodeDeviceTokenBody() throws { - let json: [String: String] = [ - "client_id": "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f" - ] - let data = try JSONSerialization.data(withJSONObject: json) - - let oauthBody = try JSONDecoder().decode(OAuthBody.self, from: data) - #expect(oauthBody.code == nil) - #expect(oauthBody.refreshToken == nil) - #expect(oauthBody.clientId == "9b36d8c0db59eff5038aea7a417d73e69aea75b41aac771816d2ef1b3109cc2f") - #expect(oauthBody.clientSecret == nil) - #expect(oauthBody.redirectURI == nil) - #expect(oauthBody.grantType == nil) - - // To ensure `nil` keys are not encoded as empty or `null` - let backToData = try JSONEncoder().encode(oauthBody) - let backToJSON = try #require(JSONSerialization.jsonObject(with: backToData) as? [String: String]) - #expect(backToJSON == json) + static func mock(_ method: TraktKit.Method, _ urlString: String, result: Result, httpCode: Int? = nil, headers: [HTTPHeader] = [.contentType, .apiVersion, .apiKey("")], replace: Bool = false) throws { + let mock = try RequestMocking.MockedResponse(urlString: urlString, result: result, httpCode: httpCode ?? method.expectedResult, headers: headers) + if replace { + RequestMocking.replace(mock: mock) + } else { + RequestMocking.add(mock: mock) } } } diff --git a/Tests/TraktKitTests/RouteTests.swift b/Tests/TraktKitTests/RouteTests.swift deleted file mode 100644 index 19f84d9..0000000 --- a/Tests/TraktKitTests/RouteTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// RouteTests.swift -// TraktKit -// -// Created by Maximilian Litteral on 3/3/25. -// - -import Testing -@testable import TraktKit - -extension TraktTestSuite { - @Suite - struct RouteTests { - - @Test - func buildRequest() { - let route = traktManager.shows.trending() - } - } -} diff --git a/Tests/TraktKitTests/SyncTests+Async.swift b/Tests/TraktKitTests/SyncTests+Async.swift index a9b3fe5..472a2cb 100644 --- a/Tests/TraktKitTests/SyncTests+Async.swift +++ b/Tests/TraktKitTests/SyncTests+Async.swift @@ -7,11 +7,13 @@ import Foundation import Testing +@testable import TraktKit extension TraktTestSuite { @Suite(.serialized) struct SyncTestSuite { @Test func getLastActivities() async throws { + let traktManager = await authenticatedTraktManager() try mock(.GET, "https://api.trakt.tv/sync/last_activities", result: .success(jsonData(named: "test_get_last_activity"))) let lastActivities = try await traktManager.sync() @@ -42,9 +44,200 @@ extension TraktTestSuite { #expect(dateFormatter.string(from: lastActivities.episodes.pausedAt) == "2014-11-20T06:51:30.000Z") } + // MARK: - Playback + + @Test func getMoviePlaybackProgress() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/sync/playback/movies", result: .success(jsonData(named: "test_get_playback_progress"))) + + let progress = try await traktManager.sync() + .playback(type: "movies") + .perform() + + #expect(progress.count == 2) + let firstItem = try #require(progress.first) + #expect(firstItem.id == 13) + } + + @Test func removePlaybackProgress() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.DELETE, "https://api.trakt.tv/sync/playback/13", result: .success(.init()), httpCode: StatusCodes.SuccessNoContentToReturn) + + try await traktManager.sync().removePlayback(id: 13).perform() + } + + // MARK: - Collection + + @Test func getCollectedMovies() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/sync/collection/movies?extended=min", result: .success(jsonData(named: "test_get_collection"))) + + let movies = try await traktManager.sync() + .collection(type: "movies") + .extend(.Min) + .perform() + #expect(movies.count == 2) + } + + @Test func getCollectedShows() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/sync/collection/shows?extended=min", result: .success(jsonData(named: "test_get_collection_shows"))) + + let movies = try await traktManager.sync() + .collection(type: "shows") + .extend(.Min) + .perform() + #expect(movies.count == 2) + } + + @Test func addItemsToCollection() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.POST, "https://api.trakt.tv/sync/collection", result: .success(jsonData(named: "test_add_items_to_collection"))) + + let result = try await traktManager.sync() + .addToCollection(movies: []) // For mock we don't have to pass anything in. + .perform() + #expect(result.added.movies == 1) + #expect(result.added.episodes == 12) + } + + @Test func removeItemsFromCollection() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/sync/collection/remove", result: .success(jsonData(named: "test_remove_items_from_collection"))) + + let result = try await traktManager.sync() + .removeFromCollection(movies: []) // For mock we don't have to pass anything in. + .perform() + + #expect(result.deleted.movies == 1) + #expect(result.deleted.episodes == 12) + #expect(result.notFound.episodes.count == 0) + #expect(result.notFound.movies.count == 1) + } + + // MARK: - Watched + + @Test func getWatchedMovies() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/sync/watched/movies?extended=min", result: .success(jsonData(named: "test_get_watched"))) + + let watchedMovies = try await traktManager.sync() + .watchedMovies() + .extend(.Min) + .perform() + #expect(watchedMovies.count == 2) + } + + @Test func getWatchedShows() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/sync/watched/shows?extended=min", result: .success(jsonData(named: "test_get_watched_shows"))) + + let watchedShows = try await traktManager.sync() + .watchedShows() + .extend(.Min) + .perform() + #expect(watchedShows.count == 2) + #expect(watchedShows.allSatisfy { $0.seasons != nil }) + } + + @Test func getWatchedShowsWithoutSeasons() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/sync/watched/shows?extended=noseasons", result: .success(jsonData(named: "test_get_watched_shows_noseasons"))) + + let watchedShows = try await traktManager.sync() + .watchedShows() + .extend(.noSeasons) + .perform() + #expect(watchedShows.count == 2) + #expect(watchedShows.allSatisfy { $0.seasons == nil }) + } + + // MARK: - History + + @Test func getHistory() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/sync/history/movies?extended=min", result: .success(jsonData(named: "test_get_watched_history"))) + + let history = try await traktManager.sync() + .history(type: "movies") + .extend(.Min) + .perform() + .object + + #expect(history.count == 3) + } + + @Test func addItemsToHistory() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.POST, "https://api.trakt.tv/sync/history", result: .success(jsonData(named: "test_add_items_to_watched_history"))) + + let result = try await traktManager.sync() + .addToHistory() + .perform() + + #expect(result.added.movies == 2) + #expect(result.added.episodes == 72) + } + + @Test func removeItemsFromHistory() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/sync/history/remove", result: .success(jsonData(named: "test_remove_items_from_history"))) + + let result = try await traktManager.sync() + .removeFromHistory() + .perform() + + #expect(result.deleted.movies == 2) + #expect(result.deleted.episodes == 72) + } + + // MARK: - Ratings + + @Test func getRatings() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/sync/ratings/movies/9", result: .success(jsonData(named: "test_get_ratings"))) + + let ratings = try await traktManager.sync() + .ratings(type: "movies", rating: 9) + .perform() + .object + + #expect(ratings.count == 2) + } + + @Test func addRating() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.POST, "https://api.trakt.tv/sync/ratings", result: .success(jsonData(named: "test_add_new_ratings"))) + + let result = try await traktManager.sync() + .addRatings() + .perform() + + #expect(result.added.movies == 1) + #expect(result.added.shows == 1) + #expect(result.added.seasons == 1) + #expect(result.added.episodes == 2) + #expect(result.notFound.movies.count == 1) + } + + @Test func removeRating() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/sync/ratings/remove", result: .success(jsonData(named: "test_remove_ratings"))) + + let result = try await traktManager.sync() + .removeRatings() + .perform() + + #expect(result.deleted.movies == 1) + #expect(result.deleted.shows == 1) + #expect(result.deleted.seasons == 1) + #expect(result.deleted.episodes == 2) + } + // MARK: - Watchlist @Test func getWatchlist() async throws { + let traktManager = await authenticatedTraktManager() try mock(.GET, "https://api.trakt.tv/sync/watchlist/movies?extended=min", result: .success(jsonData(named: "test_get_watchlist"))) let watchlist = try await traktManager.sync() @@ -64,6 +257,7 @@ extension TraktTestSuite { } @Test func addToWatchlist() async throws { + let traktManager = await authenticatedTraktManager() try mock(.POST, "https://api.trakt.tv/sync/watchlist", result: .success(jsonData(named: "test_add_items_to_watchlist"))) let result = try await traktManager.sync() diff --git a/Tests/TraktKitTests/SyncTests.swift b/Tests/TraktKitTests/SyncTests.swift index 2e7b921..e45be33 100644 --- a/Tests/TraktKitTests/SyncTests.swift +++ b/Tests/TraktKitTests/SyncTests.swift @@ -42,10 +42,14 @@ final class SyncTests: TraktTestCase { traktManager.getPlaybackProgress(type: .Movies) { result in if case .success(let progress) = result { XCTAssertEqual(progress.count, 2) - let first = progress.first - XCTAssertEqual(first?.progress, 10) - XCTAssertNotNil(first?.movie) - XCTAssertEqual(first?.id, 13) + do { + let first = try XCTUnwrap(progress.first) + XCTAssertEqual(first.progress, 10) + XCTAssertNotNil(first.movie) + XCTAssertEqual(first.id, 13) + } catch { + XCTFail("Failed to unwrap") + } expectation.fulfill() } } @@ -424,7 +428,7 @@ final class SyncTests: TraktTestCase { case .success: expectation.fulfill() case .error(let error): - XCTFail("Failed to add to watchlist: \(error)") + XCTFail("Failed to add to watchlist: \(String(describing: error))") } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) diff --git a/Tests/TraktKitTests/TraktManagerTests.swift b/Tests/TraktKitTests/TraktManagerTests.swift index 1e7e753..3353954 100644 --- a/Tests/TraktKitTests/TraktManagerTests.swift +++ b/Tests/TraktKitTests/TraktManagerTests.swift @@ -10,10 +10,11 @@ import Testing @testable import TraktKit extension TraktTestSuite { - @Suite(.serialized) + @Suite struct TraktManagerTests { @Test func pollForAccessTokenInvalidDeviceCode() async throws { + let traktManager = await authenticatedTraktManager() try mock(.GET, "https://api.trakt.tv/oauth/device/token", result: .success(.init()), httpCode: 404) let deviceCodeJSON: [String: Any] = [ @@ -32,32 +33,36 @@ extension TraktTestSuite { @Test func retryRequestSuccess() async throws { + let traktManager = await authenticatedTraktManager() let urlString = "https://api.trakt.tv/retry_test" + let url = try #require(URL(string: urlString)) try mock(.GET, urlString, result: .success(Data()), httpCode: 429, headers: [.retry(5)]) // Update mock in 2 seconds - Task.detached { + Task { try await Task.sleep(for: .seconds(2)) try mock(.GET, urlString, result: .success(Data()), httpCode: 201, replace: true) } - let request = URLRequest(url: URL(string: urlString)!) + let request = URLRequest(url: url) let (_, response) = try await traktManager.fetchData(request: request, retryLimit: 2) let httpHeader = try #require(response as? HTTPURLResponse) #expect(httpHeader.statusCode == 201) } @Test func retryRequestFailed() async throws { + let traktManager = await authenticatedTraktManager() let urlString = "https://api.trakt.tv/retry_test_failed" + let url = try #require(URL(string: urlString)) try mock(.GET, urlString, result: .success(Data()), httpCode: 429, headers: [.retry(3)]) // Update mock in 2 seconds - Task.detached { + Task { try await Task.sleep(for: .seconds(1)) try mock(.GET, urlString, result: .success(Data()), httpCode: 405, replace: true) } - let request = URLRequest(url: URL(string: urlString)!) + let request = URLRequest(url: url) await #expect(throws: TraktManager.TraktError.noMethodFound, performing: { try await traktManager.fetchData(request: request, retryLimit: 2) @@ -65,8 +70,11 @@ extension TraktTestSuite { } @Test func fetchPaginatedResults() async throws { + let traktManager = await authenticatedTraktManager() + + let path = "shows/paginated-results/trending" for p in 1...2 { - let urlString = "https://api.trakt.tv/shows/trending?page=\(p)&limit=10" + let urlString = "https://api.trakt.tv/\(path)?page=\(p)&limit=10" let json: [[String: Any]] = (0..<10).map { i in [ "watchers": Int.random(in: 0...100), @@ -81,17 +89,23 @@ extension TraktTestSuite { ] } let data = try JSONSerialization.data(withJSONObject: json) - try mock(.GET, urlString, result: .success(data), headers: [.page(p), .pageCount(2)]) + try mock(.GET, urlString, result: .success(data), headers: [.page(p), .pageCount(2)], replace: true) } - let trending = try await traktManager.shows.trending().limit(10).fetchAllPages() + // Note: Creating a mock route to test pagination logic due to running into conflicts with other tests setting up mock data for the same endpoint. + let mockTrendingRoute: Route> = Route(path: path, method: .GET, traktManager: traktManager) + + let trending = try await mockTrendingRoute.limit(10).fetchAllPages() #expect(trending.count == 20) } @Test func streamPaginatedResults() async throws { + let traktManager = await authenticatedTraktManager() + var watchersByPage = [[Int]]() + let path = "shows/paged-results/trending" for p in 1...2 { - let urlString = "https://api.trakt.tv/shows/trending?page=\(p)&limit=10" + let urlString = "https://api.trakt.tv/\(path)?page=\(p)&limit=10" let json: [[String: Any]] = (0..<10).map { i in [ "watchers": Int.random(in: 0...100), @@ -107,10 +121,13 @@ extension TraktTestSuite { } watchersByPage.append(json.map { $0["watchers"] as? Int ?? 0 }) let data = try JSONSerialization.data(withJSONObject: json) - try mock(.GET, urlString, result: .success(data), headers: [.page(p), .pageCount(2)]) + try mock(.GET, urlString, result: .success(data), headers: [.page(p), .pageCount(2)], replace: true) } - for try await page in traktManager.shows.trending().limit(10).pagedResults() { + // Note: Creating a mock route to test pagination logic due to running into conflicts with other tests setting up mock data for the same endpoint. + let mockTrendingRoute: Route> = Route(path: path, method: .GET, traktManager: traktManager) + + for try await page in mockTrendingRoute.limit(10).pagedResults() { let watchersForPage = watchersByPage.removeFirst() #expect(page.map { $0.watchers } == watchersForPage) #expect(page.count == 10) diff --git a/Tests/TraktKitTests/TraktTestCase.swift b/Tests/TraktKitTests/TraktTestCase.swift index 19637f6..31a7a06 100644 --- a/Tests/TraktKitTests/TraktTestCase.swift +++ b/Tests/TraktKitTests/TraktTestCase.swift @@ -9,13 +9,19 @@ import XCTest @testable import TraktKit class TraktTestCase: XCTestCase { - lazy var traktManager = TraktManager(session: URLSession.mockedResponsesOnly, clientId: "", clientSecret: "", redirectURI: "") + lazy var traktManager = TraktManager( + session: URLSession.mockedResponsesOnly, + clientId: "", + clientSecret: "", + redirectURI: "", + authStorage: TraktMockAuthStorage(accessToken: "", refreshToken: "", expirationDate: .distantFuture) + ) - override func setUp() { + override func setUp() async throws { + try await traktManager.refreshCurrentAuthState() } override func tearDown() { - super.tearDown() RequestMocking.removeAllMocks() } diff --git a/Tests/TraktKitTests/TraktTestSuite.swift b/Tests/TraktKitTests/TraktTestSuite.swift index 7ef3e40..d9610d6 100644 --- a/Tests/TraktKitTests/TraktTestSuite.swift +++ b/Tests/TraktKitTests/TraktTestSuite.swift @@ -9,14 +9,22 @@ import Testing import Foundation @testable import TraktKit -@Suite +@Suite(.serialized) final class TraktTestSuite { - static let traktManager = TraktManager(session: URLSession.mockedResponsesOnly, clientId: "", clientSecret: "", redirectURI: "") - deinit { RequestMocking.removeAllMocks() } + static func authenticatedTraktManager() async -> TraktManager { + await TraktManager( + session: URLSession.mockedResponsesOnly, + clientId: "", + clientSecret: "", + redirectURI: "", + authStorage: TraktMockAuthStorage(accessToken: "", refreshToken: "", expirationDate: .distantFuture) + ) + } + static func mock(_ method: TraktKit.Method, _ urlString: String, result: Result, httpCode: Int? = nil, headers: [HTTPHeader] = [.contentType, .apiVersion, .apiKey("")], replace: Bool = false) throws { let mock = try RequestMocking.MockedResponse(urlString: urlString, result: result, httpCode: httpCode ?? method.expectedResult, headers: headers) if replace { From 5814b2669c194d5a38d2959d070e8f7f3ca609d1 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 8 Mar 2025 07:01:21 -0500 Subject: [PATCH 23/38] Code cleanup Removing uses of option and force `try`, replacing uses of `decode` with `decodeIfPresent`, Removing redundant or unused code. --- Common/Models/Movies/TraktMovie.swift | 4 +- Common/Models/Shows/TraktShow.swift | 4 +- Common/Models/Sync/TraktCollectedItem.swift | 2 +- .../CompletionHandlerEndpoints/Checkin.swift | 2 +- .../CompletionHandlerEndpoints/Comments.swift | 6 +-- .../CompletionHandlerEndpoints/Scrobble.swift | 9 ++-- .../CompletionHandlerEndpoints/Sync.swift | 16 +++---- .../CompletionHandlerEndpoints/Users.swift | 8 ++-- Common/Wrapper/CompletionHandlers.swift | 43 ++++++++++++++++++- Common/Wrapper/Enums.swift | 25 ----------- Common/Wrapper/TraktManager.swift | 31 +------------ Tests/TraktKitTests/CommentTests.swift | 4 +- Tests/TraktKitTests/UserTests.swift | 17 ++++---- 13 files changed, 79 insertions(+), 92 deletions(-) diff --git a/Common/Models/Movies/TraktMovie.swift b/Common/Models/Movies/TraktMovie.swift index ee88b9d..6593837 100644 --- a/Common/Models/Movies/TraktMovie.swift +++ b/Common/Models/Movies/TraktMovie.swift @@ -61,8 +61,8 @@ public struct TraktMovie: TraktObject { released = try container.decodeIfPresent(Date.self, forKey: CodingKeys.released) runtime = try container.decodeIfPresent(Int.self, forKey: CodingKeys.runtime) certification = try container.decodeIfPresent(String.self, forKey: .certification) - trailer = try? container.decode(URL.self, forKey: .trailer) - homepage = try? container.decode(URL.self, forKey: .homepage) + trailer = try container.decodeIfPresent(URL.self, forKey: .trailer) + homepage = try container.decodeIfPresent(URL.self, forKey: .homepage) rating = try container.decodeIfPresent(Double.self, forKey: .rating) votes = try container.decodeIfPresent(Int.self, forKey: .votes) updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) diff --git a/Common/Models/Shows/TraktShow.swift b/Common/Models/Shows/TraktShow.swift index d66f379..3356b85 100644 --- a/Common/Models/Shows/TraktShow.swift +++ b/Common/Models/Shows/TraktShow.swift @@ -83,8 +83,8 @@ public struct TraktShow: TraktObject { certification = try container.decodeIfPresent(String.self, forKey: .certification) network = try container.decodeIfPresent(String.self, forKey: .network) country = try container.decodeIfPresent(String.self, forKey: .country) - trailer = try? container.decode(URL.self, forKey: .trailer) - homepage = try? container.decode(URL.self, forKey: .homepage) + trailer = try container.decodeIfPresent(URL.self, forKey: .trailer) + homepage = try container.decodeIfPresent(URL.self, forKey: .homepage) status = try container.decodeIfPresent(String.self, forKey: .status) rating = try container.decodeIfPresent(Double.self, forKey: .rating) votes = try container.decodeIfPresent(Int.self, forKey: .votes) diff --git a/Common/Models/Sync/TraktCollectedItem.swift b/Common/Models/Sync/TraktCollectedItem.swift index ae8f257..e9b2c92 100644 --- a/Common/Models/Sync/TraktCollectedItem.swift +++ b/Common/Models/Sync/TraktCollectedItem.swift @@ -89,7 +89,7 @@ public struct TraktCollectedItem: TraktObject { hdr = try? container.decodeIfPresent(HDR.self, forKey: .hdr) audio = try? container.decodeIfPresent(Audio.self, forKey: .audio) audioChannels = try? container.decodeIfPresent(AudioChannels.self, forKey: .audioChannels) - is3D = try container.decodeIfPresent(Bool.self, forKey: .is3D) ?? false + is3D = (try? container.decodeIfPresent(Bool.self, forKey: .is3D)) ?? false } } diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Checkin.swift b/Common/Wrapper/CompletionHandlerEndpoints/Checkin.swift index f440f94..a070e89 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Checkin.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Checkin.swift @@ -16,7 +16,7 @@ extension TraktManager { */ @discardableResult public func checkIn(_ body: TraktCheckinBody, completionHandler: @escaping checkinCompletionHandler) -> URLSessionDataTask? { - guard let request = post("checkin", body: body) else { return nil } + guard let request = try? post("checkin", body: body) else { return nil } return performRequest(request: request, completion: completionHandler) } diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Comments.swift b/Common/Wrapper/CompletionHandlerEndpoints/Comments.swift index 85723ce..e2764d5 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Comments.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Comments.swift @@ -20,7 +20,7 @@ extension TraktManager { @discardableResult public func postComment(movie: SyncId? = nil, show: SyncId? = nil, season: SyncId? = nil, episode: SyncId? = nil, list: SyncId? = nil, comment: String, isSpoiler spoiler: Bool? = nil, completion: @escaping SuccessCompletionHandler) throws -> URLSessionDataTask? { let body = TraktCommentBody(movie: movie, show: show, season: season, episode: episode, list: list, comment: comment, spoiler: spoiler) - guard let request = post("comments", body: body) else { return nil } + let request = try post("comments", body: body) return performRequest(request: request, completion: completion) } @@ -88,9 +88,9 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func postReply(commentID id: T, comment: String, isSpoiler spoiler: Bool? = nil, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { + public func postReply(commentID id: T, comment: String, isSpoiler spoiler: Bool? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktCommentBody(comment: comment, spoiler: spoiler) - guard let request = post("comments/\(id)/replies", body: body) else { return nil } + let request = try post("comments/\(id)/replies", body: body) return performRequest(request: request, completion: completion) } diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Scrobble.swift b/Common/Wrapper/CompletionHandlerEndpoints/Scrobble.swift index d52205c..55b01e1 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Scrobble.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Scrobble.swift @@ -21,7 +21,7 @@ extension TraktManager { */ @discardableResult public func scrobbleStart(_ scrobble: TraktScrobble, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { - return try perform("start", scrobble: scrobble, completion: completion) + try perform("start", scrobble: scrobble, completion: completion) } // MARK: - Pause @@ -33,7 +33,7 @@ extension TraktManager { */ @discardableResult public func scrobblePause(_ scrobble: TraktScrobble, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { - return try perform("pause", scrobble: scrobble, completion: completion) + try perform("pause", scrobble: scrobble, completion: completion) } // MARK: - Stop @@ -49,15 +49,14 @@ extension TraktManager { */ @discardableResult public func scrobbleStop(_ scrobble: TraktScrobble, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { - return try perform("stop", scrobble: scrobble, completion: completion) + try perform("stop", scrobble: scrobble, completion: completion) } // MARK: - Private @discardableResult func perform(_ scrobbleAction: String, scrobble: TraktScrobble, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { - // Request - guard let request = post("scrobble/\(scrobbleAction)", body: scrobble) else { return nil } + let request = try post("scrobble/\(scrobbleAction)", body: scrobble) return performRequest(request: request, completion: completion) } } diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Sync.swift b/Common/Wrapper/CompletionHandlerEndpoints/Sync.swift index 2316d5e..19b9108 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Sync.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Sync.swift @@ -114,7 +114,7 @@ extension TraktManager { @discardableResult public func addToCollection(movies: [CollectionId]? = nil, shows: [CollectionId]? = nil, seasons: [CollectionId]? = nil, episodes: [CollectionId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes) - guard let request = post("sync/collection", body: body) else { return nil } + let request = try post("sync/collection", body: body) return performRequest(request: request, completion: completion) } @@ -133,7 +133,7 @@ extension TraktManager { @discardableResult public func removeFromCollection(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes) - guard let request = post("sync/collection/remove", body: body) else { return nil } + let request = try post("sync/collection/remove", body: body) return performRequest(request: request, completion: completion) } @@ -257,7 +257,7 @@ extension TraktManager { @discardableResult public func addToHistory(movies: [AddToHistoryId]? = nil, shows: [AddToHistoryId]? = nil, seasons: [AddToHistoryId]? = nil, episodes: [AddToHistoryId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes) - guard let request = post("sync/history", body: body) else { return nil } + let request = try post("sync/history", body: body) return performRequest(request: request, completion: completion) } @@ -279,7 +279,7 @@ extension TraktManager { @discardableResult public func removeFromHistory(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, historyIDs: [Int]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes, ids: historyIDs) - guard let request = post("sync/history/remove", body: body) else { return nil } + let request = try post("sync/history/remove", body: body) return performRequest(request: request, completion: completion) } @@ -323,7 +323,7 @@ extension TraktManager { @discardableResult public func addRatings(movies: [RatingId]? = nil, shows: [RatingId]? = nil, seasons: [RatingId]? = nil, episodes: [RatingId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes) - guard let request = post("sync/ratings", body: body) else { return nil } + let request = try post("sync/ratings", body: body) return performRequest(request: request, completion: completion) } @@ -340,7 +340,7 @@ extension TraktManager { @discardableResult public func removeRatings(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes) - guard let request = post("sync/ratings/remove", body: body) else { return nil } + let request = try post("sync/ratings/remove", body: body) return performRequest(request: request, completion: completion) } @@ -392,7 +392,7 @@ extension TraktManager { @discardableResult public func addToWatchlist(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes) - guard let request = post("sync/watchlist", body: body) else { completion(.error(error: nil)); return nil } + let request = try post("sync/watchlist", body: body) return performRequest(request: request, completion: completion) } @@ -411,7 +411,7 @@ extension TraktManager { @discardableResult public func removeFromWatchlist(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes) - guard let request = post("sync/watchlist/remove", body: body) else { completion(.error(error: nil)); return nil } + let request = try post("sync/watchlist/remove", body: body) return performRequest(request: request, completion: completion) } } diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Users.swift b/Common/Wrapper/CompletionHandlerEndpoints/Users.swift index 8b5e815..8bd32b1 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Users.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Users.swift @@ -125,7 +125,7 @@ extension TraktManager { @discardableResult public func hide(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, from section: String, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons) - guard let request = post("users/hidden/\(section)", body: body) else { return nil } + let request = try post("users/hidden/\(section)", body: body) return performRequest(request: request, completion: completion) } @@ -137,7 +137,7 @@ extension TraktManager { @discardableResult public func unhide(movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, from section: String, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons) - guard let request = post("users/hidden/\(section)/remove", body: body) else { return nil } + let request = try post("users/hidden/\(section)/remove", body: body) return performRequest(request: request, completion: completion) } @@ -407,7 +407,7 @@ extension TraktManager { @discardableResult public func addItemToCustomList(username: String = "me", listID: T, movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, people: [SyncId]? = nil, completion: @escaping AddListItemCompletion) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes, people: people) - guard let request = post("users/\(username)/lists/\(listID)/items", body: body) else { return nil } + let request = try post("users/\(username)/lists/\(listID)/items", body: body) return performRequest(request: request, completion: completion) } @@ -427,7 +427,7 @@ extension TraktManager { @discardableResult public func removeItemFromCustomList(username: String = "me", listID: T, movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, people: [SyncId]? = nil, completion: @escaping RemoveListItemCompletion) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes, people: people) - guard let request = post("users/\(username)/lists/\(listID)/items/remove", body: body) else { return nil } + let request = try post("users/\(username)/lists/\(listID)/items/remove", body: body) return performRequest(request: request, completion: completion) } diff --git a/Common/Wrapper/CompletionHandlers.swift b/Common/Wrapper/CompletionHandlers.swift index f09f170..f97e27b 100644 --- a/Common/Wrapper/CompletionHandlers.swift +++ b/Common/Wrapper/CompletionHandlers.swift @@ -57,7 +57,7 @@ extension TraktManager { case error(error: Error?) } - public enum TraktError: Error, Equatable { + public enum TraktError: LocalizedError, Equatable { /// 204. Some methods will succeed but not return any content. The network manager doesn't handle this well at the moment as it wants to decode the data when it is empty. Instead I'll throw this error so that it can be ignored for now. case noContent @@ -95,6 +95,47 @@ extension TraktManager { case cloudflareError /// Full url response case unhandled(URLResponse) + + public var errorDescription: String? { + switch self { + case .noContent: + nil + case .badRequest: + "Request could not be parsed." + case .unauthorized: + "Unauthorized. Please sign in with Trakt." + case .forbidden: + "Forbidden. Invalid API key or unapproved app." + case .noRecordFound: + "No record found." + case .noMethodFound: + "Method not found." + case .resourceAlreadyCreated: + "Resource has already been created." + case .preconditionFailed: + "Invalid content type." + case .accountLimitExceeded: + "The number of Trakt lists or list items has been exceeded. Please see Trakt.tv for account limits and support." + case .unprocessableEntity: + "Invalid entity." + case .accountLocked: + "Trakt.tv has indicated that this account is locked. Please contact Trakt support to unlock your account." + case .vipOnly: + "This feature is VIP only with Trakt. Please see Trakt.tv for more information." + case .retry: + nil + case .rateLimitExceeded: + "Rate Limit Exceeded. Please try again in a minute." + case .serverError, .serverOverloaded, .cloudflareError: + "Trakt.tv is down. Please try again later." + case .unhandled(let urlResponse): + if let httpResponse = urlResponse as? HTTPURLResponse { + "Unhandled response. Status code \(httpResponse.statusCode)" + } else { + "Unhandled response. \(urlResponse.description)" + } + } + } } // MARK: - Completion handlers diff --git a/Common/Wrapper/Enums.swift b/Common/Wrapper/Enums.swift index 5760ee2..ae93b83 100644 --- a/Common/Wrapper/Enums.swift +++ b/Common/Wrapper/Enums.swift @@ -71,31 +71,6 @@ public struct StatusCodes: Sendable { public static let CloudflareError2 = 521 /// Service Unavailable - Cloudflare error public static let CloudflareError3 = 522 - - static func message(for status: Int) -> String? { - switch status { - case Unauthorized: - return "App not authorized. Please sign in again." - case Forbidden: - return "Invalid API Key" - case NotFound: - return "API not found" - case AccountLimitExceeded: - return "The number of Trakt lists or list items has been exceeded. Please see Trakt.tv for account limits and support." - case acountLocked: - return "Trakt.tv has indicated that this account is locked. Please contact Trakt support to unlock your account." - case vipOnly: - return "This feature is VIP only with Trakt. Please see Trakt.tv for more information." - case RateLimitExceeded: - return "Rate Limit Exceeded. Please try again in a minute." - case ServerError..(_ path: String, query: [String: String] = [:], body: Body) -> URLRequest? { - let urlString = "https://\(apiHost)/" + path - guard var components = URLComponents(string: urlString) else { return nil } - if query.isEmpty == false { - var queryItems: [URLQueryItem] = [] - for (key, value) in query { - queryItems.append(URLQueryItem(name: key, value: value)) - } - components.queryItems = queryItems - } - - guard let url = components.url else { return nil } - var request = URLRequest(url: url) - request.httpMethod = Method.POST.rawValue - - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - request.addValue("2", forHTTPHeaderField: "trakt-api-version") - request.addValue(clientId, forHTTPHeaderField: "trakt-api-key") - - if let accessToken { - request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - } - - do { - request.httpBody = try Self.jsonEncoder.encode(body) - } catch { - return nil - } - return request + func post(_ path: String, query: [String: String] = [:], body: Body) throws -> URLRequest { + try mutableRequest(forPath: path, withQuery: query, isAuthorized: true, withHTTPMethod: .POST, body: body) } // MARK: - Authentication diff --git a/Tests/TraktKitTests/CommentTests.swift b/Tests/TraktKitTests/CommentTests.swift index 61360fe..b5a7339 100644 --- a/Tests/TraktKitTests/CommentTests.swift +++ b/Tests/TraktKitTests/CommentTests.swift @@ -65,7 +65,7 @@ final class CommentTests: TraktTestCase { let newComment = "Agreed, this show is awesome. AMC in general has awesome shows and I can't wait to see what they come up with next." let expectation = XCTestExpectation(description: "Update a comment") - try! traktManager.updateComment(commentID: "417", newComment: newComment) { result in + try traktManager.updateComment(commentID: "417", newComment: newComment) { result in if case .success(let comment) = result { XCTAssertEqual(comment.comment, newComment) expectation.fulfill() @@ -128,7 +128,7 @@ final class CommentTests: TraktTestCase { let reply = "Couldn't agree more with your review!" let expectation = XCTestExpectation(description: "Get replies for comment") - traktManager.postReply(commentID: "417", comment: reply) { result in + try traktManager.postReply(commentID: "417", comment: reply) { result in if case .success(let postedReply) = result { XCTAssertEqual(postedReply.comment, reply) expectation.fulfill() diff --git a/Tests/TraktKitTests/UserTests.swift b/Tests/TraktKitTests/UserTests.swift index 344133a..cd2dd0b 100644 --- a/Tests/TraktKitTests/UserTests.swift +++ b/Tests/TraktKitTests/UserTests.swift @@ -135,7 +135,7 @@ final class UserTests: TraktTestCase { try mock(.POST, "https://api.trakt.tv/users/hidden/calendar", result: .success(jsonData(named: "test_add_hidden_item"))) let expectation = XCTestExpectation(description: "Add hidden item") - try! traktManager.hide(from: HiddenItemSection.calendar) { result in + try traktManager.hide(from: HiddenItemSection.calendar) { result in if case .success(let result) = result { XCTAssertEqual(result.added.movies, 1) XCTAssertEqual(result.added.shows, 2) @@ -159,7 +159,7 @@ final class UserTests: TraktTestCase { try mock(.GET, "https://api.trakt.tv/users/hidden/calendar/remove", result: .success(jsonData(named: "test_post_remove_hidden_items"))) let expectation = XCTestExpectation(description: "Remove hidden items") - try! traktManager.unhide(from: HiddenItemSection.calendar) { result in + try traktManager.unhide(from: HiddenItemSection.calendar) { result in if case .success(let result) = result { XCTAssertEqual(result.deleted.movies, 1) XCTAssertEqual(result.deleted.shows, 2) @@ -396,7 +396,7 @@ final class UserTests: TraktTestCase { let listName = "Star Wars in machete order" let listDescription = "Next time you want to introduce someone to Star Wars for the first time, watch the films with them in this order: IV, V, II, III, VI." - try! traktManager.createCustomList(listName: "listName", listDescription: listDescription) { result in + try traktManager.createCustomList(listName: "listName", listDescription: listDescription) { result in if case .success(let newList) = result { XCTAssertEqual(newList.name, listName) XCTAssertEqual(newList.description, listDescription) @@ -440,7 +440,7 @@ final class UserTests: TraktTestCase { try mock(.GET, "https://api.trakt.tv/users/me/lists/star-wars-in-machete-order", result: .success(jsonData(named: "test_update_custom_list"))) let expectation = XCTestExpectation(description: "User update custom list") - try! traktManager.updateCustomList(listID: "star-wars-in-machete-order", listName: "Star Wars in NEW machete order", privacy: "private", displayNumbers: false) { result in + try traktManager.updateCustomList(listID: "star-wars-in-machete-order", listName: "Star Wars in NEW machete order", privacy: "private", displayNumbers: false) { result in if case .success(let list) = result { XCTAssertEqual(list.name, "Star Wars in NEW machete order") XCTAssertEqual(list.privacy, .private) @@ -529,7 +529,7 @@ final class UserTests: TraktTestCase { XCTAssertEqual(listItems.count, 5) expectation.fulfill() case .error(let error): - XCTFail("Failed to get items on custom list: \(error)") + XCTFail("Failed to get items on custom list: \(String(describing: error))") } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) @@ -549,7 +549,7 @@ final class UserTests: TraktTestCase { try mock(.POST, "https://api.trakt.tv/users/sean/lists/star-wars-in-machete-order/items", result: .success(jsonData(named: "test_add_item_to_custom_list"))) let expectation = XCTestExpectation(description: "Add item to custom list") - try! traktManager.addItemToCustomList(username: "sean", listID: "star-wars-in-machete-order", movies: [], shows: [], episodes: []) { result in + try traktManager.addItemToCustomList(username: "sean", listID: "star-wars-in-machete-order", movies: [], shows: [], episodes: []) { result in if case .success(let response) = result { XCTAssertEqual(response.added.seasons, 1) XCTAssertEqual(response.added.people, 1) @@ -583,7 +583,7 @@ final class UserTests: TraktTestCase { try mock(.DELETE, "https://api.trakt.tv/users/sean/lists/star-wars-in-machete-order/items/remove", result: .success(jsonData(named: "test_remove_item_from_custom_list"))) let expectation = XCTestExpectation(description: "Remove item to custom list") - try! traktManager.removeItemFromCustomList(username: "sean", listID: "star-wars-in-machete-order", movies: [], shows: [], episodes: []) { result in + try traktManager.removeItemFromCustomList(username: "sean", listID: "star-wars-in-machete-order", movies: [], shows: [], episodes: []) { result in if case .success(let response) = result { XCTAssertEqual(response.deleted.seasons, 1) XCTAssertEqual(response.deleted.people, 1) @@ -808,7 +808,7 @@ final class UserTests: TraktTestCase { XCTAssertEqual(watchlist.count, 5) expectation.fulfill() case .error(let error): - XCTFail("Failed to get user watchlist: \(error)") + XCTFail("Failed to get user watchlist: \(String(describing: error))") } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) @@ -975,5 +975,4 @@ final class UserTests: TraktTestCase { break } } - } From 8859d6b9e97ddc665f18d178d7b905bbe414ad56 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 8 Mar 2025 07:18:02 -0500 Subject: [PATCH 24/38] Replace ObjectsCompletionHandler with ObjectCompletionHandler<[T]> I am working on reducing the number of `performRequest` variations so that there is less room for error, and for the request handling to become outdated for a specific type. I realized that `ObjectsCompletionHandler` was redundant when I could use `ObjectCompletionHandler<[T]>` --- .../Calendars.swift | 20 ++--- .../CompletionHandlerEndpoints/Comments.swift | 10 +-- .../CompletionHandlerEndpoints/Episodes.swift | 4 +- .../CompletionHandlerEndpoints/Genres.swift | 2 +- .../Languages.swift | 2 +- .../CompletionHandlerEndpoints/Lists.swift | 4 +- .../CompletionHandlerEndpoints/Movies.swift | 6 +- .../CompletionHandlerEndpoints/People.swift | 2 +- .../Recommendations.swift | 6 +- .../CompletionHandlerEndpoints/Seasons.swift | 2 +- .../SharedFunctions.swift | 6 +- .../CompletionHandlerEndpoints/Shows.swift | 8 +- .../CompletionHandlerEndpoints/Sync.swift | 2 +- .../CompletionHandlerEndpoints/Users.swift | 6 +- Common/Wrapper/CompletionHandlers.swift | 87 ++++--------------- 15 files changed, 58 insertions(+), 109 deletions(-) diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Calendars.swift b/Common/Wrapper/CompletionHandlerEndpoints/Calendars.swift index 8c973e4..b03e10d 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Calendars.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Calendars.swift @@ -19,7 +19,7 @@ extension TraktManager { - parameter days: Number of days to display. Example: `7`. */ @discardableResult - public func myShows(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func myShows(startDateString dateString: String, days: Int, completion: @escaping ObjectCompletionHandler<[CalendarShow]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/my/shows/\(dateString)/\(days)", withQuery: [:], isAuthorized: true, @@ -37,7 +37,7 @@ extension TraktManager { - parameter days: Number of days to display. Example: `7`. */ @discardableResult - public func myNewShows(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func myNewShows(startDateString dateString: String, days: Int, completion: @escaping ObjectCompletionHandler<[CalendarShow]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/my/shows/new/\(dateString)/\(days)", withQuery: [:], isAuthorized: true, @@ -55,7 +55,7 @@ extension TraktManager { - parameter days: Number of days to display. Example: `7`. */ @discardableResult - public func mySeasonPremieres(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func mySeasonPremieres(startDateString dateString: String, days: Int, completion: @escaping ObjectCompletionHandler<[CalendarShow]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/my/shows/premieres/\(dateString)/\(days)", withQuery: [:], isAuthorized: true, @@ -73,7 +73,7 @@ extension TraktManager { - parameter days: Number of days to display. Example: `7`. */ @discardableResult - public func myMovies(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func myMovies(startDateString dateString: String, days: Int, completion: @escaping ObjectCompletionHandler<[CalendarMovie]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/my/movies/\(dateString)/\(days)", withQuery: [:], isAuthorized: true, @@ -90,7 +90,7 @@ extension TraktManager { 🎚 Filters */ @discardableResult - public func myDVDReleases(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func myDVDReleases(startDateString dateString: String, days: Int, completion: @escaping ObjectCompletionHandler<[CalendarMovie]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/my/dvd/\(dateString)/\(days)", withQuery: [:], isAuthorized: true, @@ -106,7 +106,7 @@ extension TraktManager { - parameter days: Number of days to display. Example: `7`. */ @discardableResult - public func allShows(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func allShows(startDateString dateString: String, days: Int, completion: @escaping ObjectCompletionHandler<[CalendarShow]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/all/shows/\(dateString)/\(days)", withQuery: [:], isAuthorized: false, @@ -122,7 +122,7 @@ extension TraktManager { - parameter days: Number of days to display. Example: `7`. */ @discardableResult - public func allNewShows(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func allNewShows(startDateString dateString: String, days: Int, completion: @escaping ObjectCompletionHandler<[CalendarShow]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/all/shows/new/\(dateString)/\(days)", withQuery: [:], isAuthorized: false, @@ -138,7 +138,7 @@ extension TraktManager { - parameter days: Number of days to display. Example: `7`. */ @discardableResult - public func allSeasonPremieres(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func allSeasonPremieres(startDateString dateString: String, days: Int, completion: @escaping ObjectCompletionHandler<[CalendarShow]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/all/shows/premieres/\(dateString)/\(days)", withQuery: [:], isAuthorized: false, @@ -154,7 +154,7 @@ extension TraktManager { - parameter days: Number of days to display. Example: `7`. */ @discardableResult - public func allMovies(startDateString dateString: String, days: Int, extended: [ExtendedType] = [.Min], filters: [Filter]? = nil, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func allMovies(startDateString dateString: String, days: Int, extended: [ExtendedType] = [.Min], filters: [Filter]? = nil, completion: @escaping ObjectCompletionHandler<[CalendarMovie]>) -> URLSessionDataTask? { var query: [String: String] = ["extended": extended.queryString()] @@ -176,7 +176,7 @@ extension TraktManager { /** */ @discardableResult - public func allDVD(startDateString dateString: String, days: Int, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func allDVD(startDateString dateString: String, days: Int, completion: @escaping ObjectCompletionHandler<[CalendarMovie]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "calendars/all/dvd/\(dateString)/\(days)", withQuery: [:], isAuthorized: false, diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Comments.swift b/Common/Wrapper/CompletionHandlerEndpoints/Comments.swift index e2764d5..0f440ff 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Comments.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Comments.swift @@ -74,7 +74,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getReplies(commentID id: T, completion: @escaping ObjectsCompletionHandler) throws -> URLSessionDataTask? { + public func getReplies(commentID id: T, completion: @escaping ObjectCompletionHandler<[Comment]>) throws -> URLSessionDataTask? { let request = try mutableRequest(forPath: "comments/\(id)/replies", withQuery: [:], isAuthorized: false, @@ -118,7 +118,7 @@ extension TraktManager { ✨ Extended Info */ @discardableResult - public func getUsersWhoLikedComment(commentID id: T, completion: @escaping ObjectsCompletionHandler) throws -> URLSessionDataTask? { + public func getUsersWhoLikedComment(commentID id: T, completion: @escaping ObjectCompletionHandler<[TraktCommentLikedUser]>) throws -> URLSessionDataTask? { let request = try mutableRequest(forPath: "comments/\(id)/likes", withQuery: [:], isAuthorized: true, @@ -165,7 +165,7 @@ extension TraktManager { ✨ Extended */ @discardableResult - public func getTrendingComments(commentType: CommentType, mediaType: Type2, includeReplies: Bool, completion: @escaping ObjectsCompletionHandler) throws -> URLSessionDataTask? { + public func getTrendingComments(commentType: CommentType, mediaType: Type2, includeReplies: Bool, completion: @escaping ObjectCompletionHandler<[TraktTrendingComment]>) throws -> URLSessionDataTask? { let request = try mutableRequest(forPath: "comments/trending/\(commentType.rawValue)/\(mediaType.rawValue)", withQuery: ["include_replies": "\(includeReplies)"], isAuthorized: false, @@ -182,7 +182,7 @@ extension TraktManager { ✨ Extended */ @discardableResult - public func getRecentComments(commentType: CommentType, mediaType: Type2, includeReplies: Bool, completion: @escaping ObjectsCompletionHandler) throws -> URLSessionDataTask? { + public func getRecentComments(commentType: CommentType, mediaType: Type2, includeReplies: Bool, completion: @escaping ObjectCompletionHandler<[TraktTrendingComment]>) throws -> URLSessionDataTask? { let request = try mutableRequest(forPath: "comments/recent/\(commentType.rawValue)/\(mediaType.rawValue)", withQuery: ["include_replies": "\(includeReplies)"], isAuthorized: false, @@ -199,7 +199,7 @@ extension TraktManager { ✨ Extended */ @discardableResult - public func getRecentlyUpdatedComments(commentType: CommentType, mediaType: Type2, includeReplies: Bool, completion: @escaping ObjectsCompletionHandler) throws -> URLSessionDataTask? { + public func getRecentlyUpdatedComments(commentType: CommentType, mediaType: Type2, includeReplies: Bool, completion: @escaping ObjectCompletionHandler<[TraktTrendingComment]>) throws -> URLSessionDataTask? { let request = try mutableRequest(forPath: "comments/updates/\(commentType.rawValue)/\(mediaType.rawValue)", withQuery: ["include_replies": "\(includeReplies)"], isAuthorized: false, diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Episodes.swift b/Common/Wrapper/CompletionHandlerEndpoints/Episodes.swift index a9157c8..50836cb 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Episodes.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Episodes.swift @@ -38,7 +38,7 @@ extension TraktManager { - parameter language: 2 character language code */ @discardableResult - public func getEpisodeTranslations(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, language: String? = nil, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getEpisodeTranslations(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, language: String? = nil, completion: @escaping ObjectCompletionHandler<[TraktEpisodeTranslation]>) -> URLSessionDataTask? { var path = "shows/\(id)/seasons/\(season)/episodes/\(episode)/translations" if let language = language { path += "/\(language)" @@ -150,7 +150,7 @@ extension TraktManager { Returns all users watching this episode right now. */ @discardableResult - public func getUsersWatchingEpisode(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getUsersWatchingEpisode(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, completion: @escaping ObjectCompletionHandler<[User]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)/watching", withQuery: [:], isAuthorized: false, diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Genres.swift b/Common/Wrapper/CompletionHandlerEndpoints/Genres.swift index 051e340..6ba8c72 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Genres.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Genres.swift @@ -14,7 +14,7 @@ extension TraktManager { Get a list of all genres, including names and slugs. */ @discardableResult - public func listGenres(type: WatchedType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func listGenres(type: WatchedType, completion: @escaping ObjectCompletionHandler<[Genres]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "genres/\(type)", withQuery: [:], isAuthorized: false, diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Languages.swift b/Common/Wrapper/CompletionHandlerEndpoints/Languages.swift index a034dc7..16b3b3d 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Languages.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Languages.swift @@ -14,7 +14,7 @@ extension TraktManager { Get a list of all genres, including names and slugs. */ @discardableResult - public func listLanguages(type: WatchedType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func listLanguages(type: WatchedType, completion: @escaping ObjectCompletionHandler<[Languages]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "languages/\(type)", withQuery: [:], isAuthorized: false, diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Lists.swift b/Common/Wrapper/CompletionHandlerEndpoints/Lists.swift index 73a5dab..131ae24 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Lists.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Lists.swift @@ -16,7 +16,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getTrendingLists(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getTrendingLists(completion: @escaping ObjectCompletionHandler<[TraktTrendingList]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "lists/trending", withQuery: [:], @@ -35,7 +35,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getPopularLists(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getPopularLists(completion: @escaping ObjectCompletionHandler<[TraktTrendingList]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "lists/popular", withQuery: [:], diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Movies.swift b/Common/Wrapper/CompletionHandlerEndpoints/Movies.swift index abf73e4..de5bd16 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Movies.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Movies.swift @@ -125,7 +125,7 @@ extension TraktManager { Returns all title aliases for a movie. Includes country where name is different. */ @discardableResult - public func getMovieAliases(movieID id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getMovieAliases(movieID id: T, completion: @escaping ObjectCompletionHandler<[Alias]>) -> URLSessionDataTask? { return getAliases(.Movies, id: id, completion: completion) } @@ -138,7 +138,7 @@ extension TraktManager { - parameter country: 2 character country code. Example: `us`. */ @discardableResult - public func getMovieReleases(movieID id: T, country: String?, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getMovieReleases(movieID id: T, country: String?, completion: @escaping ObjectCompletionHandler<[TraktMovieRelease]>) -> URLSessionDataTask? { var path = "movies/\(id)/releases" @@ -265,7 +265,7 @@ extension TraktManager { Returns all users watching this movie right now. */ @discardableResult - public func getUsersWatchingMovie(movieID id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getUsersWatchingMovie(movieID id: T, completion: @escaping ObjectCompletionHandler<[User]>) -> URLSessionDataTask? { return getUsersWatching(.Movies, id: id, completion: completion) } } diff --git a/Common/Wrapper/CompletionHandlerEndpoints/People.swift b/Common/Wrapper/CompletionHandlerEndpoints/People.swift index c991bbf..3f74f58 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/People.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/People.swift @@ -66,7 +66,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getListsContainingPerson(personId id: T, listType: ListType? = nil, sortBy: ListSortType? = nil, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getListsContainingPerson(personId id: T, listType: ListType? = nil, sortBy: ListSortType? = nil, completion: @escaping ObjectCompletionHandler<[TraktList]>) -> URLSessionDataTask? { var path = "people/\(id)/lists" if let listType = listType { path += "/\(listType)" diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Recommendations.swift b/Common/Wrapper/CompletionHandlerEndpoints/Recommendations.swift index c4f4796..2831f41 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Recommendations.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Recommendations.swift @@ -19,7 +19,7 @@ extension TraktManager { ✨ Extended Info */ @discardableResult - public func getRecommendedMovies(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getRecommendedMovies(completion: @escaping ObjectCompletionHandler<[TraktMovie]>) -> URLSessionDataTask? { return getRecommendations(.Movies, completion: completion) } @@ -39,7 +39,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func getRecommendedShows(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getRecommendedShows(completion: @escaping ObjectCompletionHandler<[TraktShow]>) -> URLSessionDataTask? { return getRecommendations(.Shows, completion: completion) } @@ -56,7 +56,7 @@ extension TraktManager { // MARK: - Private @discardableResult - private func getRecommendations(_ type: WatchedType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + private func getRecommendations(_ type: WatchedType, completion: @escaping ObjectCompletionHandler<[T]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "recommendations/\(type)", withQuery: [:], isAuthorized: true, diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Seasons.swift b/Common/Wrapper/CompletionHandlerEndpoints/Seasons.swift index 3d44c74..70cc28f 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Seasons.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Seasons.swift @@ -150,7 +150,7 @@ extension TraktManager { Returns all users watching this season right now. */ @discardableResult - public func getUsersWatchingSeasons(showID id: T, season: NSNumber, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getUsersWatchingSeasons(showID id: T, season: NSNumber, completion: @escaping ObjectCompletionHandler<[User]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/watching", withQuery: [:], isAuthorized: false, diff --git a/Common/Wrapper/CompletionHandlerEndpoints/SharedFunctions.swift b/Common/Wrapper/CompletionHandlerEndpoints/SharedFunctions.swift index 4dedb07..0a80316 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/SharedFunctions.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/SharedFunctions.swift @@ -197,7 +197,7 @@ internal extension TraktManager { // MARK: - Translations - func getTranslations(_ type: WatchedType, id: T, language: String?, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + func getTranslations(_ type: WatchedType, id: T, language: String?, completion: @escaping ObjectCompletionHandler<[U]>) -> URLSessionDataTask? { var path = "\(type)/\(id)/translations" if let language = language { @@ -256,7 +256,7 @@ internal extension TraktManager { // MARK: - Related - func getRelated(_ type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + func getRelated(_ type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler<[U]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "\(type)/\(id)/related", withQuery: ["extended": extended.queryString()], isAuthorized: false, @@ -278,7 +278,7 @@ internal extension TraktManager { // MARK: - Watching - func getUsersWatching(_ type: WatchedType, id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + func getUsersWatching(_ type: WatchedType, id: T, completion: @escaping ObjectCompletionHandler<[User]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "\(type)/\(id)/watching", withQuery: [:], isAuthorized: false, diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Shows.swift b/Common/Wrapper/CompletionHandlerEndpoints/Shows.swift index 468310c..26e28b2 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Shows.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Shows.swift @@ -139,7 +139,7 @@ extension TraktManager { - parameter id: Trakt.tv ID, Trakt.tv slug, or IMDB ID */ @discardableResult - public func getShowAliases(showID id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getShowAliases(showID id: T, completion: @escaping ObjectCompletionHandler<[Alias]>) -> URLSessionDataTask? { return getAliases(.Shows, id: id, completion: completion) } @@ -176,7 +176,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getListsContainingShow(showID id: T, listType: ListType? = nil, sortBy: ListSortType? = nil, pagination: Pagination? = nil, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getListsContainingShow(showID id: T, listType: ListType? = nil, sortBy: ListSortType? = nil, pagination: Pagination? = nil, completion: @escaping ObjectCompletionHandler<[TraktList]>) -> URLSessionDataTask? { var path = "shows/\(id)/lists" if let listType = listType { path += "/\(listType)" @@ -277,7 +277,7 @@ extension TraktManager { **Note**: We are continuing to improve this algorithm. */ @discardableResult - public func getRelatedShows(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getRelatedShows(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler<[TraktShow]>) -> URLSessionDataTask? { return getRelated(.Shows, id: id, extended: extended, completion: completion) } @@ -297,7 +297,7 @@ extension TraktManager { Returns all users watching this show right now. */ @discardableResult - public func getUsersWatchingShow(showID id: T, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getUsersWatchingShow(showID id: T, completion: @escaping ObjectCompletionHandler<[User]>) -> URLSessionDataTask? { return getUsersWatching(.Shows, id: id, completion: completion) } diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Sync.swift b/Common/Wrapper/CompletionHandlerEndpoints/Sync.swift index 19b9108..f448002 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Sync.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Sync.swift @@ -46,7 +46,7 @@ extension TraktManager { - parameter type: Possible Values: .Movies, .Episodes */ @discardableResult - public func getPlaybackProgress(type: WatchedType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getPlaybackProgress(type: WatchedType, completion: @escaping ObjectCompletionHandler<[PlaybackProgress]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "sync/playback/\(type)", withQuery: [:], isAuthorized: true, diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Users.swift b/Common/Wrapper/CompletionHandlerEndpoints/Users.swift index 8bd32b1..a2a11fc 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Users.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Users.swift @@ -45,7 +45,7 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func getFollowRequests(completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getFollowRequests(completion: @escaping ObjectCompletionHandler<[FollowRequest]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "users/requests", withQuery: [:], isAuthorized: true, @@ -152,7 +152,7 @@ extension TraktManager { - Parameter type: Possible values: comments, lists. */ @discardableResult - public func getLikes(type: LikeType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getLikes(type: LikeType, completion: @escaping ObjectCompletionHandler<[Like]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "users/likes/\(type.rawValue)", withQuery: [:], isAuthorized: true, @@ -187,7 +187,7 @@ extension TraktManager { 🔓 OAuth Optional */ @discardableResult - public func getUserCollection(username: String = "me", type: MediaType, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { + public func getUserCollection(username: String = "me", type: MediaType, completion: @escaping ObjectCompletionHandler<[TraktCollectedItem]>) -> URLSessionDataTask? { let authorization = username == "me" ? true : false guard let request = try? mutableRequest(forPath: "users/\(username)/collection/\(type.rawValue)", withQuery: [:], diff --git a/Common/Wrapper/CompletionHandlers.swift b/Common/Wrapper/CompletionHandlers.swift index f97e27b..22df50e 100644 --- a/Common/Wrapper/CompletionHandlers.swift +++ b/Common/Wrapper/CompletionHandlers.swift @@ -14,12 +14,6 @@ public enum ObjectResultType: Sendable { case error(error: Error?) } -/// Generic results type -public enum ObjectsResultType: Sendable { - case success(objects: [T]) - case error(error: Error?) -} - /// Generic results type + Pagination public enum ObjectsResultTypePagination: Sendable { case success(objects: [T], currentPage: Int, limit: Int) @@ -142,7 +136,6 @@ extension TraktManager { // MARK: Common public typealias ObjectCompletionHandler = @Sendable (_ result: ObjectResultType) -> Void - public typealias ObjectsCompletionHandler = @Sendable(_ result: ObjectsResultType) -> Void public typealias paginatedCompletionHandler = @Sendable (_ result: ObjectsResultTypePagination) -> Void public typealias DataResultCompletionHandler = @Sendable (_ result: DataResultType) -> Void @@ -150,16 +143,16 @@ extension TraktManager { public typealias ProgressCompletionHandler = @Sendable (_ result: ProgressResultType) -> Void public typealias CommentsCompletionHandler = paginatedCompletionHandler - public typealias SearchCompletionHandler = ObjectsCompletionHandler + public typealias SearchCompletionHandler = ObjectCompletionHandler<[TraktSearchResult]> public typealias statsCompletionHandler = ObjectCompletionHandler // MARK: Shared public typealias UpdateCompletionHandler = paginatedCompletionHandler - public typealias AliasCompletionHandler = ObjectsCompletionHandler + public typealias AliasCompletionHandler = ObjectCompletionHandler<[Alias]> public typealias RatingDistributionCompletionHandler = ObjectCompletionHandler // MARK: Calendar - public typealias dvdReleaseCompletionHandler = ObjectsCompletionHandler + public typealias dvdReleaseCompletionHandler = ObjectCompletionHandler<[TraktDVDReleaseMovie]> // MARK: Checkin public typealias checkinCompletionHandler = @Sendable (_ result: CheckinResultType) -> Void @@ -168,47 +161,47 @@ extension TraktManager { public typealias TrendingShowsCompletionHandler = paginatedCompletionHandler public typealias MostShowsCompletionHandler = paginatedCompletionHandler public typealias AnticipatedShowCompletionHandler = paginatedCompletionHandler - public typealias ShowTranslationsCompletionHandler = ObjectsCompletionHandler - public typealias SeasonsCompletionHandler = ObjectsCompletionHandler + public typealias ShowTranslationsCompletionHandler = ObjectCompletionHandler<[TraktShowTranslation]> + public typealias SeasonsCompletionHandler = ObjectCompletionHandler<[TraktSeason]> - public typealias WatchedShowsCompletionHandler = ObjectsCompletionHandler + public typealias WatchedShowsCompletionHandler = ObjectCompletionHandler<[TraktWatchedShow]> public typealias ShowWatchedProgressCompletionHandler = ObjectCompletionHandler // MARK: Episodes public typealias EpisodeCompletionHandler = ObjectCompletionHandler - public typealias EpisodesCompletionHandler = ObjectsCompletionHandler + public typealias EpisodesCompletionHandler = ObjectCompletionHandler<[TraktEpisode]> // MARK: Movies public typealias MovieCompletionHandler = ObjectCompletionHandler - public typealias MoviesCompletionHandler = ObjectsCompletionHandler + public typealias MoviesCompletionHandler = ObjectCompletionHandler<[TraktMovie]> public typealias TrendingMoviesCompletionHandler = paginatedCompletionHandler public typealias MostMoviesCompletionHandler = paginatedCompletionHandler public typealias AnticipatedMovieCompletionHandler = paginatedCompletionHandler - public typealias MovieTranslationsCompletionHandler = ObjectsCompletionHandler + public typealias MovieTranslationsCompletionHandler = ObjectCompletionHandler<[TraktMovieTranslation]> public typealias WatchedMoviesCompletionHandler = paginatedCompletionHandler - public typealias BoxOfficeMoviesCompletionHandler = ObjectsCompletionHandler + public typealias BoxOfficeMoviesCompletionHandler = ObjectCompletionHandler<[TraktBoxOfficeMovie]> // MARK: Sync public typealias LastActivitiesCompletionHandler = ObjectCompletionHandler - public typealias RatingsCompletionHandler = ObjectsCompletionHandler + public typealias RatingsCompletionHandler = ObjectCompletionHandler<[TraktRating]> public typealias HistoryCompletionHandler = paginatedCompletionHandler - public typealias CollectionCompletionHandler = ObjectsCompletionHandler + public typealias CollectionCompletionHandler = ObjectCompletionHandler<[TraktCollectedItem]> // MARK: Users public typealias ListCompletionHandler = ObjectCompletionHandler - public typealias ListsCompletionHandler = ObjectsCompletionHandler - public typealias ListItemCompletionHandler = ObjectsCompletionHandler + public typealias ListsCompletionHandler = ObjectCompletionHandler<[TraktList]> + public typealias ListItemCompletionHandler = ObjectCompletionHandler<[TraktListItem]> public typealias WatchlistCompletionHandler = paginatedCompletionHandler public typealias HiddenItemsCompletionHandler = paginatedCompletionHandler - public typealias UserCommentsCompletionHandler = ObjectsCompletionHandler + public typealias UserCommentsCompletionHandler = ObjectCompletionHandler<[UsersComments]> public typealias AddListItemCompletion = ObjectCompletionHandler public typealias RemoveListItemCompletion = ObjectCompletionHandler public typealias FollowUserCompletion = ObjectCompletionHandler - public typealias FollowersCompletion = ObjectsCompletionHandler - public typealias FriendsCompletion = ObjectsCompletionHandler + public typealias FollowersCompletion = ObjectCompletionHandler<[FollowResult]> + public typealias FriendsCompletion = ObjectCompletionHandler<[Friend]> public typealias WatchingCompletion = @Sendable (_ result: WatchingResultType) -> Void public typealias UserStatsCompletion = ObjectCompletionHandler - public typealias UserWatchedCompletion = ObjectsCompletionHandler + public typealias UserWatchedCompletion = ObjectCompletionHandler<[TraktWatchedItem]> // MARK: - Error handling @@ -391,51 +384,7 @@ extension TraktManager { let dataTask = performRequest(request: request, completion: aCompletion) return dataTask } - - /// Array of TraktProtocol objects - func performRequest(request: URLRequest, completion: @escaping ObjectsCompletionHandler) -> URLSessionDataTask? { - let dataTask = session.dataTask(with: request) { [weak self] data, response, error in - guard let self else { return } - if let error { - completion(.error(error: error)) - return - } - // Check response - do throws(TraktError) { - try self.handleResponse(response: response) - } catch { - switch error { - case .retry(let after): - DispatchQueue.global().asyncAfter(deadline: .now() + after) { [weak self, completion] in - _ = self?.performRequest(request: request, completion: completion) - } - default: - completion(.error(error: error)) - } - return - } - - // Check data - guard let data = data else { - completion(.error(error: TraktKitError.couldNotParseData)) - return - } - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom(customDateDecodingStrategy) - do { - let array = try decoder.decode([T].self, from: data) - completion(.success(objects: array)) - } catch { - completion(.error(error: error)) - } - } - - dataTask.resume() - return dataTask - } - /// Array of ObjectsResultTypePagination objects func performRequest(request: URLRequest, completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { let dataTask = session.dataTask(with: request) { [weak self] data, response, error in From 803676c5bb02d091df934f61018d7bdd1a136348 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 8 Mar 2025 08:41:11 -0500 Subject: [PATCH 25/38] Remove custom request handling for `/users/*/watching` The `/users/*/watching` endpoints returns data if a user is currently watching something, or 204 with no data if the user is not watching anything. Rather than having a custom request handler for this, I've updated the Swift model that is decoded from the data to handle no data being returned. Since both responses have a 2xx response, an error is not thrown unless the data could not be decoded. --- Common/Models/Users/TraktWatching.swift | 139 +++++++++++++++++++++--- Common/Wrapper/CompletionHandlers.swift | 64 +---------- Tests/TraktKitTests/UserTests.swift | 23 +++- 3 files changed, 147 insertions(+), 79 deletions(-) diff --git a/Common/Models/Users/TraktWatching.swift b/Common/Models/Users/TraktWatching.swift index 6859239..9d3eb07 100644 --- a/Common/Models/Users/TraktWatching.swift +++ b/Common/Models/Users/TraktWatching.swift @@ -8,24 +8,137 @@ import Foundation -public struct TraktWatching: TraktObject { - public let expiresAt: Date - public let startedAt: Date - public let action: String - public let type: String - - public let episode: TraktEpisode? - public let show: TraktShow? - public let movie: TraktMovie? - - enum CodingKeys: String, CodingKey { +public enum TraktWatching: TraktObject { + case watching(WatchingItem) + case notWatchingAnything + + public struct WatchingItem: TraktObject { + public let expiresAt: Date + public let startedAt: Date + public let action: String + public let mediaType: MediaType + public let mediaItem: MediaItem + + public enum MediaType: String, TraktObject { + case episode + case movie + } + + public enum MediaItem: TraktObject { + case episode(TraktEpisode, show: TraktShow) + case movie(TraktMovie) + } + } +} + +extension TraktWatching { + public init(from decoder: Decoder) throws { + // First, try to decode as a watching item + do { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let expiresAt = try container.decode(Date.self, forKey: .expiresAt) + let startedAt = try container.decode(Date.self, forKey: .startedAt) + let action = try container.decode(String.self, forKey: .action) + let typeString = try container.decode(String.self, forKey: .mediaType) + + guard let type = WatchingItem.MediaType(rawValue: typeString) else { + throw DecodingError.dataCorruptedError( + forKey: .mediaType, + in: container, + debugDescription: "Invalid media type: \(typeString)" + ) + } + + let item: WatchingItem.MediaItem + + switch type { + case .episode: + let episode = try container.decode(TraktEpisode.self, forKey: .episode) + let show = try container.decode(TraktShow.self, forKey: .show) + item = .episode(episode, show: show) + + case .movie: + let movie = try container.decode(TraktMovie.self, forKey: .movie) + item = .movie(movie) + } + + let watchingItem = WatchingItem( + expiresAt: expiresAt, + startedAt: startedAt, + action: action, + mediaType: type, + mediaItem: item + ) + + self = .watching(watchingItem) + } catch { + // If decode failed, check if this is due to a 204 response + // This will be handled at the network layer, so here we'll + // just create a placeholder for that case + self = .notWatchingAnything + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .watching(let watchingItem): + // Encode common properties + try container.encode(watchingItem.expiresAt, forKey: .expiresAt) + try container.encode(watchingItem.startedAt, forKey: .startedAt) + try container.encode(watchingItem.action, forKey: .action) + try container.encode(watchingItem.mediaType.rawValue, forKey: .mediaType) + + // Encode media-specific properties + switch watchingItem.mediaItem { + case .episode(let episode, let show): + try container.encode(episode, forKey: .episode) + try container.encode(show, forKey: .show) + + case .movie(let movie): + try container.encode(movie, forKey: .movie) + } + + case .notWatchingAnything: + // For not watching, we'll encode a special marker + // This isn't in the Trakt API but helps us identify this state when decoding our stored data + try container.encodeNil(forKey: .expiresAt) + try container.encodeNil(forKey: .startedAt) + try container.encodeNil(forKey: .action) + try container.encodeNil(forKey: .mediaType) + } + } + + private enum CodingKeys: String, CodingKey { case expiresAt = "expires_at" case startedAt = "started_at" case action - case type - + case mediaType = "type" case episode case show case movie } } + +// Extension to add convenience accessors +extension TraktWatching { + public var isWatching: Bool { + switch self { + case .watching: + return true + case .notWatchingAnything: + return false + } + } + + public var mediaType: WatchingItem.MediaType? { + switch self { + case .watching(let item): + return item.mediaType + case .notWatchingAnything: + return nil + } + } +} diff --git a/Common/Wrapper/CompletionHandlers.swift b/Common/Wrapper/CompletionHandlers.swift index 22df50e..7efd8ac 100644 --- a/Common/Wrapper/CompletionHandlers.swift +++ b/Common/Wrapper/CompletionHandlers.swift @@ -34,17 +34,6 @@ extension TraktManager { case fail } - public enum ProgressResultType: Sendable { - case success - case fail(Int) - } - - public enum WatchingResultType: Sendable { - case checkedIn(watching: TraktWatching) - case notCheckedIn - case error(error: Error?) - } - public enum CheckinResultType: Sendable { case success(checkin: TraktCheckinResponse) case checkedIn(expiration: Date) @@ -140,7 +129,6 @@ extension TraktManager { public typealias DataResultCompletionHandler = @Sendable (_ result: DataResultType) -> Void public typealias SuccessCompletionHandler = @Sendable (_ result: SuccessResultType) -> Void - public typealias ProgressCompletionHandler = @Sendable (_ result: ProgressResultType) -> Void public typealias CommentsCompletionHandler = paginatedCompletionHandler public typealias SearchCompletionHandler = ObjectCompletionHandler<[TraktSearchResult]> @@ -199,7 +187,7 @@ extension TraktManager { public typealias FollowUserCompletion = ObjectCompletionHandler public typealias FollowersCompletion = ObjectCompletionHandler<[FollowResult]> public typealias FriendsCompletion = ObjectCompletionHandler<[Friend]> - public typealias WatchingCompletion = @Sendable (_ result: WatchingResultType) -> Void + public typealias WatchingCompletion = ObjectCompletionHandler public typealias UserStatsCompletion = ObjectCompletionHandler public typealias UserWatchedCompletion = ObjectCompletionHandler<[TraktWatchedItem]> @@ -442,56 +430,6 @@ extension TraktManager { dataTask.resume() return dataTask } - - // Watching - func performRequest(request: URLRequest, completion: @escaping WatchingCompletion) -> URLSessionDataTask? { - let dataTask = session.dataTask(with: request) { [weak self] data, response, error in - guard let self else { return } - if let error { - completion(.error(error: error)) - return - } - - guard let httpResponse = response as? HTTPURLResponse else { return completion(.error(error: nil)) } - - // Check response - do throws(TraktError) { - try self.handleResponse(response: response) - } catch { - switch error { - case .retry(let after): - DispatchQueue.global().asyncAfter(deadline: .now() + after) { [weak self, completion] in - _ = self?.performRequest(request: request, completion: completion) - } - default: - completion(.error(error: error)) - } - return - } - - if httpResponse.statusCode == StatusCodes.SuccessNoContentToReturn { - completion(.notCheckedIn) - return - } - - // Check data - guard let data = data else { - completion(.error(error: TraktKitError.couldNotParseData)) - return - } - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom(customDateDecodingStrategy) - do { - let watching = try decoder.decode(TraktWatching.self, from: data) - completion(.checkedIn(watching: watching)) - } catch { - completion(.error(error: error)) - } - } - dataTask.resume() - return dataTask - } // MARK: - Async await diff --git a/Tests/TraktKitTests/UserTests.swift b/Tests/TraktKitTests/UserTests.swift index cd2dd0b..d69fde2 100644 --- a/Tests/TraktKitTests/UserTests.swift +++ b/Tests/TraktKitTests/UserTests.swift @@ -828,9 +828,26 @@ final class UserTests: TraktTestCase { let expectation = XCTestExpectation(description: "Get watching") traktManager.getUserWatching(username: "sean") { result in - if case .checkedIn(let watching) = result { - XCTAssertEqual(watching.action, "scrobble") - expectation.fulfill() + defer { expectation.fulfill() } + switch result { + case .success(let response): + XCTAssertTrue(response.isWatching) + switch response { + case .watching(let item): + XCTAssertEqual(item.action, "scrobble") + XCTAssertEqual(item.mediaType, .episode) + switch item.mediaItem { + case let .episode(episode, show): + XCTAssertEqual(episode.number, 2) + XCTAssertEqual(show.title, "Breaking Bad") + case .movie: + XCTFail("Media item should be episode") + } + case .notWatchingAnything: + XCTFail("User should be watching an episode") + } + case .error: + XCTFail("Mocked request should not fail") } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) From d1786c82c514fbadd5ac659a8e4b581b162ac4e7 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 8 Mar 2025 09:13:09 -0500 Subject: [PATCH 26/38] Remove custom request handling for `/checkin` Removed the custom request handling for `TraktCheckin`. Unfortunately because the `/checkin` endpoint returns an expiration date with the status code `409`, the response handling that happens before attempting to decode the data will throw an error. Since this appears to be the only endpoint that returns alternate data for a non-2xx response that I can think of, and because you can get the same data from `/users/*/watching`, I figured this will be ok. You can check if the error thrown is `TraktManager.TraktError.resourceAlreadyCreated` to make that request and get the end date of a checkin. --- Common/Models/Checkin/TraktCheckin.swift | 33 ++++------ Common/Wrapper/CompletionHandlers.swift | 60 +------------------ Tests/TraktKitTests/CheckinTests.swift | 23 ++++--- .../Models/Checkin/test_checkin_episode.json | 9 +-- .../Models/Checkin/test_checkin_movie.json | 11 ++-- 5 files changed, 39 insertions(+), 97 deletions(-) diff --git a/Common/Models/Checkin/TraktCheckin.swift b/Common/Models/Checkin/TraktCheckin.swift index dbc3303..6bac5d5 100644 --- a/Common/Models/Checkin/TraktCheckin.swift +++ b/Common/Models/Checkin/TraktCheckin.swift @@ -12,7 +12,9 @@ import Foundation The sharing object is optional and will apply the user's settings if not sent. If sharing is sent, each key will override the user's setting for that social network. Send true to post or false to not post on the indicated social network. You can see which social networks a user has connected with the /users/settings method. */ public struct ShareSettings: TraktObject { + public let facebook: Bool public let twitter: Bool + public let mastodon: Bool public let tumblr: Bool } @@ -25,32 +27,17 @@ public struct TraktCheckinBody: TraktObject { public let sharing: ShareSettings? /// Message used for sharing. If not sent, it will use the watching string in the user settings. public let message: String? - /// Foursquare venue ID. - public let venueId: String? - /// Foursquare venue name. - public let venueName: String? - /// Version number of the app. - public let appVersion: String? - /// Build date of the app. - public let appDate: String? - - enum CodingKeys: String, CodingKey { - case movie, episode, sharing, message - case venueId = "venue_id" - case venueName = "venueName" - case appVersion = "app_version" - case appDate = "app_date" - } - - public init(movie: SyncId? = nil, episode: SyncId? = nil, sharing: ShareSettings? = nil, message: String? = nil, venueId: String? = nil, venueName: String? = nil, appVersion: String? = nil, appDate: String? = nil) { + + /** + - parameters: + - sharing: The sharing object is optional and will apply the user's settings if not sent. If `sharing` is sent, each key will override the user's setting for that social network. Send `true` to post or `false` to not post on the indicated social network. You can see which social networks a user has connected with the `/users/settings` method. + - message: Message used for sharing. If not sent, it will use the watching string in the user settings. + */ + public init(movie: SyncId? = nil, episode: SyncId? = nil, sharing: ShareSettings? = nil, message: String? = nil) { self.movie = movie self.episode = episode self.sharing = sharing self.message = message - self.venueId = venueId - self.venueName = venueName - self.appVersion = appVersion - self.appDate = appDate } } @@ -60,7 +47,7 @@ public struct TraktCheckinResponse: TraktObject { public let id: Int public let watchedAt: Date public let sharing: ShareSettings - + /// If the user checked in to a movie, a movie object will be returned with it's name public let movie: TraktMovie? /// If the user checked in to an episode, a show object will be returned with it's name diff --git a/Common/Wrapper/CompletionHandlers.swift b/Common/Wrapper/CompletionHandlers.swift index 7efd8ac..21f282b 100644 --- a/Common/Wrapper/CompletionHandlers.swift +++ b/Common/Wrapper/CompletionHandlers.swift @@ -33,13 +33,7 @@ extension TraktManager { case success case fail } - - public enum CheckinResultType: Sendable { - case success(checkin: TraktCheckinResponse) - case checkedIn(expiration: Date) - case error(error: Error?) - } - + public enum TraktError: LocalizedError, Equatable { /// 204. Some methods will succeed but not return any content. The network manager doesn't handle this well at the moment as it wants to decode the data when it is empty. Instead I'll throw this error so that it can be ignored for now. case noContent @@ -143,7 +137,7 @@ extension TraktManager { public typealias dvdReleaseCompletionHandler = ObjectCompletionHandler<[TraktDVDReleaseMovie]> // MARK: Checkin - public typealias checkinCompletionHandler = @Sendable (_ result: CheckinResultType) -> Void + public typealias checkinCompletionHandler = ObjectCompletionHandler // MARK: Shows public typealias TrendingShowsCompletionHandler = paginatedCompletionHandler @@ -296,57 +290,7 @@ extension TraktManager { datatask.resume() return datatask } - - /// Checkin - func performRequest(request: URLRequest, completion: @escaping checkinCompletionHandler) -> URLSessionDataTask? { - let datatask = session.dataTask(with: request) { [weak self] data, response, error in - guard let self else { return } - if let error { - completion(.error(error: error)) - return - } - // Check data - guard let data = data else { - completion(.error(error: TraktKitError.couldNotParseData)) - return - } - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom(customDateDecodingStrategy) - - if let checkin = try? decoder.decode(TraktCheckinResponse.self, from: data) { - completion(.success(checkin: checkin)) - return - } else if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: .allowFragments), - let jsonDictionary = jsonObject as? RawJSON, - let expirationDateString = jsonDictionary["expires_at"] as? String, - let expirationDate = try? Date.dateFromString(expirationDateString) { - completion(.checkedIn(expiration: expirationDate)) - return - } - - // Check response - do throws(TraktError) { - try self.handleResponse(response: response) - } catch { - switch error { - case .retry(let after): - DispatchQueue.global().asyncAfter(deadline: .now() + after) { [weak self, completion] in - _ = self?.performRequest(request: request, completion: completion) - } - default: - completion(.error(error: error)) - } - return - } - - completion(.error(error: nil)) - } - datatask.resume() - return datatask - } - // Generic array of Trakt objects func performRequest(request: URLRequest, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { let aCompletion: DataResultCompletionHandler = { (result) -> Void in diff --git a/Tests/TraktKitTests/CheckinTests.swift b/Tests/TraktKitTests/CheckinTests.swift index d0e6ef7..c8d3851 100644 --- a/Tests/TraktKitTests/CheckinTests.swift +++ b/Tests/TraktKitTests/CheckinTests.swift @@ -18,10 +18,13 @@ final class CheckinTests: TraktTestCase { let expectation = XCTestExpectation(description: "Checkin a movie") let checkin = TraktCheckinBody(movie: SyncId(trakt: 12345)) traktManager.checkIn(checkin) { result in - if case .success(let checkin) = result { + defer { expectation.fulfill() } + switch result { + case .success(let checkin): XCTAssertEqual(checkin.id, 3373536619) XCTAssertNotNil(checkin.movie) - expectation.fulfill() + case .error: + XCTFail("Expected movie checkin") } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) @@ -40,11 +43,14 @@ final class CheckinTests: TraktTestCase { let expectation = XCTestExpectation(description: "Checkin a episode") let checkin = TraktCheckinBody(episode: SyncId(trakt: 12345)) traktManager.checkIn(checkin) { result in - if case .success(let checkin) = result { + defer { expectation.fulfill() } + switch result { + case .success(let checkin): XCTAssertEqual(checkin.id, 3373536620) XCTAssertNotNil(checkin.episode) XCTAssertNotNil(checkin.show) - expectation.fulfill() + case .error: + XCTFail("Expected episode checkin") } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) @@ -63,9 +69,12 @@ final class CheckinTests: TraktTestCase { let expectation = XCTestExpectation(description: "Checkin an existing item") let checkin = TraktCheckinBody(episode: SyncId(trakt: 12345)) traktManager.checkIn(checkin) { result in - if case .checkedIn(let expiration) = result { - XCTAssertEqual(expiration.dateString(withFormat: "YYYY-MM-dd"), "2014-10-15") - expectation.fulfill() + defer { expectation.fulfill() } + switch result { + case .success: + XCTFail("Expecting a 409 response error") + case .error(let error): + XCTAssertEqual(error as? TraktManager.TraktError, TraktManager.TraktError.resourceAlreadyCreated) } } let result = XCTWaiter().wait(for: [expectation], timeout: 1) diff --git a/Tests/TraktKitTests/Models/Checkin/test_checkin_episode.json b/Tests/TraktKitTests/Models/Checkin/test_checkin_episode.json index 74124d3..4d6b9a5 100644 --- a/Tests/TraktKitTests/Models/Checkin/test_checkin_episode.json +++ b/Tests/TraktKitTests/Models/Checkin/test_checkin_episode.json @@ -1,10 +1,11 @@ { "id" : 3373536620, "watched_at" : "2014-08-06T06:54:36.859Z", - "sharing" : { - "twitter" : true, - "facebook" : true, - "tumblr" : false + "sharing": { + "facebook": false, + "twitter": false, + "mastodon": false, + "tumblr": false }, "show" : { "title" : "Breaking Bad", diff --git a/Tests/TraktKitTests/Models/Checkin/test_checkin_movie.json b/Tests/TraktKitTests/Models/Checkin/test_checkin_movie.json index 030aaf6..8dfe99a 100644 --- a/Tests/TraktKitTests/Models/Checkin/test_checkin_movie.json +++ b/Tests/TraktKitTests/Models/Checkin/test_checkin_movie.json @@ -11,10 +11,11 @@ }, "id" : 3373536619, "watched_at" : "2014-08-06T01:11:37.000Z", - "sharing" : { - "twitter" : true, - "facebook" : true, - "tumblr" : false - } + "sharing": { + "facebook": false, + "twitter": false, + "mastodon": false, + "tumblr": false + }, } From d990b9f9d162c76b0978a3f11b283140c4756d8d Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 8 Mar 2025 14:24:26 -0500 Subject: [PATCH 27/38] Add additional User routes --- Common/Models/Users/TraktWatchedItem.swift | 2 + Common/Wrapper/Resources/SyncResource.swift | 2 +- Common/Wrapper/Resources/UserResource.swift | 209 +++++++++++++--- Tests/TraktKitTests/UserTests+Async.swift | 253 ++++++++++++++++++++ 4 files changed, 435 insertions(+), 31 deletions(-) create mode 100644 Tests/TraktKitTests/UserTests+Async.swift diff --git a/Common/Models/Users/TraktWatchedItem.swift b/Common/Models/Users/TraktWatchedItem.swift index c5caea8..444a877 100644 --- a/Common/Models/Users/TraktWatchedItem.swift +++ b/Common/Models/Users/TraktWatchedItem.swift @@ -11,6 +11,7 @@ import Foundation public struct TraktWatchedItem: TraktObject { public let plays: Int public let lastWatchedAt: Date + public let lastUpdatedAt: Date public var show: TraktShow? = nil public var seasons: [TraktWatchedSeason]? = nil public var movie: TraktMovie? = nil @@ -18,6 +19,7 @@ public struct TraktWatchedItem: TraktObject { enum CodingKeys: String, CodingKey { case plays case lastWatchedAt = "last_watched_at" + case lastUpdatedAt = "last_updated_at" case show case seasons case movie diff --git a/Common/Wrapper/Resources/SyncResource.swift b/Common/Wrapper/Resources/SyncResource.swift index 2ff209c..3544a01 100644 --- a/Common/Wrapper/Resources/SyncResource.swift +++ b/Common/Wrapper/Resources/SyncResource.swift @@ -155,7 +155,7 @@ extension TraktManager { Each `show` object contains `last_watched_at` and `last_updated_at` timestamps. Since users can set custom dates when they watched episodes, it is possible for `last_watched_at` to be in the past. We also include `last_updated_at` to help sync Trakt data with your app. Cache this timestamp locally and only re-process the movies and shows if you see a newer timestamp. - Each show object contains a `reset_at` timestamp. If not `null`, this is when the user started re-watching the show. Your app can adjust the progress by ignoring episodes with a `last_watched_at` prior to the `reset_at`. + Each `show` object contains a `reset_at` timestamp. If not `null`, this is when the user started re-watching the show. Your app can adjust the progress by ignoring episodes with a `last_watched_at` prior to the `reset_at`. 🔒 OAuth Required ✨ Extended Info */ diff --git a/Common/Wrapper/Resources/UserResource.swift b/Common/Wrapper/Resources/UserResource.swift index 01fda98..6d985e8 100644 --- a/Common/Wrapper/Resources/UserResource.swift +++ b/Common/Wrapper/Resources/UserResource.swift @@ -8,7 +8,7 @@ import Foundation extension TraktManager { - /// Resource for authenticated user + /// Resource containing all of the `/user` endpoints that **require** authentication. These requests will always be for the current authenticated user, and cannot be performed for another user. public struct CurrentUserResource { static let currentUserSlug = "me" @@ -75,10 +75,7 @@ extension TraktManager { */ public func hiddenItems(for section: String, type: String? = nil) -> Route> { Route( - paths: [ - "users/hidden", - section - ], + paths: ["users/hidden", section], queryItems: ["type": type].compactMapValues { $0 }, method: .GET, requiresAuthentication: true, @@ -91,12 +88,9 @@ extension TraktManager { 🔒 OAuth Required */ - public func hide(movies: [SyncId], shows: [SyncId], seasons: [SyncId], users: [SyncId], in section: String) -> Route { + public func hide(movies: [SyncId] = [], shows: [SyncId] = [], seasons: [SyncId] = [], users: [SyncId] = [], in section: String) -> Route { Route( - paths: [ - "users/hidden", - section - ], + paths: ["users/hidden", section], body: TraktMediaBody(movies: movies, shows: shows, seasons: seasons, users: users), method: .POST, requiresAuthentication: true, @@ -109,12 +103,9 @@ extension TraktManager { 🔒 OAuth Required */ - public func unhide(movies: [SyncId], shows: [SyncId], seasons: [SyncId], users: [SyncId], in section: String) -> Route { + public func unhide(movies: [SyncId] = [], shows: [SyncId] = [], seasons: [SyncId] = [], users: [SyncId] = [], in section: String) -> Route { Route( - paths: [ - "users/hidden/remove", - section - ], + paths: ["users/hidden", section, "remove"], body: TraktMediaBody(movies: movies, shows: shows, seasons: seasons, users: users), method: .POST, requiresAuthentication: true, @@ -128,19 +119,21 @@ extension TraktManager { 🔓 OAuth Required ✨ Extended Info */ public func profile() -> Route { - UsersResource(slug: Self.currentUserSlug, traktManager: traktManager).profile(authenticate: true) + UsersResource(slug: Self.currentUserSlug, traktManager: traktManager).profile() } } - /// Resource for /Users/id + /// Resource containing all of the `/user/*` endpoints where authentication is **optional** or **not** required. public struct UsersResource { public let slug: String private let path: String + private let authenticate: Bool private let traktManager: TraktManager - internal init(slug: String, traktManager: TraktManager) { + internal init(slug: String, authenticate: Bool = false, traktManager: TraktManager) { self.slug = slug self.path = "users/\(slug)" + self.authenticate = slug == "me" ? true : authenticate self.traktManager = traktManager } @@ -151,7 +144,7 @@ extension TraktManager { 🔓 OAuth Optional ✨ Extended Info */ - public func profile(authenticate: Bool = false) -> Route { + public func profile() -> Route { Route(paths: [path], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) } @@ -168,7 +161,7 @@ extension TraktManager { 🔒 OAuth Optional 📄 Pagination */ - public func likes(type: String? = nil, authenticate: Bool = false) -> Route> { + public func likes(type: String? = nil) -> Route> { Route(paths: [path, "likes", type], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) } @@ -187,7 +180,7 @@ extension TraktManager { - parameter type: `movies` or `shows` */ - public func collection(type: String, authenticate: Bool = false) -> Route<[TraktCollectedItem]> { + public func collection(type: String) -> Route<[TraktCollectedItem]> { Route(paths: [path, "collection", type], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) } @@ -200,14 +193,9 @@ extension TraktManager { 🔓 OAuth Optional 📄 Pagination ✨ Extended Info */ - public func comments(commentType: String? = nil, mediaType: String? = nil, includeReplies: String? = nil, authenticate: Bool = false) -> Route> { + public func comments(commentType: String? = nil, mediaType: String? = nil, includeReplies: String? = nil) -> Route> { Route( - paths: [ - path, - "comments", - commentType, - mediaType - ], + paths: [path, "comments", commentType, mediaType], queryItems: ["include_replies": includeReplies].compactMapValues { $0 }, method: .GET, requiresAuthentication: authenticate, @@ -215,14 +203,175 @@ extension TraktManager { ) } - // MARK: Settings + // MARK: - Notes + + // MARK: - Lists + + // MARK: - Collaborations + + // MARK: - List public func lists() -> Route<[TraktList]> { Route(paths: [path, "lists"], method: .GET, traktManager: traktManager) } - + public func itemsOnList(_ listId: String, type: ListItemType? = nil) -> Route<[TraktListItem]> { Route(paths: ["users/\(slug)/lists/\(listId)/items", type?.rawValue], method: .GET, traktManager: traktManager) } + + // MARK: - Follow + + // MARK: - Followers + + // MARK: - Following + + // MARK: - Friends + + // MARK: - History + + /** + Returns movies and episodes that a user has watched, sorted by most recent. You can optionally limit the `type` to `movies` or `episodes`. The `id` (64-bit integer) in each history item uniquely identifies the event and can be used to remove individual events by using the `/sync/history/remove` method. The `action` will be set to `scrobble`, `checkin`, or `watch`. + + Specify a `type` and trakt `item_id` to limit the history for just that item. If the `item_id` is valid, but there is no history, an empty array will be returned. + + - parameters: + - type: Possible values: `movies` , `shows` , `seasons` , `episodes`. + - mediaId: Trakt ID for a specific item. + - startingAt: Starting date. + - endingAt: Ending date. + */ + public func watchedHistory( + type: String? = nil, + mediaId: CustomStringConvertible? = nil, + startingAt: Date? = nil, + endingAt: Date? = nil + ) -> Route> { + Route( + paths: [path, "history", type, mediaId], + queryItems: [ + "start_at": startingAt?.UTCDateString(), + "end_at": endingAt?.UTCDateString() + ].compactMapValues { $0 }, + method: .GET, + requiresAuthentication: authenticate, + traktManager: traktManager + ) + } + + // MARK: - Ratings + + /** + Get a user's ratings filtered by `type`. You can optionally filter for a specific `rating` between 1 and 10. Send a comma separated string for `rating` if you need multiple ratings. + + 🔓 OAuth Optional 📄 Pagination Optional ✨ Extended Info + + - parameters: + - type: Possible values: `movies` , `shows` , `seasons` , `episodes` , `all` . + - rating: Filter for a specific rating. Possible values: 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10 . + */ + public func ratings(type: String? = nil, rating: CustomStringConvertible? = nil) -> Route> { + Route( + paths: [path, "ratings", type, rating], + method: .GET, + requiresAuthentication: authenticate, + traktManager: traktManager + ) + } + + // MARK: - Watchlist + + /** + Returns all items in a user's watchlist filtered by type. + + --- + **Notes** + + Each watchlist item contains a `notes` field with text entered by the user. + + --- + **Sorting Headers** + + By default, all list items are sorted by `rank` `asc`. We send `X-Applied-Sort-By` and `X-Applied-Sort-How` headers to indicate how the results are actually being sorted. + + We also send `X-Sort-By` and `X-Sort-How` headers which indicate the user's sort preference. Use these to to custom sort the watchlist in your app for more advanced sort abilities we can't do in the API. Values for `X-Sort-By` include `rank`, `added`, `title`, `released`, `runtime`, `popularity`, `percentage`, and `votes`. Values for `X-Sort-How` include `asc` and `desc`. + + --- + **Auto Removal** + + When an item is watched, it will be automatically removed from the watchlist. For shows and seasons, watching 1 episode will remove the entire show or season. + + --- + **The watchlist should not be used as a list of what the user is actively watching.** + + Use a combination of the ``TraktManager/SyncResource/watchedShows()``, ``TraktManager/SyncResource/watchedMovies()`` and ``TraktManager/ShowResource/watchedProgress(includeHiddenSeasons:includeSpecials:progressCountsSpecials:)`` methods to get what the user is actively watching. + + 🔓 OAuth Optional 📄 Pagination Optional ✨ Extended Info 😁 Emojis + + - parameters: + - type: Filter for a specific item type. Possible values: `movies` , `shows` , `seasons` , `episodes` . + - sort: How to sort (only if type is also sent). Possible values: `rank` , `added` , `released` , `title` . + */ + public func watchlist(type: String? = nil, sort: String? = nil) -> Route> { + Route(paths: [path, "watchlist", type, sort], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) + } + + /** + Returns all top level comments for the watchlist. By default, the `newest` comments are returned first. Other sorting options include `oldest`, most `likes`, and most `replies`. + + > note: If you send OAuth, comments from blocked users will be automatically filtered out. + + 🔓 OAuth Optional 📄 Pagination 😁 Emojis + + - parameter sort: How to sort. Possible values: `newest` , `oldest` , `likes` , `replies` . + */ + public func watchlistComments(sort: String? = nil) -> Route> { + Route(paths: [path, "watchlist", "comments", sort], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) + } + + // MARK: - Favorites + + // MARK: - Watching + + /** + Returns a movie or episode if the user is currently watching something. If they are not, it returns no data and a `204` HTTP status code. + + 🔓 OAuth Optional ✨ Extended Info + */ + public func watching() -> Route { + Route(paths: [path, "watching"], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) + } + + // MARK: - Watched + + /** + Returns all movies or shows a user has watched sorted by most plays. + + If type is set to `shows` and you add `?extended=noseasons` to the URL, it won't return season or episode info. + + Each `movie` and `show` object contains `last_watched_at` and `last_updated_at` timestamps. Since users can set custom dates when they watched movies and episodes, it is possible for `last_watched_at` to be in the past. We also include `last_updated_at` to help sync Trakt data with your app. Cache this timestamp locally and only re-process the movies and shows if you see a newer timestamp. + + Each `show` object contains a `reset_at` timestamp. If not null, this is when the user started re-watching the show. Your app can adjust the progress by ignoring episodes with a `last_watched_at` prior to the `reset_at`. + + 🔓 OAuth Optional ✨ Extended Info + */ + public func watched(type: String) -> Route<[TraktWatchedItem]> { + Route( + paths: [path, "watched", type], + method: .GET, + requiresAuthentication: authenticate, + traktManager: traktManager + ) + } + + // MARK: - Stats + + /** + Returns stats about the movies, shows, and episodes a user has watched, collected, and rated. + + 🔓 OAuth Optional + */ + public func stats() -> Route { + Route(paths: [path, "stats"], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) + } } } diff --git a/Tests/TraktKitTests/UserTests+Async.swift b/Tests/TraktKitTests/UserTests+Async.swift new file mode 100644 index 0000000..bb4d120 --- /dev/null +++ b/Tests/TraktKitTests/UserTests+Async.swift @@ -0,0 +1,253 @@ +// +// UserTests+Async.swift +// TraktKit +// +// Created by Maximilian Litteral on 3/8/25. +// + +import Testing +@testable import TraktKit + +extension TraktTestSuite { + @Suite(.serialized) + struct UserTestSuite { + @Test func getSettings() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/users/settings", result: .success(jsonData(named: "test_get_settings"))) + + let settings = try await traktManager.currentUser().settings().perform() + #expect(settings.user.name == "Justin Nemeth") + #expect(settings.user.gender == "male") + #expect(settings.connections.twitter == true) + #expect(settings.connections.slack == false) + #expect(settings.limits.list.count == 2) + #expect(settings.limits.list.itemCount == 100) + } + + // MARK: - Follow requests + + @Test func getFollowingRequests() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/users/requests", result: .success(jsonData(named: "test_get_follow_request"))) + + let followRequests = try await traktManager.currentUser() + .getFollowerRequests() + .perform() + + #expect(followRequests.count == 1) + } + + @Test func approveFollowRequest() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/users/requests/123", result: .success(jsonData(named: "test_approve_follow_request"))) + + let result = try await traktManager.currentUser() + .approveFollowRequest(id: 123) + .perform() + + #expect(result.user.username == "sean") + } + + @Test func denyFollowRequest() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.DELETE, "https://api.trakt.tv/users/requests/123", result: .success(.init())) + + try await traktManager.currentUser() + .denyFollowRequest(id: 123) + .perform() + } + + @Test func getSavedFilters() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/users/saved_filters", result: .success(jsonData(named: "test_get_saved_filters"))) + + let filters = try await traktManager.currentUser().savedFilters().perform().object + #expect(filters.count == 4) + + let firstFilter = try #require(filters.first) + #expect(firstFilter.id == 101) + } + + // MARK: - Hidden items + + @Test func getHiddenItems() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/users/hidden/progress_watched?page=1&limit=10&type=show&extended=min", result: .success(jsonData(named: "test_get_hidden_items"))) + + let result = try await traktManager.currentUser() + .hiddenItems(for: HiddenItemSection.progressWatched, type: "show") + .page(1) + .limit(10) + .extend(.Min) + .perform() + .object + + #expect(result.count == 2) + } + + @Test func hideItem() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.POST, "https://api.trakt.tv/users/hidden/calendar", result: .success(jsonData(named: "test_add_hidden_item"))) + + let result = try await traktManager.currentUser() + .hide(movies: [.init(trakt: 1234)], in: HiddenItemSection.calendar) + .perform() + + #expect(result.added.movies == 1) + #expect(result.added.shows == 2) + #expect(result.added.seasons == 2) + } + + @Test func unhideItem() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/users/hidden/calendar/remove", result: .success(jsonData(named: "test_post_remove_hidden_items"))) + + let result = try await traktManager.currentUser() + .unhide(movies: [.init(trakt: 1234)], in: HiddenItemSection.calendar) + .perform() + #expect(result.deleted.movies == 1) + #expect(result.deleted.shows == 2) + #expect(result.deleted.seasons == 2) + } + + // MARK: - Profile + + @Test func getMinProfile() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/users/me?extended=min", result: .success(jsonData(named: "test_get_min_profile"))) + + let profile = try await traktManager.currentUser() + .profile() + .extend(.Min) + .perform() + + #expect(profile.username == "sean") + #expect(profile.isPrivate == false) + #expect(profile.isVIP == true) + #expect(profile.isVIPEP == true) + #expect(profile.name == "Sean Rudford") + } + + @Test func getFullProfile() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/users/me?extended=full", result: .success(jsonData(named: "test_get_full_profile"))) + + let profile = try await traktManager.currentUser() + .profile() + .extend(.Full) + .perform() + + #expect(profile.username == "sean") + #expect(profile.isPrivate == false) + #expect(profile.isVIP == true) + #expect(profile.isVIPEP == true) + #expect(profile.name == "Sean Rudford") + #expect(profile.joinedAt != nil) + #expect(profile.age == 35) + #expect(profile.about == "I have all your cassette tapes.") + #expect(profile.location == "SF") + #expect(profile.gender == "male") + } + + @Test func getVIPProfile() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/users/me?extended=full", result: .success(jsonData(named: "test_get_VIP_profile"))) + + let profile = try await traktManager.currentUser() + .profile() + .extend(.Full) + .perform() + + #expect(profile.username == "sean") + #expect(profile.isPrivate == false) + #expect(profile.isVIP == true) + #expect(profile.isVIPEP == true) + #expect(profile.name == "Sean Rudford") + #expect(profile.vipYears == 5) + #expect(profile.vipOG == true) + } + + // MARK: - Collection + + @Test func getCollection() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/users/me/collection/shows", result: .success(jsonData(named: "test_get_user_collection"))) + + let collection = try await traktManager.user("me") + .collection(type: "shows") + .perform() + + let shows = collection.map { $0.show } + let seasons = collection.map { $0.seasons } + #expect(shows.count == 2) + #expect(seasons.count == 2) + + let firstItemMetadata = try #require(collection.first(where: { $0.show?.ids.trakt == 245 })?.seasons?.first?.episodes.first?.metadata) + + #expect(firstItemMetadata.mediaType == .bluray) + #expect(firstItemMetadata.resolution == nil) + #expect(firstItemMetadata.hdr == nil) + #expect(firstItemMetadata.audio == .dtsHDMA) + #expect(firstItemMetadata.audioChannels == nil) + #expect(firstItemMetadata.is3D == false) + } + + // MARK: - Comments + + @Test func getComments() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/users/sean/comments", result: .success(jsonData(named: "test_get_user_comments"))) + + let comments = try await traktManager.user("sean") + .comments() + .perform() + .object + + #expect(comments.count == 5) + } + + // MARK: - History + + @Test func getWatchedHistory() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/users/sean/history?extended=min", result: .success(jsonData(named: "test_get_user_watched_history"))) + + let history = try await traktManager.user("sean") + .watchedHistory() + .extend(.Min) + .perform() + .object + + #expect(history.count == 3) + } + + // MARK: - Ratings + + @Test func getRatings() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/users/sean/ratings", result: .success(jsonData(named: "test_get_user_ratings"))) + + let ratings = try await traktManager.user("sean") + .ratings() + .perform() + .object + + #expect(ratings.count == 2) + } + + // MARK: - Watchlist + + @Test func getWatchlist() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/users/sean/watchlist/movies?extended=min", result: .success(jsonData(named: "test_get_user_watchlist"))) + + let watchlist = try await traktManager.user("sean") + .watchlist(type: "movies") + .extend(.Min) + .perform() + .object + + #expect(watchlist.count == 5) + } + } +} From 4e172a42a6c7727a183b849d87e3aee6c8532633 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 8 Mar 2025 14:25:57 -0500 Subject: [PATCH 28/38] Add checkin resource --- .../Wrapper/Resources/CheckinResource.swift | 63 +++++++++++++++++++ .../Resources/TraktManager+Resources.swift | 6 ++ 2 files changed, 69 insertions(+) create mode 100644 Common/Wrapper/Resources/CheckinResource.swift diff --git a/Common/Wrapper/Resources/CheckinResource.swift b/Common/Wrapper/Resources/CheckinResource.swift new file mode 100644 index 0000000..d24e123 --- /dev/null +++ b/Common/Wrapper/Resources/CheckinResource.swift @@ -0,0 +1,63 @@ +// +// CheckinResource.swift +// TraktKit +// +// Created by Maximilian Litteral on 3/8/25. +// + +extension TraktManager { + /** + Checking in is a manual action used by mobile apps allowing the user to indicate what they are watching right now. While not as effortless as scrobbling, checkins help fill in the gaps. You might be watching live tv, at a friend's house, or watching a movie in theaters. You can simply checkin from your phone or tablet in those situations. The item will display as watching on the site, then automatically switch to watched status once the duration has elapsed. + */ + public struct CheckinResource { + private let traktManager: TraktManager + private let path: String = "checkin" + + internal init(traktManager: TraktManager) { + self.traktManager = traktManager + } + + // MARK: - Routes + + /** + Check into a movie or episode. This should be tied to a user action to manually indicate they are watching something. The item will display as watching on the site, then automatically switch to watched status once the duration has elapsed. A unique history `id` (64-bit integer) will be returned and can be used to reference this checkin directly. + + --- + **Sharing** + + The sharing object is optional and will apply the user's settings if not sent. If sharing is sent, each key will override the user's setting for that social network. Send true to post or false to not post on the indicated social network. You can see which social networks a user has connected with the ``TraktManager/CurrentUserResource/settings()`` method. + + > note: If a checkin is already in progress, a ``TraktManager/TraktError/resourceAlreadyCreated`` error will thrown. Use ``TraktManager/UsersResource/watching()`` to get the timestamp of when the check-in is completed. + + 🔒 OAuth Required + */ + public func checkInto( + movie: SyncId? = nil, + episode: SyncId? = nil, + sharing: ShareSettings? = nil, + message: String? = nil + ) async throws -> Route { + Route( + path: path, + body: TraktCheckinBody( + movie: movie, + episode: episode, + sharing: sharing, + message: message + ), + method: .POST, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + /** + Removes any active checkins, no need to provide a specific item. + + 🔒 OAuth Required + */ + public func deleteActiveCheckin() async throws -> EmptyRoute { + EmptyRoute(path: path, method: .DELETE, requiresAuthentication: true, traktManager: traktManager) + } + } +} diff --git a/Common/Wrapper/Resources/TraktManager+Resources.swift b/Common/Wrapper/Resources/TraktManager+Resources.swift index 6af0720..d3bae53 100644 --- a/Common/Wrapper/Resources/TraktManager+Resources.swift +++ b/Common/Wrapper/Resources/TraktManager+Resources.swift @@ -16,6 +16,12 @@ extension TraktManager { AuthenticationResource(traktManager: self) } + // MARK: - Checkin + + public func checkin() -> CheckinResource { + CheckinResource(traktManager: self) + } + // MARK: - Search public func search() -> SearchResource { From 60a3b9cca875255ddf19658e4dc5e309646d5d34 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sun, 9 Mar 2025 08:04:52 -0400 Subject: [PATCH 29/38] Add additional User endpoints related to lists --- Common/Models/Users/TraktList.swift | 91 +++++++++ .../CompletionHandlerEndpoints/Users.swift | 6 +- Common/Wrapper/Resources/UserResource.swift | 133 +++++++++++-- .../Users/test_get_user_watched_movies.json | 56 +++--- .../Users/test_get_user_watched_shows.json | 186 +++++++++--------- ...est_get_user_watched_shows_no_seasons.json | 63 +++--- Tests/TraktKitTests/UserTests+Async.swift | 4 +- 7 files changed, 372 insertions(+), 167 deletions(-) diff --git a/Common/Models/Users/TraktList.swift b/Common/Models/Users/TraktList.swift index 35817d0..3b9ac6c 100644 --- a/Common/Models/Users/TraktList.swift +++ b/Common/Models/Users/TraktList.swift @@ -9,11 +9,92 @@ import Foundation public enum ListPrivacy: String, TraktObject { + /// Only you can see the list. case `private` + /// Anyone with the `share_link` can see the list. + case link + /// Only your friends can see the list. case friends + /// Anyone can see the list. case `public` } +public struct TraktNewList: TraktObject { + public let name: String + public let description: String? + public let privacy: ListPrivacy? + public let displayNumbers: Bool? + public let allowComments: Bool? + public let sortBy: String? + public let sortHow: String? + + init( + name: String, + description: String? = nil, + privacy: ListPrivacy? = nil, + displayNumbers: Bool? = nil, + allowComments: Bool? = nil, + sortBy: String? = nil, + sortHow: String? = nil + ) { + self.name = name + self.description = description + self.privacy = privacy + self.displayNumbers = displayNumbers + self.allowComments = allowComments + self.sortBy = sortBy + self.sortHow = sortHow + } + + enum CodingKeys: String, CodingKey { + case name + case description + case privacy + case displayNumbers = "display_numbers" + case allowComments = "allow_comments" + case sortBy = "sort_by" + case sortHow = "sort_how" + } +} + +public struct TraktUpdateList: TraktObject { + public let name: String? + public let description: String? + public let privacy: ListPrivacy? + public let displayNumbers: Bool? + public let allowComments: Bool? + public let sortBy: String? + public let sortHow: String? + + init( + name: String? = nil, + description: String? = nil, + privacy: ListPrivacy? = nil, + displayNumbers: Bool? = nil, + allowComments: Bool? = nil, + sortBy: String? = nil, + sortHow: String? = nil + ) { + self.name = name + self.description = description + self.privacy = privacy + self.displayNumbers = displayNumbers + self.allowComments = allowComments + self.sortBy = sortBy + self.sortHow = sortHow + } + + enum CodingKeys: String, CodingKey { + case name + case description + case privacy + case displayNumbers = "display_numbers" + case allowComments = "allow_comments" + case sortBy = "sort_by" + case sortHow = "sort_how" + } +} + public struct TraktList: TraktObject { public let allowComments: Bool public let commentCount: Int @@ -53,3 +134,13 @@ public struct TraktTrendingList: TraktObject { case list } } + +public struct TraktReorderListsResponse: TraktObject { + public let updated: Int + public let skipped: [Int] + + enum CodingKeys: String, CodingKey { + case updated + case skipped = "skipped_ids" + } +} diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Users.swift b/Common/Wrapper/CompletionHandlerEndpoints/Users.swift index a2a11fc..cb247fc 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Users.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Users.swift @@ -289,7 +289,7 @@ extension TraktManager { 🔓 OAuth Optional */ @discardableResult - public func getCustomList(username: String = "me", listID: T, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { + public func getCustomList(username: String = "me", listID: CustomStringConvertible, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { let authorization = username == "me" ? true : false guard let request = try? mutableRequest(forPath: "users/\(username)/lists/\(listID)", @@ -305,8 +305,8 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func updateCustomList(listID: T, listName: String? = nil, listDescription: String? = nil, privacy: String? = nil, displayNumbers: Bool? = nil, allowComments: Bool? = nil, completion: @escaping ListCompletionHandler) throws -> URLSessionDataTask? { - + public func updateCustomList(listID: CustomStringConvertible, listName: String? = nil, listDescription: String? = nil, privacy: String? = nil, displayNumbers: Bool? = nil, allowComments: Bool? = nil, completion: @escaping ListCompletionHandler) throws -> URLSessionDataTask? { + // JSON var json = [String: Any]() json["name"] = listName diff --git a/Common/Wrapper/Resources/UserResource.swift b/Common/Wrapper/Resources/UserResource.swift index 6d985e8..105c95f 100644 --- a/Common/Wrapper/Resources/UserResource.swift +++ b/Common/Wrapper/Resources/UserResource.swift @@ -14,6 +14,7 @@ extension TraktManager { static let currentUserSlug = "me" private let traktManager: TraktManager + private let path: String = "users" internal init(traktManager: TraktManager) { self.traktManager = traktManager @@ -22,19 +23,19 @@ extension TraktManager { // MARK: - Methods public func settings() -> Route { - Route(path: "users/settings", method: .GET, requiresAuthentication: true, traktManager: traktManager) + Route(paths: [path, "settings"], method: .GET, requiresAuthentication: true, traktManager: traktManager) } // MARK: Following Requests /// List a user's pending following requests that they're waiting for the other user's to approve. public func getPendingFollowingRequests() -> Route<[FollowRequest]> { - Route(path: "users/requests/following", method: .GET, requiresAuthentication: true, traktManager: traktManager) + Route(paths: [path, "requests", "following"], method: .GET, requiresAuthentication: true, traktManager: traktManager) } /// List a user's pending follow requests so they can either approve or deny them. public func getFollowerRequests() -> Route<[FollowRequest]> { - Route(path: "users/requests", method: .GET, requiresAuthentication: true, traktManager: traktManager) + Route(paths: [path, "requests"], method: .GET, requiresAuthentication: true, traktManager: traktManager) } /** @@ -43,7 +44,7 @@ extension TraktManager { 🔒 OAuth Required */ public func approveFollowRequest(id: Int) -> Route { - Route(path: "users/requests/\(id)", method: .POST, requiresAuthentication: true, traktManager: traktManager) + Route(paths: [path, "requests", id], method: .POST, requiresAuthentication: true, traktManager: traktManager) } /** @@ -52,7 +53,7 @@ extension TraktManager { 🔒 OAuth Required */ public func denyFollowRequest(id: Int) -> EmptyRoute { - EmptyRoute(path: "users/requests/\(id)", method: .DELETE, requiresAuthentication: true, traktManager: traktManager) + EmptyRoute(paths: [path, "requests", id], method: .DELETE, requiresAuthentication: true, traktManager: traktManager) } /** @@ -63,7 +64,7 @@ extension TraktManager { 📄 Pagination */ public func savedFilters(for section: String? = nil) -> Route> { - Route(paths: ["users/saved_filters", section], method: .GET, requiresAuthentication: true, traktManager: traktManager) + Route(paths: [path, "saved_filters", section], method: .GET, requiresAuthentication: true, traktManager: traktManager) } // MARK: - Hidden @@ -75,7 +76,7 @@ extension TraktManager { */ public func hiddenItems(for section: String, type: String? = nil) -> Route> { Route( - paths: ["users/hidden", section], + paths: [path, "hidden", section], queryItems: ["type": type].compactMapValues { $0 }, method: .GET, requiresAuthentication: true, @@ -90,7 +91,7 @@ extension TraktManager { */ public func hide(movies: [SyncId] = [], shows: [SyncId] = [], seasons: [SyncId] = [], users: [SyncId] = [], in section: String) -> Route { Route( - paths: ["users/hidden", section], + paths: [path, "hidden", section], body: TraktMediaBody(movies: movies, shows: shows, seasons: seasons, users: users), method: .POST, requiresAuthentication: true, @@ -105,7 +106,7 @@ extension TraktManager { */ public func unhide(movies: [SyncId] = [], shows: [SyncId] = [], seasons: [SyncId] = [], users: [SyncId] = [], in section: String) -> Route { Route( - paths: ["users/hidden", section, "remove"], + paths: [path, "hidden", section, "remove"], body: TraktMediaBody(movies: movies, shows: shows, seasons: seasons, users: users), method: .POST, requiresAuthentication: true, @@ -113,6 +114,8 @@ extension TraktManager { ) } + // MARK: - Profile + /** Get a user's profile information. If the user is private, info will only be returned if you send OAuth and are either that user or an approved follower. Adding `?extended=vip` will return some additional VIP related fields so you can display the user's Trakt VIP status and year count. @@ -121,6 +124,73 @@ extension TraktManager { public func profile() -> Route { UsersResource(slug: Self.currentUserSlug, traktManager: traktManager).profile() } + + // MARK: - Lists + + /** + Create a new personal list. The `name` is the only required field, but the other info is recommended to ask for. + + --- + **Limits** + + If the user's list limit is exceeded, a ``TraktManager/TraktError/accountLimitExceeded`` error is thrown. Use the ``TraktManager/CurrentUserResource/settings()`` method to get all limits for a user account. In most cases, upgrading to Trakt VIP will increase the limits. + + 🔥 VIP Enhanced 🔒 OAuth Required + */ + public func createPersonalList(_ body: TraktNewList) -> Route { + Route(paths: [path, Self.currentUserSlug, "lists"], method: .POST, requiresAuthentication: true, traktManager: traktManager) + } + + /** + Reorder all lists by sending the updated rank of list ids. Use the /users/:id/lists method to get all list ids. + + 🔒 OAuth Required + */ + public func reorderLists(_ rank: [Int]) -> Route { + struct ReorderBody: TraktObject { + let rank: [Int] + } + + return Route( + paths: [path, Self.currentUserSlug, "lists", "reorder"], + body: ReorderBody(rank: rank), + method: .POST, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + // MARK: - List + + /** + Update a personal list by sending 1 or more parameters. If you update the list name, the original slug will still be retained so existing references to this list won't break. + + 🔒 OAuth Required + */ + public func updatePersonalList(_ listId: CustomStringConvertible, changes: TraktUpdateList) -> Route { + Route( + paths: [path, Self.currentUserSlug, "lists", listId], + body: changes, + method: .POST, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + /** + Remove a personal list and all items it contains. + + 🔒 OAuth Required + */ + public func deletePersonalList(_ listId: CustomStringConvertible) -> EmptyRoute { + EmptyRoute( + paths: [path, Self.currentUserSlug, "lists", listId], + method: .DELETE, + requiresAuthentication: true, + traktManager: traktManager + ) + } + } /// Resource containing all of the `/user/*` endpoints where authentication is **optional** or **not** required. @@ -207,16 +277,53 @@ extension TraktManager { // MARK: - Lists + /** + Returns all personal lists for a user. Use the /users/:id/lists/:list_id/items method to get the actual items a specific list contains. + + 🔓 OAuth Optional 😁 Emojis + */ + public func lists() -> Route<[TraktList]> { + Route(paths: [path, "lists"], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager) + } + // MARK: - Collaborations // MARK: - List - public func lists() -> Route<[TraktList]> { - Route(paths: [path, "lists"], method: .GET, traktManager: traktManager) + /** + Returns a single personal list. Use the /users/:id/lists/:list_id/items method to get the actual items this list contains. + + 🔓 OAuth Optional 😁 Emojis + + - parameter listId: Trakt ID or Trakt slug + */ + public func personalList(_ listId: CustomStringConvertible) -> Route { + Route( + paths: [path, "lists", listId], + method: .GET, + requiresAuthentication: authenticate, + traktManager: traktManager + ) } - public func itemsOnList(_ listId: String, type: ListItemType? = nil) -> Route<[TraktListItem]> { - Route(paths: ["users/\(slug)/lists/\(listId)/items", type?.rawValue], method: .GET, traktManager: traktManager) + /** + Get all items on a personal list. Items can be a `movie`, `show`, `season`, `episode`, or `person`. You can optionally specify the `type` parameter with a single value or comma delimited string for multiple item types. + + **Type** + + Each list item contains a notes field with text entered by the user. + + **Sorting Headers** + + All list items are sorted by ascending `rank`. We also send `X-Sort-By` and `X-Sort-How` headers which can be used to custom sort the list in your app based on the user's preference. Values for `X-Sort-By` include `rank`, `added`, `title`, `released`, `runtime`, `popularity`, `percentage`, `votes`, `my_rating`, `random`, `watched`, and `collected`. Values for `X-Sort-How` include `asc` and `desc`. + */ + public func itemsOnList(_ listId: CustomStringConvertible, type: ListItemType? = nil) -> Route<[TraktListItem]> { + Route( + paths: [path, "lists", listId, "items", type?.rawValue], + method: .GET, + requiresAuthentication: authenticate, + traktManager: traktManager + ) } // MARK: - Follow diff --git a/Tests/TraktKitTests/Models/Users/test_get_user_watched_movies.json b/Tests/TraktKitTests/Models/Users/test_get_user_watched_movies.json index d77b51c..f7dbd67 100644 --- a/Tests/TraktKitTests/Models/Users/test_get_user_watched_movies.json +++ b/Tests/TraktKitTests/Models/Users/test_get_user_watched_movies.json @@ -1,30 +1,32 @@ [ - { - "plays" : 4, - "last_watched_at" : "2014-10-11T17:00:54.000Z", - "movie" : { - "title" : "Batman Begins", - "year" : 2005, - "ids" : { - "tmdb" : 272, - "slug" : "batman-begins-2005", - "trakt" : 6, - "imdb" : "tt0372784" - } + { + "plays": 4, + "last_watched_at": "2014-10-11T17:00:54.000Z", + "last_updated_at": "2014-10-11T17:00:54.000Z", + "movie": { + "title": "Batman Begins", + "year": 2005, + "ids": { + "trakt": 6, + "slug": "batman-begins-2005", + "imdb": "tt0372784", + "tmdb": 272 + } + } + }, + { + "plays": 2, + "last_watched_at": "2014-10-12T17:00:54.000Z", + "last_updated_at": "2014-10-12T17:00:54.000Z", + "movie": { + "title": "The Dark Knight", + "year": 2008, + "ids": { + "trakt": 4, + "slug": "the-dark-knight-2008", + "imdb": "tt0468569", + "tmdb": 155 + } + } } - }, - { - "plays" : 2, - "last_watched_at" : "2014-10-12T17:00:54.000Z", - "movie" : { - "title" : "The Dark Knight", - "year" : 2008, - "ids" : { - "tmdb" : 155, - "slug" : "the-dark-knight-2008", - "trakt" : 4, - "imdb" : "tt0468569" - } - } - } ] diff --git a/Tests/TraktKitTests/Models/Users/test_get_user_watched_shows.json b/Tests/TraktKitTests/Models/Users/test_get_user_watched_shows.json index ec160b1..b4cf8a4 100644 --- a/Tests/TraktKitTests/Models/Users/test_get_user_watched_shows.json +++ b/Tests/TraktKitTests/Models/Users/test_get_user_watched_shows.json @@ -1,97 +1,99 @@ [ - { - "last_watched_at" : "2014-10-11T17:00:54.000Z", - "seasons" : [ - { - "number" : 1, - "episodes" : [ - { - "number" : 1, - "plays" : 1, - "last_watched_at" : "2014-10-11T17:00:54.000Z" - }, - { - "number" : 2, - "plays" : 1, - "last_watched_at" : "2014-10-11T17:00:54.000Z" - } + { + "plays": 56, + "last_watched_at": "2014-10-11T17:00:54.000Z", + "last_updated_at": "2014-10-11T17:00:54.000Z", + "reset_at": null, + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "slug": "breaking-bad", + "tvdb": 81189, + "imdb": "tt0903747", + "tmdb": 1396 + } + }, + "seasons": [ + { + "number": 1, + "episodes": [ + { + "number": 1, + "plays": 1, + "last_watched_at": "2014-10-11T17:00:54.000Z" + }, + { + "number": 2, + "plays": 1, + "last_watched_at": "2014-10-11T17:00:54.000Z" + } + ] + }, + { + "number": 2, + "episodes": [ + { + "number": 1, + "plays": 1, + "last_watched_at": "2014-10-11T17:00:54.000Z" + }, + { + "number": 2, + "plays": 1, + "last_watched_at": "2014-10-11T17:00:54.000Z" + } + ] + } ] - }, - { - "number" : 2, - "episodes" : [ - { - "number" : 1, - "plays" : 1, - "last_watched_at" : "2014-10-11T17:00:54.000Z" - }, - { - "number" : 2, - "plays" : 1, - "last_watched_at" : "2014-10-11T17:00:54.000Z" - } - ] - } - ], - "show" : { - "title" : "Breaking Bad", - "year" : 2008, - "ids" : { - "tmdb" : 1396, - "slug" : "breaking-bad", - "tvdb" : 81189, - "trakt" : 1, - "imdb" : "tt0903747" - } }, - "plays" : 56 - }, - { - "last_watched_at" : "2014-10-12T17:00:54.000Z", - "seasons" : [ - { - "number" : 1, - "episodes" : [ - { - "number" : 1, - "plays" : 1, - "last_watched_at" : "2014-10-11T17:00:54.000Z" - }, - { - "number" : 2, - "plays" : 1, - "last_watched_at" : "2014-10-11T17:00:54.000Z" - } - ] - }, - { - "number" : 2, - "episodes" : [ - { - "number" : 1, - "plays" : 1, - "last_watched_at" : "2014-10-11T17:00:54.000Z" - }, - { - "number" : 2, - "plays" : 1, - "last_watched_at" : "2014-10-11T17:00:54.000Z" - } + { + "plays": 23, + "last_watched_at": "2014-10-12T17:00:54.000Z", + "last_updated_at": "2014-10-12T17:00:54.000Z", + "show": { + "title": "Parks and Recreation", + "year": 2009, + "ids": { + "trakt": 4, + "slug": "parks-and-recreation", + "tvdb": 84912, + "imdb": "tt1266020", + "tmdb": 8592 + } + }, + "seasons": [ + { + "number": 1, + "episodes": [ + { + "number": 1, + "plays": 1, + "last_watched_at": "2014-10-11T17:00:54.000Z" + }, + { + "number": 2, + "plays": 1, + "last_watched_at": "2014-10-11T17:00:54.000Z" + } + ] + }, + { + "number": 2, + "episodes": [ + { + "number": 1, + "plays": 1, + "last_watched_at": "2014-10-11T17:00:54.000Z" + }, + { + "number": 2, + "plays": 1, + "last_watched_at": "2014-10-11T17:00:54.000Z" + } + ] + } ] - } - ], - "show" : { - "title" : "Parks and Recreation", - "year" : 2009, - "ids" : { - "tmdb" : 8592, - "slug" : "parks-and-recreation", - "tvdb" : 84912, - "trakt" : 4, - "imdb" : "tt1266020" - } - }, - "plays" : 23 - } + } ] - diff --git a/Tests/TraktKitTests/Models/Users/test_get_user_watched_shows_no_seasons.json b/Tests/TraktKitTests/Models/Users/test_get_user_watched_shows_no_seasons.json index 4b63ce0..e3925a5 100644 --- a/Tests/TraktKitTests/Models/Users/test_get_user_watched_shows_no_seasons.json +++ b/Tests/TraktKitTests/Models/Users/test_get_user_watched_shows_no_seasons.json @@ -1,33 +1,36 @@ [ - { - "plays" : 56, - "last_watched_at" : "2014-10-11T17:00:54.000Z", - "show" : { - "title" : "Breaking Bad", - "year" : 2008, - "ids" : { - "tmdb" : 1396, - "slug" : "breaking-bad", - "tvdb" : 81189, - "trakt" : 1, - "imdb" : "tt0903747" - } + { + "plays": 56, + "last_watched_at": "2014-10-11T17:00:54.000Z", + "last_updated_at": "2014-10-11T17:00:54.000Z", + "reset_at": null, + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "slug": "breaking-bad", + "tvdb": 81189, + "imdb": "tt0903747", + "tmdb": 1396 + } + } + }, + { + "plays": 23, + "last_watched_at": "2014-10-12T17:00:54.000Z", + "last_updated_at": "2014-10-12T17:00:54.000Z", + "reset_at": "2019-10-12T17:00:54.000Z", + "show": { + "title": "Parks and Recreation", + "year": 2009, + "ids": { + "trakt": 4, + "slug": "parks-and-recreation", + "tvdb": 84912, + "imdb": "tt1266020", + "tmdb": 8592 + } + } } - }, - { - "plays" : 23, - "last_watched_at" : "2014-10-12T17:00:54.000Z", - "show" : { - "title" : "Parks and Recreation", - "year" : 2009, - "ids" : { - "tmdb" : 8592, - "slug" : "parks-and-recreation", - "tvdb" : 84912, - "trakt" : 4, - "imdb" : "tt1266020" - } - } - } ] - diff --git a/Tests/TraktKitTests/UserTests+Async.swift b/Tests/TraktKitTests/UserTests+Async.swift index bb4d120..aae6355 100644 --- a/Tests/TraktKitTests/UserTests+Async.swift +++ b/Tests/TraktKitTests/UserTests+Async.swift @@ -130,7 +130,7 @@ extension TraktTestSuite { @Test func getFullProfile() async throws { let traktManager = await authenticatedTraktManager() - try mock(.GET, "https://api.trakt.tv/users/me?extended=full", result: .success(jsonData(named: "test_get_full_profile"))) + try mock(.GET, "https://api.trakt.tv/users/me?extended=full", result: .success(jsonData(named: "test_get_full_profile")), replace: true) let profile = try await traktManager.currentUser() .profile() @@ -151,7 +151,7 @@ extension TraktTestSuite { @Test func getVIPProfile() async throws { let traktManager = await authenticatedTraktManager() - try mock(.GET, "https://api.trakt.tv/users/me?extended=full", result: .success(jsonData(named: "test_get_VIP_profile"))) + try mock(.GET, "https://api.trakt.tv/users/me?extended=full", result: .success(jsonData(named: "test_get_VIP_profile")), replace: true) let profile = try await traktManager.currentUser() .profile() From 318f13bcb16891213e23e38f84bd5b08a36903cc Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sun, 9 Mar 2025 08:17:08 -0400 Subject: [PATCH 30/38] Replace generic use of CustomStringConvertible --- Common/Models/Users/TraktList.swift | 4 +-- .../CompletionHandlerEndpoints/Comments.swift | 18 ++++++------ .../CompletionHandlerEndpoints/Episodes.swift | 16 +++++------ .../SharedFunctions.swift | 18 ++++++------ .../CompletionHandlerEndpoints/Shows.swift | 28 +++++++++---------- .../CompletionHandlerEndpoints/Users.swift | 12 ++++---- 6 files changed, 48 insertions(+), 48 deletions(-) diff --git a/Common/Models/Users/TraktList.swift b/Common/Models/Users/TraktList.swift index 3b9ac6c..94e0af4 100644 --- a/Common/Models/Users/TraktList.swift +++ b/Common/Models/Users/TraktList.swift @@ -28,7 +28,7 @@ public struct TraktNewList: TraktObject { public let sortBy: String? public let sortHow: String? - init( + public init( name: String, description: String? = nil, privacy: ListPrivacy? = nil, @@ -66,7 +66,7 @@ public struct TraktUpdateList: TraktObject { public let sortBy: String? public let sortHow: String? - init( + public init( name: String? = nil, description: String? = nil, privacy: ListPrivacy? = nil, diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Comments.swift b/Common/Wrapper/CompletionHandlerEndpoints/Comments.swift index 0f440ff..53a88f5 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Comments.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Comments.swift @@ -28,7 +28,7 @@ extension TraktManager { Returns a single comment and indicates how many replies it has. Use **GET** `/comments/:id/replies` to get the actual replies. */ @discardableResult - public func getComment(commentID id: T, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { + public func getComment(commentID id: CustomStringConvertible, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let request = try mutableRequest(forPath: "comments/\(id)", withQuery: [:], isAuthorized: false, @@ -42,7 +42,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func updateComment(commentID id: T, newComment comment: String, isSpoiler spoiler: Bool? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { + public func updateComment(commentID id: CustomStringConvertible, newComment comment: String, isSpoiler spoiler: Bool? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktCommentBody(comment: comment, spoiler: spoiler) var request = try mutableRequest(forPath: "comments/\(id)", withQuery: [:], @@ -58,7 +58,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func deleteComment(commentID id: T, completion: @escaping SuccessCompletionHandler) throws -> URLSessionDataTask? { + public func deleteComment(commentID id: CustomStringConvertible, completion: @escaping SuccessCompletionHandler) throws -> URLSessionDataTask? { let request = try mutableRequest(forPath: "comments/\(id)", withQuery: [:], isAuthorized: true, @@ -74,7 +74,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getReplies(commentID id: T, completion: @escaping ObjectCompletionHandler<[Comment]>) throws -> URLSessionDataTask? { + public func getReplies(commentID id: CustomStringConvertible, completion: @escaping ObjectCompletionHandler<[Comment]>) throws -> URLSessionDataTask? { let request = try mutableRequest(forPath: "comments/\(id)/replies", withQuery: [:], isAuthorized: false, @@ -88,7 +88,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func postReply(commentID id: T, comment: String, isSpoiler spoiler: Bool? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { + public func postReply(commentID id: CustomStringConvertible, comment: String, isSpoiler spoiler: Bool? = nil, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let body = TraktCommentBody(comment: comment, spoiler: spoiler) let request = try post("comments/\(id)/replies", body: body) return performRequest(request: request, completion: completion) @@ -102,7 +102,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getAttachedMediaItem(commentID id: T, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { + public func getAttachedMediaItem(commentID id: CustomStringConvertible, completion: @escaping ObjectCompletionHandler) throws -> URLSessionDataTask? { let request = try mutableRequest(forPath: "comments/\(id)/item", withQuery: [:], isAuthorized: true, @@ -118,7 +118,7 @@ extension TraktManager { ✨ Extended Info */ @discardableResult - public func getUsersWhoLikedComment(commentID id: T, completion: @escaping ObjectCompletionHandler<[TraktCommentLikedUser]>) throws -> URLSessionDataTask? { + public func getUsersWhoLikedComment(commentID id: CustomStringConvertible, completion: @escaping ObjectCompletionHandler<[TraktCommentLikedUser]>) throws -> URLSessionDataTask? { let request = try mutableRequest(forPath: "comments/\(id)/likes", withQuery: [:], isAuthorized: true, @@ -134,7 +134,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func likeComment(commentID id: T, completion: @escaping SuccessCompletionHandler) throws -> URLSessionDataTask? { + public func likeComment(commentID id: CustomStringConvertible, completion: @escaping SuccessCompletionHandler) throws -> URLSessionDataTask? { let request = try mutableRequest(forPath: "comments/\(id)/like", withQuery: [:], isAuthorized: false, @@ -148,7 +148,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func removeLikeOnComment(commentID id: T, completion: @escaping SuccessCompletionHandler) throws -> URLSessionDataTask? { + public func removeLikeOnComment(commentID id: CustomStringConvertible, completion: @escaping SuccessCompletionHandler) throws -> URLSessionDataTask? { let request = try mutableRequest(forPath: "comments/\(id)/like", withQuery: [:], isAuthorized: false, diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Episodes.swift b/Common/Wrapper/CompletionHandlerEndpoints/Episodes.swift index 50836cb..a5acb14 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Episodes.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Episodes.swift @@ -18,7 +18,7 @@ extension TraktManager { **Note**: If the `first_aired` is unknown, it will be set to `null`. */ @discardableResult - public func getEpisodeSummary(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { + public func getEpisodeSummary(showID id: CustomStringConvertible, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)", withQuery: ["extended": extended.queryString()], isAuthorized: false, @@ -38,7 +38,7 @@ extension TraktManager { - parameter language: 2 character language code */ @discardableResult - public func getEpisodeTranslations(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, language: String? = nil, completion: @escaping ObjectCompletionHandler<[TraktEpisodeTranslation]>) -> URLSessionDataTask? { + public func getEpisodeTranslations(showID id: CustomStringConvertible, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, language: String? = nil, completion: @escaping ObjectCompletionHandler<[TraktEpisodeTranslation]>) -> URLSessionDataTask? { var path = "shows/\(id)/seasons/\(season)/episodes/\(episode)/translations" if let language = language { path += "/\(language)" @@ -60,7 +60,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getEpisodeComments(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, pagination: Pagination? = nil, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTask? { + public func getEpisodeComments(showID id: CustomStringConvertible, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, pagination: Pagination? = nil, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = [:] @@ -87,7 +87,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getListsContainingEpisode(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, listType: ListType? = nil, sortBy: ListSortType? = nil, pagination: Pagination? = nil, completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { + public func getListsContainingEpisode(showID id: CustomStringConvertible, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, listType: ListType? = nil, sortBy: ListSortType? = nil, pagination: Pagination? = nil, completion: @escaping paginatedCompletionHandler) -> URLSessionDataTask? { var path = "shows/\(id)/seasons/\(season)/episodes/\(episode)/lists" if let listType = listType { path += "/\(listType)" @@ -120,7 +120,7 @@ extension TraktManager { Returns rating (between 0 and 10) and distribution for an episode. */ @discardableResult - public func getEpisodeRatings(showID id: T, seasonNumber: NSNumber, episodeNumber: NSNumber, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { + public func getEpisodeRatings(showID id: CustomStringConvertible, seasonNumber: NSNumber, episodeNumber: NSNumber, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(seasonNumber)/episodes/\(episodeNumber)/ratings", withQuery: [:], isAuthorized: false, @@ -135,7 +135,7 @@ extension TraktManager { Returns lots of episode stats. */ @discardableResult - public func getEpisodeStatistics(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, completion: @escaping statsCompletionHandler) -> URLSessionDataTask? { + public func getEpisodeStatistics(showID id: CustomStringConvertible, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, completion: @escaping statsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)/stats", withQuery: [:], isAuthorized: false, @@ -150,7 +150,7 @@ extension TraktManager { Returns all users watching this episode right now. */ @discardableResult - public func getUsersWatchingEpisode(showID id: T, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, completion: @escaping ObjectCompletionHandler<[User]>) -> URLSessionDataTask? { + public func getUsersWatchingEpisode(showID id: CustomStringConvertible, seasonNumber season: NSNumber, episodeNumber episode: NSNumber, completion: @escaping ObjectCompletionHandler<[User]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)/watching", withQuery: [:], isAuthorized: false, @@ -173,7 +173,7 @@ extension TraktManager { **Note**: This returns a lot of data, so please only use this extended parameter if you actually need it! */ @discardableResult - public func getPeopleInEpisode(showID id: T, season: NSNumber, episode: NSNumber, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTask? { + public func getPeopleInEpisode(showID id: CustomStringConvertible, season: NSNumber, episode: NSNumber, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/seasons/\(season)/episodes/\(episode)/people", withQuery: ["extended": extended.queryString()], isAuthorized: false, diff --git a/Common/Wrapper/CompletionHandlerEndpoints/SharedFunctions.swift b/Common/Wrapper/CompletionHandlerEndpoints/SharedFunctions.swift index 0a80316..cea36eb 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/SharedFunctions.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/SharedFunctions.swift @@ -176,7 +176,7 @@ internal extension TraktManager { // MARK: - Summary - func getSummary(_ type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { + func getSummary(_ type: WatchedType, id: CustomStringConvertible, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "\(type)/\(id)", withQuery: ["extended": extended.queryString()], isAuthorized: false, @@ -187,7 +187,7 @@ internal extension TraktManager { // MARK: - Aliases - func getAliases(_ type: WatchedType, id: T, completion: @escaping AliasCompletionHandler) -> URLSessionDataTask? { + func getAliases(_ type: WatchedType, id: CustomStringConvertible, completion: @escaping AliasCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "\(type)/\(id)/aliases", withQuery: [:], isAuthorized: false, @@ -197,7 +197,7 @@ internal extension TraktManager { // MARK: - Translations - func getTranslations(_ type: WatchedType, id: T, language: String?, completion: @escaping ObjectCompletionHandler<[U]>) -> URLSessionDataTask? { + func getTranslations(_ type: WatchedType, id: CustomStringConvertible, language: String?, completion: @escaping ObjectCompletionHandler<[U]>) -> URLSessionDataTask? { var path = "\(type)/\(id)/translations" if let language = language { @@ -213,7 +213,7 @@ internal extension TraktManager { // MARK: - Comments - func getComments(_ type: WatchedType, id: T, pagination: Pagination?, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTask? { + func getComments(_ type: WatchedType, id: CustomStringConvertible, pagination: Pagination?, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTask? { var query: [String: String] = [:] @@ -234,7 +234,7 @@ internal extension TraktManager { // MARK: - People - func getPeople(_ type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTask? { + func getPeople(_ type: WatchedType, id: CustomStringConvertible, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "\(type)/\(id)/people", withQuery: ["extended": extended.queryString()], isAuthorized: false, @@ -245,7 +245,7 @@ internal extension TraktManager { // MARK: - Ratings - func getRatings(_ type: WatchedType, id: T, completion: @escaping RatingDistributionCompletionHandler) -> URLSessionDataTask? { + func getRatings(_ type: WatchedType, id: CustomStringConvertible, completion: @escaping RatingDistributionCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "\(type)/\(id)/ratings", withQuery: [:], isAuthorized: false, @@ -256,7 +256,7 @@ internal extension TraktManager { // MARK: - Related - func getRelated(_ type: WatchedType, id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler<[U]>) -> URLSessionDataTask? { + func getRelated(_ type: WatchedType, id: CustomStringConvertible, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler<[U]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "\(type)/\(id)/related", withQuery: ["extended": extended.queryString()], isAuthorized: false, @@ -267,7 +267,7 @@ internal extension TraktManager { // MARK: - Stats - func getStatistics(_ type: WatchedType, id: T, completion: @escaping statsCompletionHandler) -> URLSessionDataTask? { + func getStatistics(_ type: WatchedType, id: CustomStringConvertible, completion: @escaping statsCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "\(type)/\(id)/stats", withQuery: [:], isAuthorized: false, @@ -278,7 +278,7 @@ internal extension TraktManager { // MARK: - Watching - func getUsersWatching(_ type: WatchedType, id: T, completion: @escaping ObjectCompletionHandler<[User]>) -> URLSessionDataTask? { + func getUsersWatching(_ type: WatchedType, id: CustomStringConvertible, completion: @escaping ObjectCompletionHandler<[User]>) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "\(type)/\(id)/watching", withQuery: [:], isAuthorized: false, diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Shows.swift b/Common/Wrapper/CompletionHandlerEndpoints/Shows.swift index 26e28b2..319be45 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Shows.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Shows.swift @@ -127,7 +127,7 @@ extension TraktManager { **Note**: When getting `full` extended info, the `status` field can have a value of `returning series` (airing right now), `in production` (airing soon), `planned` (in development), `canceled`, or `ended`. */ @discardableResult - public func getShowSummary(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { + public func getShowSummary(showID id: CustomStringConvertible, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { return getSummary(.Shows, id: id, extended: extended, completion: completion) } @@ -139,7 +139,7 @@ extension TraktManager { - parameter id: Trakt.tv ID, Trakt.tv slug, or IMDB ID */ @discardableResult - public func getShowAliases(showID id: T, completion: @escaping ObjectCompletionHandler<[Alias]>) -> URLSessionDataTask? { + public func getShowAliases(showID id: CustomStringConvertible, completion: @escaping ObjectCompletionHandler<[Alias]>) -> URLSessionDataTask? { return getAliases(.Shows, id: id, completion: completion) } @@ -152,7 +152,7 @@ extension TraktManager { - parameter language: 2 character language code. Example: `es` */ @discardableResult - public func getShowTranslations(showID id: T, language: String?, completion: @escaping ShowTranslationsCompletionHandler) -> URLSessionDataTask? { + public func getShowTranslations(showID id: CustomStringConvertible, language: String?, completion: @escaping ShowTranslationsCompletionHandler) -> URLSessionDataTask? { return getTranslations(.Shows, id: id, language: language, completion: completion) } @@ -164,7 +164,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getShowComments(showID id: T, pagination: Pagination? = nil, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTask? { + public func getShowComments(showID id: CustomStringConvertible, pagination: Pagination? = nil, completion: @escaping CommentsCompletionHandler) -> URLSessionDataTask? { return getComments(.Shows, id: id, pagination: pagination, completion: completion) } @@ -176,7 +176,7 @@ extension TraktManager { 📄 Pagination */ @discardableResult - public func getListsContainingShow(showID id: T, listType: ListType? = nil, sortBy: ListSortType? = nil, pagination: Pagination? = nil, completion: @escaping ObjectCompletionHandler<[TraktList]>) -> URLSessionDataTask? { + public func getListsContainingShow(showID id: CustomStringConvertible, listType: ListType? = nil, sortBy: ListSortType? = nil, pagination: Pagination? = nil, completion: @escaping ObjectCompletionHandler<[TraktList]>) -> URLSessionDataTask? { var path = "shows/\(id)/lists" if let listType = listType { path += "/\(listType)" @@ -211,7 +211,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func getShowCollectionProgress(showID id: T, hidden: Bool = false, specials: Bool = false, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { + public func getShowCollectionProgress(showID id: CustomStringConvertible, hidden: Bool = false, specials: Bool = false, completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/progress/collection", withQuery: ["hidden": "\(hidden)", @@ -230,7 +230,7 @@ extension TraktManager { 🔒 OAuth: Required */ @discardableResult - public func getShowWatchedProgress(showID id: T, hidden: Bool = false, specials: Bool = false, completion: @escaping ShowWatchedProgressCompletionHandler) -> URLSessionDataTask? { + public func getShowWatchedProgress(showID id: CustomStringConvertible, hidden: Bool = false, specials: Bool = false, completion: @escaping ShowWatchedProgressCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/progress/watched", withQuery: ["hidden": "\(hidden)", @@ -255,7 +255,7 @@ extension TraktManager { **Note**: This returns a lot of data, so please only use this extended parameter if you actually need it! */ @discardableResult - public func getPeopleInShow(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTask? { + public func getPeopleInShow(showID id: CustomStringConvertible, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler>) -> URLSessionDataTask? { return getPeople(.Shows, id: id, extended: extended, completion: completion) } @@ -265,7 +265,7 @@ extension TraktManager { Returns rating (between 0 and 10) and distribution for a show. */ @discardableResult - public func getShowRatings(showID id: T, completion: @escaping RatingDistributionCompletionHandler) -> URLSessionDataTask? { + public func getShowRatings(showID id: CustomStringConvertible, completion: @escaping RatingDistributionCompletionHandler) -> URLSessionDataTask? { return getRatings(.Shows, id: id, completion: completion) } @@ -277,7 +277,7 @@ extension TraktManager { **Note**: We are continuing to improve this algorithm. */ @discardableResult - public func getRelatedShows(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler<[TraktShow]>) -> URLSessionDataTask? { + public func getRelatedShows(showID id: CustomStringConvertible, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler<[TraktShow]>) -> URLSessionDataTask? { return getRelated(.Shows, id: id, extended: extended, completion: completion) } @@ -287,7 +287,7 @@ extension TraktManager { Returns lots of show stats. */ @discardableResult - public func getShowStatistics(showID id: T, completion: @escaping statsCompletionHandler) -> URLSessionDataTask? { + public func getShowStatistics(showID id: CustomStringConvertible, completion: @escaping statsCompletionHandler) -> URLSessionDataTask? { return getStatistics(.Shows, id: id, completion: completion) } @@ -297,7 +297,7 @@ extension TraktManager { Returns all users watching this show right now. */ @discardableResult - public func getUsersWatchingShow(showID id: T, completion: @escaping ObjectCompletionHandler<[User]>) -> URLSessionDataTask? { + public func getUsersWatchingShow(showID id: CustomStringConvertible, completion: @escaping ObjectCompletionHandler<[User]>) -> URLSessionDataTask? { return getUsersWatching(.Shows, id: id, completion: completion) } @@ -309,7 +309,7 @@ extension TraktManager { **Note**: If no episode is found, a 204 HTTP status code will be returned. */ @discardableResult - public func getNextEpisode(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { + public func getNextEpisode(showID id: CustomStringConvertible, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/next_episode", withQuery: ["extended": extended.queryString()], isAuthorized: false, @@ -326,7 +326,7 @@ extension TraktManager { **Note**: If no episode is found, a 204 HTTP status code will be returned. */ @discardableResult - public func getLastEpisode(showID id: T, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { + public func getLastEpisode(showID id: CustomStringConvertible, extended: [ExtendedType] = [.Min], completion: @escaping ObjectCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "shows/\(id)/last_episode", withQuery: ["extended": extended.queryString()], isAuthorized: false, diff --git a/Common/Wrapper/CompletionHandlerEndpoints/Users.swift b/Common/Wrapper/CompletionHandlerEndpoints/Users.swift index cb247fc..b748647 100644 --- a/Common/Wrapper/CompletionHandlerEndpoints/Users.swift +++ b/Common/Wrapper/CompletionHandlerEndpoints/Users.swift @@ -328,7 +328,7 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func deleteCustomList(username: String = "me", listID: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { + public func deleteCustomList(username: String = "me", listID: CustomStringConvertible, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "users/\(username)/lists/\(listID)", withQuery: [:], @@ -345,7 +345,7 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func likeList(username: String = "me", listID: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { + public func likeList(username: String = "me", listID: CustomStringConvertible, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "users/\(username)/lists/\(listID)/like", withQuery: [:], @@ -360,7 +360,7 @@ extension TraktManager { 🔒 OAuth Required */ @discardableResult - public func removeListLike(username: String = "me", listID: T, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { + public func removeListLike(username: String = "me", listID: CustomStringConvertible, completion: @escaping SuccessCompletionHandler) -> URLSessionDataTask? { guard let request = try? mutableRequest(forPath: "users/\(username)/lists/\(listID)/like", withQuery: [:], @@ -377,7 +377,7 @@ extension TraktManager { 🔓 OAuth Optional */ @discardableResult - public func getItemsForCustomList(username: String = "me", listID: T, type: [ListItemType]? = nil, extended: [ExtendedType] = [.Min], completion: @escaping ListItemCompletionHandler) -> URLSessionDataTask? { + public func getItemsForCustomList(username: String = "me", listID: CustomStringConvertible, type: [ListItemType]? = nil, extended: [ExtendedType] = [.Min], completion: @escaping ListItemCompletionHandler) -> URLSessionDataTask? { let authorization = username == "me" ? true : false var path = "users/\(username)/lists/\(listID)/items" @@ -405,7 +405,7 @@ extension TraktManager { - parameter people: Array of people Trakt ids */ @discardableResult - public func addItemToCustomList(username: String = "me", listID: T, movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, people: [SyncId]? = nil, completion: @escaping AddListItemCompletion) throws -> URLSessionDataTask? { + public func addItemToCustomList(username: String = "me", listID: CustomStringConvertible, movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, people: [SyncId]? = nil, completion: @escaping AddListItemCompletion) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes, people: people) let request = try post("users/\(username)/lists/\(listID)/items", body: body) return performRequest(request: request, completion: completion) @@ -425,7 +425,7 @@ extension TraktManager { - parameter people: Array of people Trakt ids */ @discardableResult - public func removeItemFromCustomList(username: String = "me", listID: T, movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, people: [SyncId]? = nil, completion: @escaping RemoveListItemCompletion) throws -> URLSessionDataTask? { + public func removeItemFromCustomList(username: String = "me", listID: CustomStringConvertible, movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, people: [SyncId]? = nil, completion: @escaping RemoveListItemCompletion) throws -> URLSessionDataTask? { let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes, people: people) let request = try post("users/\(username)/lists/\(listID)/items/remove", body: body) return performRequest(request: request, completion: completion) From ba417820464e2738a65f291da554303dedc91499 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sun, 9 Mar 2025 10:50:40 -0400 Subject: [PATCH 31/38] Make pagination functions public --- Common/Wrapper/Route.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Common/Wrapper/Route.swift b/Common/Wrapper/Route.swift index c7b0dd9..d691b5c 100644 --- a/Common/Wrapper/Route.swift +++ b/Common/Wrapper/Route.swift @@ -241,7 +241,7 @@ public struct PagedObject: PagedObjectProtocol, TraktOb extension Route where T: PagedObjectProtocol { /// Fetches all pages for a paginated endpoint, and returns the data in a Set. - func fetchAllPages() async throws -> Set where T.Type == PagedObject<[Element]>.Type { + public func fetchAllPages() async throws -> Set where T.Type == PagedObject<[Element]>.Type { // Fetch first page let firstPage = try await self.page(1).perform() var resultSet = Set(firstPage.object) @@ -287,7 +287,7 @@ extension Route where T: PagedObjectProtocol { } /// Stream paged results one at a time - func pagedResults() -> AsyncThrowingStream<[Element], Error> where T.Type == PagedObject<[Element]>.Type { + public func pagedResults() -> AsyncThrowingStream<[Element], Error> where T.Type == PagedObject<[Element]>.Type { AsyncThrowingStream { continuation in let task = Task { do { From 4fec2020c1e254809a13fb4aa5a7f5fff641bff6 Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sun, 23 Mar 2025 10:12:38 -0400 Subject: [PATCH 32/38] Fix missing body for creating custom lists --- Common/Wrapper/Resources/UserResource.swift | 8 +++++++- Common/Wrapper/Route.swift | 8 ++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Common/Wrapper/Resources/UserResource.swift b/Common/Wrapper/Resources/UserResource.swift index 105c95f..9cf0ed2 100644 --- a/Common/Wrapper/Resources/UserResource.swift +++ b/Common/Wrapper/Resources/UserResource.swift @@ -138,7 +138,13 @@ extension TraktManager { 🔥 VIP Enhanced 🔒 OAuth Required */ public func createPersonalList(_ body: TraktNewList) -> Route { - Route(paths: [path, Self.currentUserSlug, "lists"], method: .POST, requiresAuthentication: true, traktManager: traktManager) + Route( + paths: [path, Self.currentUserSlug, "lists"], + body: body, + method: .POST, + requiresAuthentication: true, + traktManager: traktManager + ) } /** diff --git a/Common/Wrapper/Route.swift b/Common/Wrapper/Route.swift index d691b5c..df83995 100644 --- a/Common/Wrapper/Route.swift +++ b/Common/Wrapper/Route.swift @@ -241,7 +241,7 @@ public struct PagedObject: PagedObjectProtocol, TraktOb extension Route where T: PagedObjectProtocol { /// Fetches all pages for a paginated endpoint, and returns the data in a Set. - public func fetchAllPages() async throws -> Set where T.Type == PagedObject<[Element]>.Type { + public func fetchAllPages(maxConcurrentRequests preferredMaxConcurrentRequests: Int = 10) async throws -> Set where T.Type == PagedObject<[Element]>.Type { // Fetch first page let firstPage = try await self.page(1).perform() var resultSet = Set(firstPage.object) @@ -249,7 +249,7 @@ extension Route where T: PagedObjectProtocol { // Return early if there's only one page guard firstPage.pageCount > 1 else { return resultSet } resultSet = await withTaskGroup(of: [Element].self, returning: Set.self) { group in - let maxConcurrentRequests = min(firstPage.pageCount - 1, 10) + let maxConcurrentRequests = min(firstPage.pageCount - 1, preferredMaxConcurrentRequests) let pages = 2...firstPage.pageCount let indexStream = AsyncStream { continuation in @@ -287,7 +287,7 @@ extension Route where T: PagedObjectProtocol { } /// Stream paged results one at a time - public func pagedResults() -> AsyncThrowingStream<[Element], Error> where T.Type == PagedObject<[Element]>.Type { + public func pagedResults(maxConcurrentRequests preferredMaxConcurrentRequests: Int = 10) -> AsyncThrowingStream<[Element], Error> where T.Type == PagedObject<[Element]>.Type { AsyncThrowingStream { continuation in let task = Task { do { @@ -301,7 +301,7 @@ extension Route where T: PagedObjectProtocol { } // Use a semaphore to limit concurrency - let semaphore = AsyncSemaphore(value: 10) + let semaphore = AsyncSemaphore(value: preferredMaxConcurrentRequests) let pages = 2...firstPage.pageCount try await withThrowingTaskGroup(of: (Int, [Element]).self) { group in From 4a1195a7bff741f86804a5d13b3e0da55214eadb Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sun, 23 Mar 2025 10:57:31 -0400 Subject: [PATCH 33/38] Add list endpoints --- Common/Wrapper/Resources/UserResource.swift | 130 ++++++++++++++++++-- Tests/TraktKitTests/UserTests+Async.swift | 55 +++++++++ 2 files changed, 177 insertions(+), 8 deletions(-) diff --git a/Common/Wrapper/Resources/UserResource.swift b/Common/Wrapper/Resources/UserResource.swift index 9cf0ed2..101111f 100644 --- a/Common/Wrapper/Resources/UserResource.swift +++ b/Common/Wrapper/Resources/UserResource.swift @@ -168,6 +168,17 @@ extension TraktManager { // MARK: - List + /** + Returns a single personal list. Use the ``TraktManager/UsersResource/itemsOnList(_:type:)`` method to get the actual items this list contains. + + 🔓 OAuth Optional 😁 Emojis + + - parameter listId: Trakt ID or Trakt slug + */ + public func personalList(_ listId: CustomStringConvertible) -> Route { + UsersResource(slug: Self.currentUserSlug, traktManager: traktManager).personalList(listId) + } + /** Update a personal list by sending 1 or more parameters. If you update the list name, the original slug will still be retained so existing references to this list won't break. @@ -197,6 +208,59 @@ extension TraktManager { ) } + // MARK: - List Items + + /** + Get all items on a personal list. Items can be a `movie`, `show`, `season`, `episode`, or `person`. You can optionally specify the type parameter with a single value or comma delimited string for multiple item types. + + **Notes** + + Each list item contains a `notes` field with text entered by the user. + + **Sorting** + + Default sorting is based on the list defaults and sent in the `X-Sort-By` and `X-Sort-How` headers. If you specify the `sort_by` and `sort_how` parameters, the response will be sorted based on those values and sent in the `X-Applied-Sort-By` and` X-Applied-Sort-How` headers. + + Some sort_by options are 🔥 **VIP Only** including `imdb_rating`, `tmdb_rating`, `rt_tomatometer`, `rt_audience`, `metascore`, `votes`, `imdb_votes`, and `tmdb_votes`. If sent for a non VIP, the items will fall back to `rank`. + + 🔥 VIP Enhanced 🔓 OAuth Optional 📄 Pagination Optional ✨ Extended Info 😁 Emojis + + - parameters: + - listId: Trakt ID or Trakt slug + - type: Filter for a specific item type. Possible values: `movie` , `show` , `season` , `episode` , `person` . + - sortBy: Sort by a specific property. Possible values: `rank` , `added` , `title` , `released` , `runtime` , `popularity` , `random` , `percentage` , `imdb_rating` , `tmdb_rating` , `rt_tomatometer` , `rt_audience` , `metascore` , `votes` , `imdb_votes` , `tmdb_votes` , `my_rating` , `watched` , `collected` . + - sortHow: Sort direction. Possible values: `asc` , `desc` . + */ + public func itemsOnList(_ listId: CustomStringConvertible, type: ListItemType? = nil, sortBy: String? = nil, sortHow: String? = nil) -> Route> { + UsersResource(slug: Self.currentUserSlug, traktManager: traktManager).itemsOnList(listId, type: type, sortBy: sortBy, sortHow: sortHow) + } + + /** + Add one or more items to a personal list. Items can be movies, shows, seasons, episodes, or people. + + **Notes** + + Each list item can optionally accept a `notes` (500 maximum characters) field with custom text. The user must be a Trakt VIP to send notes. + + **Limits** + + If the user's list item limit is exceeded, a `420` HTTP error code is returned. Use the /users/settings method to get all limits for a user account. In most cases, upgrading to Trakt VIP will increase the limits. + + 🔥 VIP Enhanced 🔒 OAuth Required 😁 Emojis + */ + public func addItemsToList(_ listId: CustomStringConvertible, movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, people: [SyncId]? = nil) -> Route { + UsersResource(slug: Self.currentUserSlug, traktManager: traktManager).addItemsToList(listId, movies: movies, shows: shows, seasons: seasons, episodes: episodes, people: people) + } + + /** + Remove one or more items from a personal list. + + 🔒 OAuth Required + */ + public func removeItemsFromList(_ listId: CustomStringConvertible, movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, people: [SyncId]? = nil) -> Route { + UsersResource(slug: Self.currentUserSlug, traktManager: traktManager).removeItemsFromList(listId, movies: movies, shows: shows, seasons: seasons, episodes: episodes, people: people) + } + } /// Resource containing all of the `/user/*` endpoints where authentication is **optional** or **not** required. @@ -297,7 +361,7 @@ extension TraktManager { // MARK: - List /** - Returns a single personal list. Use the /users/:id/lists/:list_id/items method to get the actual items this list contains. + Returns a single personal list. Use the ``TraktManager/UsersResource/itemsOnList(_:type:)`` method to get the actual items this list contains. 🔓 OAuth Optional 😁 Emojis @@ -313,25 +377,75 @@ extension TraktManager { } /** - Get all items on a personal list. Items can be a `movie`, `show`, `season`, `episode`, or `person`. You can optionally specify the `type` parameter with a single value or comma delimited string for multiple item types. + Get all items on a personal list. Items can be a `movie`, `show`, `season`, `episode`, or `person`. You can optionally specify the type parameter with a single value or comma delimited string for multiple item types. - **Type** + **Notes** - Each list item contains a notes field with text entered by the user. + Each list item contains a `notes` field with text entered by the user. - **Sorting Headers** + **Sorting** + + Default sorting is based on the list defaults and sent in the `X-Sort-By` and `X-Sort-How` headers. If you specify the `sort_by` and `sort_how` parameters, the response will be sorted based on those values and sent in the `X-Applied-Sort-By` and` X-Applied-Sort-How` headers. - All list items are sorted by ascending `rank`. We also send `X-Sort-By` and `X-Sort-How` headers which can be used to custom sort the list in your app based on the user's preference. Values for `X-Sort-By` include `rank`, `added`, `title`, `released`, `runtime`, `popularity`, `percentage`, `votes`, `my_rating`, `random`, `watched`, and `collected`. Values for `X-Sort-How` include `asc` and `desc`. + Some sort_by options are 🔥 **VIP Only** including `imdb_rating`, `tmdb_rating`, `rt_tomatometer`, `rt_audience`, `metascore`, `votes`, `imdb_votes`, and `tmdb_votes`. If sent for a non VIP, the items will fall back to `rank`. + + 🔥 VIP Enhanced 🔓 OAuth Optional 📄 Pagination Optional ✨ Extended Info 😁 Emojis + + - parameters: + - listId: Trakt ID or Trakt slug + - type: Filter for a specific item type. Possible values: `movie` , `show` , `season` , `episode` , `person` . + - sortBy: Sort by a specific property. Possible values: `rank` , `added` , `title` , `released` , `runtime` , `popularity` , `random` , `percentage` , `imdb_rating` , `tmdb_rating` , `rt_tomatometer` , `rt_audience` , `metascore` , `votes` , `imdb_votes` , `tmdb_votes` , `my_rating` , `watched` , `collected` . + - sortHow: Sort direction. Possible values: `asc` , `desc` . */ - public func itemsOnList(_ listId: CustomStringConvertible, type: ListItemType? = nil) -> Route<[TraktListItem]> { + public func itemsOnList(_ listId: CustomStringConvertible, type: ListItemType? = nil, sortBy: String? = nil, sortHow: String? = nil) -> Route> { Route( - paths: [path, "lists", listId, "items", type?.rawValue], + paths: [path, "lists", listId, "items", type?.rawValue, sortBy, sortHow], method: .GET, requiresAuthentication: authenticate, traktManager: traktManager ) } + /** + Add one or more items to a personal list. Items can be movies, shows, seasons, episodes, or people. + + **Notes** + + Each list item can optionally accept a `notes` (500 maximum characters) field with custom text. The user must be a Trakt VIP to send notes. + + **Limits** + + If the user's list item limit is exceeded, a `420` HTTP error code is returned. Use the /users/settings method to get all limits for a user account. In most cases, upgrading to Trakt VIP will increase the limits. + + 🔥 VIP Enhanced 🔒 OAuth Required 😁 Emojis + */ + public func addItemsToList(_ listId: CustomStringConvertible, movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, people: [SyncId]? = nil) -> Route { + let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes, people: people) + return Route( + paths: [path, "lists", listId, "items"], + body: body, + method: .POST, + requiresAuthentication: true, + traktManager: traktManager + ) + } + + /** + Remove one or more items from a personal list. + + 🔒 OAuth Required + */ + public func removeItemsFromList(_ listId: CustomStringConvertible, movies: [SyncId]? = nil, shows: [SyncId]? = nil, seasons: [SyncId]? = nil, episodes: [SyncId]? = nil, people: [SyncId]? = nil) -> Route { + let body = TraktMediaBody(movies: movies, shows: shows, seasons: seasons, episodes: episodes, people: people) + return Route( + paths: [path, "lists", listId, "items", "remove"], + body: body, + method: .POST, + requiresAuthentication: true, + traktManager: traktManager + ) + } + // MARK: - Follow // MARK: - Followers diff --git a/Tests/TraktKitTests/UserTests+Async.swift b/Tests/TraktKitTests/UserTests+Async.swift index aae6355..2b67912 100644 --- a/Tests/TraktKitTests/UserTests+Async.swift +++ b/Tests/TraktKitTests/UserTests+Async.swift @@ -206,6 +206,61 @@ extension TraktTestSuite { #expect(comments.count == 5) } + // MARK: - List + + @Test func getCustomList() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/users/me/lists/star-wars-in-machete-order", result: .success(jsonData(named: "test_get_custom_list"))) + + let customList = try await traktManager.currentUser().personalList("star-wars-in-machete-order").perform() + #expect(customList.name == "Star Wars in machete order") + #expect(customList.description != nil) + } + + @Test func getListItems() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.GET, "https://api.trakt.tv/users/sean/lists/star-wars-in-machete-order/items", result: .success(jsonData(named: "test_get_items_on_custom_list"))) + + let listItems = try await traktManager.user("sean").itemsOnList("star-wars-in-machete-order").perform().object + #expect(listItems.count == 5) + } + + @Test func addItemsToList() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.POST, "https://api.trakt.tv/users/me/lists/star-wars-in-machete-order/items", result: .success(jsonData(named: "test_add_item_to_custom_list"))) + + // For the test we don't need to add any specific Ids. + let response = try await traktManager.currentUser().addItemsToList("star-wars-in-machete-order", movies: []).perform() + #expect(response.added.seasons == 1) + #expect(response.added.people == 1) + #expect(response.added.movies == 1) + #expect(response.added.shows == 1) + #expect(response.added.episodes == 2) + + #expect(response.existing.seasons == 0) + #expect(response.existing.episodes == 0) + #expect(response.existing.movies == 0) + #expect(response.existing.shows == 0) + #expect(response.existing.episodes == 0) + + #expect(response.notFound.movies.count == 1) + } + + @Test func removeItemsFromList() async throws { + let traktManager = await authenticatedTraktManager() + try mock(.DELETE, "https://api.trakt.tv/users/me/lists/star-wars-in-machete-order/items/remove", result: .success(jsonData(named: "test_remove_item_from_custom_list"))) + + // For the test we don't need to add any specific Ids. + let response = try await traktManager.currentUser().removeItemsFromList("star-wars-in-machete-order", movies: []).perform() + #expect(response.deleted.seasons == 1) + #expect(response.deleted.people == 1) + #expect(response.deleted.movies == 1) + #expect(response.deleted.shows == 1) + #expect(response.deleted.episodes == 2) + + #expect(response.notFound.movies.count == 1) + } + // MARK: - History @Test func getWatchedHistory() async throws { From 47ba8caabe7394a7f4df54d1445ba6cb9b3b066b Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 29 Mar 2025 18:37:57 -0400 Subject: [PATCH 34/38] Update sync id for removing ratings --- Common/Wrapper/Resources/SyncResource.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Common/Wrapper/Resources/SyncResource.swift b/Common/Wrapper/Resources/SyncResource.swift index 3544a01..203fe38 100644 --- a/Common/Wrapper/Resources/SyncResource.swift +++ b/Common/Wrapper/Resources/SyncResource.swift @@ -310,10 +310,10 @@ extension TraktManager { 🔒 OAuth Required */ public func removeRatings( - movies: [RatingId]? = nil, - shows: [RatingId]? = nil, - seasons: [RatingId]? = nil, - episodes: [RatingId]? = nil + movies: [SyncId]? = nil, + shows: [SyncId]? = nil, + seasons: [SyncId]? = nil, + episodes: [SyncId]? = nil ) -> Route { Route( paths: [path, "ratings", "remove"], From 13daa1d4ea9913c1569350e4c0705370b77a11ec Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Mon, 31 Mar 2025 08:59:44 -0400 Subject: [PATCH 35/38] Add series dropped date to last activities --- Common/Models/Structures.swift | 3 +++ Tests/TraktKitTests/Models/Sync/test_get_last_activity.json | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Common/Models/Structures.swift b/Common/Models/Structures.swift index 52395e2..ed144d7 100644 --- a/Common/Models/Structures.swift +++ b/Common/Models/Structures.swift @@ -210,6 +210,7 @@ public struct TraktLastActivities: TraktObject { public let favoritesAt: Date public let commentedAt: Date public let hiddenAt: Date + public let droppedAt: Date enum CodingKeys: String, CodingKey { case ratedAt = "rated_at" @@ -217,6 +218,7 @@ public struct TraktLastActivities: TraktObject { case favoritesAt = "favorited_at" case commentedAt = "commented_at" case hiddenAt = "hidden_at" + case droppedAt = "dropped_at" } } @@ -277,6 +279,7 @@ public struct TraktLastActivities: TraktObject { } } + /// Used for watchlist, favorites, saved filters, and notes. public struct LastUpdated: TraktObject { public let updatedAt: Date diff --git a/Tests/TraktKitTests/Models/Sync/test_get_last_activity.json b/Tests/TraktKitTests/Models/Sync/test_get_last_activity.json index 8f8b7ae..dfdb88e 100644 --- a/Tests/TraktKitTests/Models/Sync/test_get_last_activity.json +++ b/Tests/TraktKitTests/Models/Sync/test_get_last_activity.json @@ -23,7 +23,8 @@ "watchlisted_at": "2014-11-20T06:51:30.000Z", "favorited_at": "2014-11-20T06:51:30.000Z", "commented_at": "2014-11-20T06:51:30.000Z", - "hidden_at": "2016-08-20T06:51:30.000Z" + "hidden_at": "2016-08-20T06:51:30.000Z", + "dropped_at": "2025-03-13T01:23:45.000Z" }, "seasons": { "rated_at": "2014-11-19T19:54:24.000Z", From 7b8d9c1137d6258630cedc5ee4a1ab0273892d2f Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Sat, 5 Apr 2025 06:59:01 -0400 Subject: [PATCH 36/38] Use URLProtocolCachePolicy I set the cache policy to reloadIgnoringLocalAndRemoteCacheData because I was getting reports by users that data wasn't being updated. But after research into URLSession caching, and the headers sent by Trakt, the sync endpoints should be getting new data every time, while other requests are cached, so I'm removing this cache policy. --- Common/Wrapper/TraktManager.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Common/Wrapper/TraktManager.swift b/Common/Wrapper/TraktManager.swift index 72c1f1f..bb132fc 100644 --- a/Common/Wrapper/TraktManager.swift +++ b/Common/Wrapper/TraktManager.swift @@ -183,7 +183,6 @@ public final class TraktManager: Sendable { // Request var request = URLRequest(url: url) request.httpMethod = httpMethod.rawValue - request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData // Headers request.addValue("application/json", forHTTPHeaderField: "Content-Type") From dcea8f107385d50436d2293ad7a7e2d22e3eb66e Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Tue, 16 Sep 2025 07:09:07 -0400 Subject: [PATCH 37/38] Add support for Trakt image API, fix Swift 6 error --- Common/Models/Movies/TraktMovie.swift | 29 ++++++++++++++-- Common/Models/Shows/TraktEpisode.swift | 9 ++++- Common/Models/Shows/TraktSeason.swift | 24 +++++++++++-- Common/Models/Shows/TraktShow.swift | 34 +++++++++++++++++-- Common/Models/TraktImages.swift | 27 +++++++++++++++ Common/Wrapper/Enums.swift | 2 ++ Tests/TraktKitTests/EpisodeTests.swift | 21 ++++++++++++ .../Models/Episodes/Episode_Images.json | 17 ++++++++++ .../Models/Shows/Show_Images.json | 32 +++++++++++++++++ .../NetworkMocking/RequestMocking.swift | 20 +++++------ Tests/TraktKitTests/ShowsTests.swift | 29 ++++++++++++++++ 11 files changed, 225 insertions(+), 19 deletions(-) create mode 100644 Common/Models/TraktImages.swift create mode 100644 Tests/TraktKitTests/Models/Episodes/Episode_Images.json create mode 100644 Tests/TraktKitTests/Models/Shows/Show_Images.json diff --git a/Common/Models/Movies/TraktMovie.swift b/Common/Models/Movies/TraktMovie.swift index 6593837..3b20ada 100644 --- a/Common/Models/Movies/TraktMovie.swift +++ b/Common/Models/Movies/TraktMovie.swift @@ -29,7 +29,10 @@ public struct TraktMovie: TraktObject { public let availableTranslations: [String]? public let genres: [String]? public let certification: String? - + + // Extended: Images + public let images: TraktImages? + enum CodingKeys: String, CodingKey { case title case year @@ -48,6 +51,8 @@ public struct TraktMovie: TraktObject { case availableTranslations = "available_translations" case genres case certification + + case images } public init(from decoder: Decoder) throws { @@ -69,9 +74,28 @@ public struct TraktMovie: TraktObject { language = try container.decodeIfPresent(String.self, forKey: .language) availableTranslations = try container.decodeIfPresent([String].self, forKey: .availableTranslations) genres = try container.decodeIfPresent([String].self, forKey: .genres) + images = try container.decodeIfPresent(TraktImages.self, forKey: .images) } - public init(title: String, year: Int? = nil, ids: ID, tagline: String? = nil, overview: String? = nil, released: Date? = nil, runtime: Int? = nil, trailer: URL? = nil, homepage: URL? = nil, rating: Double? = nil, votes: Int? = nil, updatedAt: Date? = nil, language: String? = nil, availableTranslations: [String]? = nil, genres: [String]? = nil, certification: String? = nil) { + public init( + title: String, + year: Int? = nil, + ids: ID, + tagline: String? = nil, + overview: String? = nil, + released: Date? = nil, + runtime: Int? = nil, + trailer: URL? = nil, + homepage: URL? = nil, + rating: Double? = nil, + votes: Int? = nil, + updatedAt: Date? = nil, + language: String? = nil, + availableTranslations: [String]? = nil, + genres: [String]? = nil, + certification: String? = nil, + images: TraktImages? = nil + ) { self.title = title self.year = year self.ids = ids @@ -88,5 +112,6 @@ public struct TraktMovie: TraktObject { self.availableTranslations = availableTranslations self.genres = genres self.certification = certification + self.images = images } } diff --git a/Common/Models/Shows/TraktEpisode.swift b/Common/Models/Shows/TraktEpisode.swift index 27216c4..e2a274b 100644 --- a/Common/Models/Shows/TraktEpisode.swift +++ b/Common/Models/Shows/TraktEpisode.swift @@ -29,6 +29,9 @@ public struct TraktEpisode: TraktObject { /// When getting full extended info, the `episodeType` field can have a value of `standard`, `series_premiere` (season 1, episode 1), `season_premiere` (episode 1), `mid_season_finale`,` mid_season_premiere` (the next episode after the mid season finale), `season_finale`, or `series_finale` (last episode to air for an ended show). public let episodeType: String? + // Extended: Images + public let images: TraktImages? + enum CodingKeys: String, CodingKey { case season case number @@ -45,6 +48,8 @@ public struct TraktEpisode: TraktObject { case runtime case commentCount = "comment_count" case episodeType = "episode_type" + + case images } public init( @@ -61,7 +66,8 @@ public struct TraktEpisode: TraktObject { absoluteNumber: Int? = nil, runtime: Int? = nil, commentCount: Int? = nil, - episodeType: String? = nil + episodeType: String? = nil, + images: TraktImages? = nil ) { self.season = season self.number = number @@ -77,5 +83,6 @@ public struct TraktEpisode: TraktObject { self.commentCount = commentCount self.runtime = runtime self.episodeType = episodeType + self.images = images } } diff --git a/Common/Models/Shows/TraktSeason.swift b/Common/Models/Shows/TraktSeason.swift index 2248136..174474f 100644 --- a/Common/Models/Shows/TraktSeason.swift +++ b/Common/Models/Shows/TraktSeason.swift @@ -27,7 +27,10 @@ public struct TraktSeason: TraktObject { // Extended: Episodes public let episodes: [TraktEpisode]? - + + // Extended: Images + public let images: TraktImages? + enum CodingKeys: String, CodingKey { case number case ids @@ -43,9 +46,25 @@ public struct TraktSeason: TraktObject { case network case episodes + + case images } - public init(number: Int, ids: SeasonId, rating: Double? = nil, votes: Int? = nil, episodeCount: Int? = nil, airedEpisodes: Int? = nil, title: String? = nil, overview: String? = nil, firstAired: Date? = nil, updatedAt: Date? = nil, network: String? = nil, episodes: [TraktEpisode]? = nil) { + public init( + number: Int, + ids: SeasonId, + rating: Double? = nil, + votes: Int? = nil, + episodeCount: Int? = nil, + airedEpisodes: Int? = nil, + title: String? = nil, + overview: String? = nil, + firstAired: Date? = nil, + updatedAt: Date? = nil, + network: String? = nil, + episodes: [TraktEpisode]? = nil, + images: TraktImages? = nil + ) { self.number = number self.ids = ids self.rating = rating @@ -58,5 +77,6 @@ public struct TraktSeason: TraktObject { self.updatedAt = updatedAt self.network = network self.episodes = episodes + self.images = images } } diff --git a/Common/Models/Shows/TraktShow.swift b/Common/Models/Shows/TraktShow.swift index 3356b85..74019f2 100644 --- a/Common/Models/Shows/TraktShow.swift +++ b/Common/Models/Shows/TraktShow.swift @@ -45,7 +45,10 @@ public struct TraktShow: TraktObject { public let availableTranslations: [String]? public let genres: [String]? public let airedEpisodes: Int? - + + // Extended: Images + public let images: TraktImages? + enum CodingKeys: String, CodingKey { case title case year @@ -68,6 +71,8 @@ public struct TraktShow: TraktObject { case availableTranslations = "available_translations" case genres case airedEpisodes = "aired_episodes" + + case images } public init(from decoder: Decoder) throws { @@ -93,9 +98,33 @@ public struct TraktShow: TraktObject { availableTranslations = try container.decodeIfPresent([String].self, forKey: .availableTranslations) genres = try container.decodeIfPresent([String].self, forKey: .genres) airedEpisodes = try container.decodeIfPresent(Int.self, forKey: .airedEpisodes) + + images = try container.decodeIfPresent(TraktImages.self, forKey: .images) } - public init(title: String, year: Int? = nil, ids: ID, overview: String? = nil, firstAired: Date? = nil, airs: Airs? = nil, runtime: Int? = nil, certification: String? = nil, network: String? = nil, country: String? = nil, trailer: URL? = nil, homepage: URL? = nil, status: String? = nil, rating: Double? = nil, votes: Int? = nil, updatedAt: Date? = nil, language: String? = nil, availableTranslations: [String]? = nil, genres: [String]? = nil, airedEpisodes: Int? = nil) { + public init( + title: String, + year: Int? = nil, + ids: ID, + overview: String? = nil, + firstAired: Date? = nil, + airs: Airs? = nil, + runtime: Int? = nil, + certification: String? = nil, + network: String? = nil, + country: String? = nil, + trailer: URL? = nil, + homepage: URL? = nil, + status: String? = nil, + rating: Double? = nil, + votes: Int? = nil, + updatedAt: Date? = nil, + language: String? = nil, + availableTranslations: [String]? = nil, + genres: [String]? = nil, + airedEpisodes: Int? = nil, + images: TraktImages? = nil + ) { self.title = title self.year = year self.ids = ids @@ -116,5 +145,6 @@ public struct TraktShow: TraktObject { self.availableTranslations = availableTranslations self.genres = genres self.airedEpisodes = airedEpisodes + self.images = images } } diff --git a/Common/Models/TraktImages.swift b/Common/Models/TraktImages.swift new file mode 100644 index 0000000..bcc9d01 --- /dev/null +++ b/Common/Models/TraktImages.swift @@ -0,0 +1,27 @@ +// +// TraktImages.swift +// TraktKit +// +// Created by Maximilian Litteral on 9/16/25. +// + +import Foundation + +public struct TraktImages: TraktObject { + public let fanart: [URL] + public let poster: [URL] + public let logo: [URL] + public let clearart: [URL] + public let banner: [URL] + public let thumb: [URL] + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.fanart = (try? container.decodeIfPresent([URL].self, forKey: .fanart)) ?? [] + self.poster = (try? container.decodeIfPresent([URL].self, forKey: .poster)) ?? [] + self.logo = (try? container.decodeIfPresent([URL].self, forKey: .logo)) ?? [] + self.clearart = (try? container.decodeIfPresent([URL].self, forKey: .clearart)) ?? [] + self.banner = (try? container.decodeIfPresent([URL].self, forKey: .banner)) ?? [] + self.thumb = (try? container.decodeIfPresent([URL].self, forKey: .thumb)) ?? [] + } +} diff --git a/Common/Wrapper/Enums.swift b/Common/Wrapper/Enums.swift index ae93b83..8e4cee4 100644 --- a/Common/Wrapper/Enums.swift +++ b/Common/Wrapper/Enums.swift @@ -235,6 +235,8 @@ public enum ExtendedType: String, CustomStringConvertible, Sendable { case noSeasons = "noseasons" /// For the show and season `/people` methods. case guestStars = "guest_stars" + /// Get images for media objects. https://trakt.docs.apiary.io/introduction/images + case images public var description: String { return self.rawValue diff --git a/Tests/TraktKitTests/EpisodeTests.swift b/Tests/TraktKitTests/EpisodeTests.swift index aab6be4..c8647c9 100644 --- a/Tests/TraktKitTests/EpisodeTests.swift +++ b/Tests/TraktKitTests/EpisodeTests.swift @@ -89,6 +89,27 @@ final class EpisodeTests: TraktTestCase { XCTAssertEqual(episode.availableTranslations, ["en"]) } + func test_get_episode_images() async throws { + try mock(.GET, "https://api.trakt.tv/shows/severance/seasons/2/episodes/5?extended=images", result: .success(jsonData(named: "Episode_Images"))) + + let episode = try await traktManager + .show(id: "severance") + .season(2).episode(5) + .summary() + .extend(.images) + .perform() + + XCTAssertEqual(episode.title, "Trojan's Horse") + XCTAssertEqual(episode.season, 2) + XCTAssertEqual(episode.number, 5) + XCTAssertEqual(episode.ids.trakt, 12103031) + XCTAssertNil(episode.overview) + XCTAssertNil(episode.firstAired) + XCTAssertNil(episode.updatedAt) + XCTAssertNil(episode.absoluteNumber) + XCTAssertNil(episode.availableTranslations) + } + // MARK: - Translations func test_get_all_episode_translations() throws { diff --git a/Tests/TraktKitTests/Models/Episodes/Episode_Images.json b/Tests/TraktKitTests/Models/Episodes/Episode_Images.json new file mode 100644 index 0000000..c6fee60 --- /dev/null +++ b/Tests/TraktKitTests/Models/Episodes/Episode_Images.json @@ -0,0 +1,17 @@ +{ + "season": 2, + "number": 5, + "title": "Trojan's Horse", + "ids": { + "trakt": 12103031, + "tvdb": 10592762, + "imdb": "tt15242966", + "tmdb": 5469120, + "tvrage": null + }, + "images": { + "screenshot": [ + "walter-r2.trakt.tv/images/episodes/012/103/031/screenshots/medium/d610df59c0.jpg.webp" + ] + } +} diff --git a/Tests/TraktKitTests/Models/Shows/Show_Images.json b/Tests/TraktKitTests/Models/Shows/Show_Images.json new file mode 100644 index 0000000..7e24673 --- /dev/null +++ b/Tests/TraktKitTests/Models/Shows/Show_Images.json @@ -0,0 +1,32 @@ +{ + "title": "Severance", + "year": 2022, + "ids": { + "trakt": 154997, + "slug": "severance", + "tvdb": 371980, + "imdb": "tt11280740", + "tmdb": 95396, + "tvrage": null + }, + "images": { + "fanart": [ + "walter-r2.trakt.tv/images/shows/000/154/997/fanarts/medium/9400ecb8e2.jpg.webp" + ], + "poster": [ + "walter-r2.trakt.tv/images/shows/000/154/997/posters/thumb/f60ddb06de.jpg.webp" + ], + "logo": [ + "walter-r2.trakt.tv/images/shows/000/154/997/logos/medium/29510e9cd0.png.webp" + ], + "clearart": [ + "walter-r2.trakt.tv/images/shows/000/154/997/cleararts/medium/d3639066f2.png.webp" + ], + "banner": [ + "walter-r2.trakt.tv/images/shows/000/154/997/banners/medium/883c234c02.jpg.webp" + ], + "thumb": [ + "walter-r2.trakt.tv/images/shows/000/154/997/thumbs/medium/1dcf1005c7.jpg.webp" + ] + } +} diff --git a/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift b/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift index 7e72a83..f0dcdc6 100644 --- a/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift +++ b/Tests/TraktKitTests/NetworkMocking/RequestMocking.swift @@ -78,18 +78,14 @@ final class RequestMocking: URLProtocol, @unchecked Sendable { HTTPURLResponse(url: url, statusCode: mock.httpCode, httpVersion: "HTTP/1.1", headerFields: mock.headers) else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + mock.loadingTime) { [weak self] in - guard let self else { return } - - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - - switch mock.result { - case let .success(data): - client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) - case let .failure(error): - client?.urlProtocol(self, didFailWithError: error) - } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + + switch mock.result { + case let .success(data): + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + case let .failure(error): + client?.urlProtocol(self, didFailWithError: error) } } diff --git a/Tests/TraktKitTests/ShowsTests.swift b/Tests/TraktKitTests/ShowsTests.swift index 13c2295..197f1a3 100644 --- a/Tests/TraktKitTests/ShowsTests.swift +++ b/Tests/TraktKitTests/ShowsTests.swift @@ -354,6 +354,35 @@ final class ShowsTests: TraktTestCase { XCTAssertEqual(show.airedEpisodes, 50) } + func test_get_show_images() async throws { + try mock(.GET, "https://api.trakt.tv/shows/game-of-thrones?extended=images", result: .success(jsonData(named: "Show_Images"))) + + let show = try await traktManager.show(id: "game-of-thrones").summary().extend(.images).perform() + XCTAssertEqual(show.title, "Severance") + XCTAssertEqual(show.year, 2022) + XCTAssertEqual(show.ids.trakt, 154997) + XCTAssertEqual(show.ids.slug, "severance") + XCTAssertNotNil(show.images) + XCTAssertEqual(show.images?.poster.count, 1) + XCTAssertNil(show.overview) + XCTAssertNil(show.firstAired) + XCTAssertNil(show.airs) + XCTAssertNil(show.runtime) + XCTAssertNil(show.certification) + XCTAssertNil(show.network) + XCTAssertNil(show.country) + XCTAssertNil(show.trailer) + XCTAssertNil(show.homepage) + XCTAssertNil(show.status) + XCTAssertNil(show.rating) + XCTAssertNil(show.votes) + XCTAssertNil(show.updatedAt) + XCTAssertNil(show.language) + XCTAssertNil(show.availableTranslations) + XCTAssertNil(show.genres) + XCTAssertNil(show.airedEpisodes) + } + // MARK: - Aliases func test_get_show_aliases() { From bdca13f42f7563b7644cb742025adf3e4bc9930c Mon Sep 17 00:00:00 2001 From: Maximilian Litteral Date: Thu, 18 Sep 2025 06:09:34 -0400 Subject: [PATCH 38/38] Add episode screenshots and crew headshots --- Common/Models/TraktImages.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Common/Models/TraktImages.swift b/Common/Models/TraktImages.swift index bcc9d01..0649dde 100644 --- a/Common/Models/TraktImages.swift +++ b/Common/Models/TraktImages.swift @@ -8,6 +8,7 @@ import Foundation public struct TraktImages: TraktObject { + // Movies / Series / Season public let fanart: [URL] public let poster: [URL] public let logo: [URL] @@ -15,6 +16,12 @@ public struct TraktImages: TraktObject { public let banner: [URL] public let thumb: [URL] + // Episodes + public let screenshot: [URL] + + // Cast / Crew + public let headshot: [URL] + public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.fanart = (try? container.decodeIfPresent([URL].self, forKey: .fanart)) ?? [] @@ -23,5 +30,7 @@ public struct TraktImages: TraktObject { self.clearart = (try? container.decodeIfPresent([URL].self, forKey: .clearart)) ?? [] self.banner = (try? container.decodeIfPresent([URL].self, forKey: .banner)) ?? [] self.thumb = (try? container.decodeIfPresent([URL].self, forKey: .thumb)) ?? [] + self.screenshot = (try? container.decodeIfPresent([URL].self, forKey: .screenshot)) ?? [] + self.headshot = (try? container.decodeIfPresent([URL].self, forKey: .headshot)) ?? [] } }