diff --git a/.gitignore b/.gitignore index df12de24..1679a7e2 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,7 @@ default.profraw junit-swift-testing.xml # Claude -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.json + +# Git worktrees +.worktrees/ \ No newline at end of file diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 00000000..f1ef1d37 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "b9b38f5b025068f2b38fa2a21f87f2d86e615e8473c27393e1a796e57ade6cb6", + "pins" : [ + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin.git", + "state" : { + "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", + "version" : "1.4.5" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + } + ], + "version" : 3 +} diff --git a/Sources/TMDb/Domain/Models/AccountStates.swift b/Sources/TMDb/Domain/Models/AccountStates.swift new file mode 100644 index 00000000..bd732352 --- /dev/null +++ b/Sources/TMDb/Domain/Models/AccountStates.swift @@ -0,0 +1,91 @@ +// +// AccountStates.swift +// TMDb +// +// Copyright © 2026 Adam Young. +// + +import Foundation + +/// +/// A model representing a user's account states for media. +/// +public struct AccountStates: Codable, Equatable, Hashable, Sendable { + + /// + /// Media identifier. + /// + public let id: Int + + /// + /// Is the media in the user's favorites. + /// + public let isFavourite: Bool + + /// + /// The user's rating for the media. + /// + public let rating: Double? + + /// + /// Is the media in the user's watchlist. + /// + public let isInWatchlist: Bool + + /// + /// Creates an account states object. + /// + /// - Parameters: + /// - id: Media identifier. + /// - isFavourite: Is the media in the user's favorites. + /// - rating: The user's rating for the media. + /// - isInWatchlist: Is the media in the user's watchlist. + /// + public init(id: Int, isFavourite: Bool, rating: Double? = nil, isInWatchlist: Bool) { + self.id = id + self.isFavourite = isFavourite + self.rating = rating + self.isInWatchlist = isInWatchlist + } + +} + +extension AccountStates { + + private enum CodingKeys: String, CodingKey { + case id + case isFavourite = "favorite" + case rating = "rated" + case isInWatchlist = "watchlist" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(Int.self, forKey: .id) + self.isFavourite = try container.decode(Bool.self, forKey: .isFavourite) + self.isInWatchlist = try container.decode(Bool.self, forKey: .isInWatchlist) + + // rating can be false (boolean) or a number (double) + if let ratingValue = try? container.decode(Double.self, forKey: .rating) { + self.rating = ratingValue + } else { + self.rating = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(isFavourite, forKey: .isFavourite) + try container.encode(isInWatchlist, forKey: .isInWatchlist) + + if let rating { + try container.encode(rating, forKey: .rating) + } else { + try container.encode(false, forKey: .rating) + } + } + +} diff --git a/Sources/TMDb/Domain/Models/AlternativeTitle.swift b/Sources/TMDb/Domain/Models/AlternativeTitle.swift new file mode 100644 index 00000000..92c91560 --- /dev/null +++ b/Sources/TMDb/Domain/Models/AlternativeTitle.swift @@ -0,0 +1,54 @@ +// +// AlternativeTitle.swift +// TMDb +// +// Copyright © 2026 Adam Young. +// + +import Foundation + +/// +/// A model representing an alternative title. +/// +public struct AlternativeTitle: Codable, Equatable, Hashable, Sendable { + + /// + /// ISO 3166-1 country code. + /// + public let countryCode: String + + /// + /// Title. + /// + public let title: String + + /// + /// Type of alternative title. + /// + public let type: String? + + /// + /// Creates an alternative title object. + /// + /// - Parameters: + /// - countryCode: ISO 3166-1 country code. + /// - title: Title. + /// - type: Type of alternative title. + /// + public init(countryCode: String, title: String, type: String? = nil) { + self.countryCode = countryCode + self.title = title + self.type = type + } + +} + +extension AlternativeTitle { + + private enum CodingKeys: String, CodingKey { + case countryCode = "iso31661" + case title + case type + } + +} diff --git a/Sources/TMDb/Domain/Models/AlternativeTitleCollection.swift b/Sources/TMDb/Domain/Models/AlternativeTitleCollection.swift new file mode 100644 index 00000000..fcc6bb3f --- /dev/null +++ b/Sources/TMDb/Domain/Models/AlternativeTitleCollection.swift @@ -0,0 +1,46 @@ +// +// AlternativeTitleCollection.swift +// TMDb +// +// Copyright © 2026 Adam Young. +// + +import Foundation + +/// +/// A model representing a collection of alternative titles. +/// +public struct AlternativeTitleCollection: Codable, Equatable, Hashable, Sendable { + + /// + /// Media identifier. + /// + public let id: Int + + /// + /// Alternative titles. + /// + public let titles: [AlternativeTitle] + + /// + /// Creates an alternative title collection object. + /// + /// - Parameters: + /// - id: Media identifier. + /// - titles: Alternative titles. + /// + public init(id: Int, titles: [AlternativeTitle]) { + self.id = id + self.titles = titles + } + +} + +extension AlternativeTitleCollection { + + private enum CodingKeys: String, CodingKey { + case id + case titles = "results" + } + +} diff --git a/Sources/TMDb/Domain/Models/Change.swift b/Sources/TMDb/Domain/Models/Change.swift new file mode 100644 index 00000000..90b879dc --- /dev/null +++ b/Sources/TMDb/Domain/Models/Change.swift @@ -0,0 +1,246 @@ +// +// Change.swift +// TMDb +// +// Copyright © 2026 Adam Young. +// + +import Foundation + +/// +/// A model representing a change entry. +/// +public struct Change: Codable, Equatable, Hashable, Sendable { + + /// + /// The key that was changed. + /// + public let key: String + + /// + /// The list of change items. + /// + public let items: [ChangeItem] + + /// + /// Creates a change object. + /// + /// - Parameters: + /// - key: The key that was changed. + /// - items: The list of change items. + /// + public init(key: String, items: [ChangeItem]) { + self.key = key + self.items = items + } + +} + +/// +/// A model representing a change item. +/// +public struct ChangeItem: Codable, Equatable, Hashable, Sendable { + + /// + /// Change identifier. + /// + public let id: String + + /// + /// Action performed. + /// + public let action: String + + /// + /// Time of change. + /// + public let time: Date + + /// + /// ISO 639-1 language code. + /// + public let languageCode: String? + + /// + /// ISO 3166-1 country code. + /// + public let countryCode: String? + + /// + /// Original value before change. + /// + public let originalValue: ChangeValue? + + /// + /// Value after change. + /// + public let value: ChangeValue? + + /// + /// Creates a change item object. + /// + /// - Parameters: + /// - id: Change identifier. + /// - action: Action performed. + /// - time: Time of change. + /// - languageCode: ISO 639-1 language code. + /// - countryCode: ISO 3166-1 country code. + /// - originalValue: Original value before change. + /// - value: Value after change. + /// + public init( + id: String, + action: String, + time: Date, + languageCode: String? = nil, + countryCode: String? = nil, + originalValue: ChangeValue? = nil, + value: ChangeValue? = nil + ) { + self.id = id + self.action = action + self.time = time + self.languageCode = languageCode + self.countryCode = countryCode + self.originalValue = originalValue + self.value = value + } + +} + +extension ChangeItem { + + private enum CodingKeys: String, CodingKey { + case id + case action + case time + case languageCode = "iso6391" + case countryCode = "iso31661" + case originalValue + case value + } + +} + +/// +/// A model representing a change value (can be string, number, or object). +/// +public enum ChangeValue: Codable, Equatable, Hashable, Sendable { + + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + return + } + + if let intValue = try? container.decode(Int.self) { + self = .int(intValue) + return + } + + if let doubleValue = try? container.decode(Double.self) { + self = .double(doubleValue) + return + } + + if let boolValue = try? container.decode(Bool.self) { + self = .bool(boolValue) + return + } + + throw DecodingError.typeMismatch( + ChangeValue.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected String, Int, Double, or Bool" + ) + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .string(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + } + } + +} + +/// +/// A model representing a collection of changes. +/// +public struct ChangeCollection: Codable, Equatable, Hashable, Sendable { + + /// + /// List of changes. + /// + public let changes: [Change] + + /// + /// Creates a change collection object. + /// + /// - Parameter changes: List of changes. + /// + public init(changes: [Change]) { + self.changes = changes + } + +} + +/// +/// A model representing a changed media ID. +/// +public struct ChangedID: Identifiable, Codable, Equatable, Hashable, Sendable { + + /// + /// Media identifier. + /// + public let id: Int + + /// + /// Is adult content. + /// + public let isAdult: Bool? + + /// + /// Creates a changed ID object. + /// + /// - Parameters: + /// - id: Media identifier. + /// - isAdult: Is adult content. + /// + public init(id: Int, isAdult: Bool? = nil) { + self.id = id + self.isAdult = isAdult + } + +} + +extension ChangedID { + + private enum CodingKeys: String, CodingKey { + case id + case isAdult = "adult" + } + +} + +/// +/// A model representing a collection of changed IDs. +/// +public typealias ChangedIDCollection = PageableListResult diff --git a/Sources/TMDb/Domain/Models/KeywordCollection.swift b/Sources/TMDb/Domain/Models/KeywordCollection.swift new file mode 100644 index 00000000..ffec6c70 --- /dev/null +++ b/Sources/TMDb/Domain/Models/KeywordCollection.swift @@ -0,0 +1,46 @@ +// +// KeywordCollection.swift +// TMDb +// +// Copyright © 2026 Adam Young. +// + +import Foundation + +/// +/// A model representing a collection of keywords. +/// +public struct KeywordCollection: Codable, Equatable, Hashable, Sendable { + + /// + /// Media identifier. + /// + public let id: Int + + /// + /// Keywords. + /// + public let keywords: [Keyword] + + /// + /// Creates a keyword collection object. + /// + /// - Parameters: + /// - id: Media identifier. + /// - keywords: Keywords. + /// + public init(id: Int, keywords: [Keyword]) { + self.id = id + self.keywords = keywords + } + +} + +extension KeywordCollection { + + private enum CodingKeys: String, CodingKey { + case id + case keywords = "results" + } + +} diff --git a/Sources/TMDb/Domain/Models/TMDbError.swift b/Sources/TMDb/Domain/Models/TMDbError.swift index bbe84937..2529a047 100644 --- a/Sources/TMDb/Domain/Models/TMDbError.swift +++ b/Sources/TMDb/Domain/Models/TMDbError.swift @@ -36,6 +36,9 @@ public enum TMDbError: Equatable, LocalizedError, Sendable { /// An error indicating there was a problem decoding data. case decode(Error) + /// An error indicating an invalid rating value was provided. + case invalidRating + /// An unknown error. case unknown @@ -77,6 +80,9 @@ public enum TMDbError: Equatable, LocalizedError, Sendable { case (.decode, .decode): true + case (.invalidRating, .invalidRating): + true + case (.unknown, .unknown): true @@ -118,6 +124,9 @@ public extension TMDbError { case .decode: "Decode error" + case .invalidRating: + "Invalid rating (must be between 0.5 and 10.0, in increments of 0.5)" + case .unknown: "Unknown" } diff --git a/Sources/TMDb/Domain/Models/Translation.swift b/Sources/TMDb/Domain/Models/Translation.swift new file mode 100644 index 00000000..834a33cd --- /dev/null +++ b/Sources/TMDb/Domain/Models/Translation.swift @@ -0,0 +1,162 @@ +// +// Translation.swift +// TMDb +// +// Copyright © 2026 Adam Young. +// + +import Foundation + +/// +/// A model representing a translation. +/// +public struct Translation: + Identifiable, Codable, Equatable, Hashable, Sendable { + + /// + /// Translation's identifier (same as `languageCode`). + /// + public var id: String { + languageCode + } + + /// + /// ISO 3166-1 country code. + /// + public let countryCode: String + + /// + /// ISO 639-1 language code. + /// + public let languageCode: String + + /// + /// Language name. + /// + public let name: String + + /// + /// English language name. + /// + public let englishName: String + + /// + /// Translation data. + /// + public let data: Data + + /// + /// Creates a translation object. + /// + /// - Parameters: + /// - countryCode: ISO 3166-1 country code. + /// - languageCode: ISO 639-1 language code. + /// - name: Language name. + /// - englishName: English language name. + /// - data: Translation data. + /// + public init( + countryCode: String, + languageCode: String, + name: String, + englishName: String, + data: Data + ) { + self.countryCode = countryCode + self.languageCode = languageCode + self.name = name + self.englishName = englishName + self.data = data + } + +} + +extension Translation { + + private enum CodingKeys: String, CodingKey { + case countryCode = "iso31661" + case languageCode = "iso6391" + case name + case englishName + case data + } + +} + +/// +/// A model representing a collection of translations. +/// +public struct TranslationCollection: + Codable, Equatable, Hashable, Sendable { + + /// + /// Media identifier. + /// + public let id: Int + + /// + /// Translations. + /// + public let translations: [Translation] + + /// + /// Creates a translation collection object. + /// + /// - Parameters: + /// - id: Media identifier. + /// - translations: Translations. + /// + public init(id: Int, translations: [Translation]) { + self.id = id + self.translations = translations + } + +} + +/// +/// A model representing TV series translation data. +/// +public struct TVSeriesTranslationData: Codable, Equatable, Hashable, Sendable { + + /// + /// TV series name. + /// + public let name: String + + /// + /// TV series overview. + /// + public let overview: String + + /// + /// TV series homepage URL. + /// + public let homepage: String? + + /// + /// TV series tagline. + /// + public let tagline: String? + + /// + /// Creates a TV series translation data object. + /// + /// - Parameters: + /// - name: TV series name. + /// - overview: TV series overview. + /// - homepage: TV series homepage URL. + /// - tagline: TV series tagline. + /// + public init( + name: String, + overview: String, + homepage: String? = nil, + tagline: String? = nil + ) { + self.name = name + self.overview = overview + self.homepage = homepage + self.tagline = tagline + } + +} diff --git a/Sources/TMDb/Domain/Services/TVSeries/Requests/LatestTVSeriesRequest.swift b/Sources/TMDb/Domain/Services/TVSeries/Requests/LatestTVSeriesRequest.swift new file mode 100644 index 00000000..ce6c8463 --- /dev/null +++ b/Sources/TMDb/Domain/Services/TVSeries/Requests/LatestTVSeriesRequest.swift @@ -0,0 +1,18 @@ +// +// LatestTVSeriesRequest.swift +// TMDb +// +// Copyright © 2026 Adam Young. +// + +import Foundation + +final class LatestTVSeriesRequest: DecodableAPIRequest { + + init() { + let path = "/tv/latest" + + super.init(path: path) + } + +} diff --git a/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesAccountStatesRequest.swift b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesAccountStatesRequest.swift new file mode 100644 index 00000000..2627d9e9 --- /dev/null +++ b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesAccountStatesRequest.swift @@ -0,0 +1,28 @@ +// +// TVSeriesAccountStatesRequest.swift +// TMDb +// +// Copyright © 2026 Adam Young. +// + +import Foundation + +final class TVSeriesAccountStatesRequest: DecodableAPIRequest { + + init(id: TVSeries.ID, sessionID: String) { + let path = "/tv/\(id)/account_states" + let queryItems = APIRequestQueryItems(sessionID: sessionID) + + super.init(path: path, queryItems: queryItems) + } + +} + +private extension APIRequestQueryItems { + + init(sessionID: String) { + self.init() + self[.sessionID] = sessionID + } + +} diff --git a/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesAddRatingRequest.swift b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesAddRatingRequest.swift new file mode 100644 index 00000000..a9d9de53 --- /dev/null +++ b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesAddRatingRequest.swift @@ -0,0 +1,35 @@ +// +// TVSeriesAddRatingRequest.swift +// TMDb +// +// Copyright © 2026 Adam Young. +// + +import Foundation + +final class TVSeriesAddRatingRequest: CodableAPIRequest { + + init(rating: Double, tvSeriesID: TVSeries.ID, sessionID: String) { + let path = "/tv/\(tvSeriesID)/rating" + let queryItems = APIRequestQueryItems(sessionID: sessionID) + let body = RatingBody(value: rating) + + super.init(path: path, queryItems: queryItems, method: .post, body: body) + } + +} + +private extension APIRequestQueryItems { + + init(sessionID: String) { + self.init() + self[.sessionID] = sessionID + } + +} + +struct RatingBody: Encodable, Equatable { + + let value: Double + +} diff --git a/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesAlternativeTitlesRequest.swift b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesAlternativeTitlesRequest.swift new file mode 100644 index 00000000..2feb2911 --- /dev/null +++ b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesAlternativeTitlesRequest.swift @@ -0,0 +1,18 @@ +// +// TVSeriesAlternativeTitlesRequest.swift +// TMDb +// +// Copyright © 2026 Adam Young. +// + +import Foundation + +final class TVSeriesAlternativeTitlesRequest: DecodableAPIRequest { + + init(id: TVSeries.ID) { + let path = "/tv/\(id)/alternative_titles" + + super.init(path: path) + } + +} diff --git a/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesChangesListRequest.swift b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesChangesListRequest.swift new file mode 100644 index 00000000..934cd72b --- /dev/null +++ b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesChangesListRequest.swift @@ -0,0 +1,42 @@ +// +// TVSeriesChangesListRequest.swift +// TMDb +// +// Copyright © 2026 Adam Young. +// + +import Foundation + +final class TVSeriesChangesListRequest: DecodableAPIRequest { + + init(startDate: Date? = nil, endDate: Date? = nil, page: Int? = nil) { + let path = "/tv/changes" + let queryItems = APIRequestQueryItems(startDate: startDate, endDate: endDate, page: page) + + super.init(path: path, queryItems: queryItems) + } + +} + +private extension APIRequestQueryItems { + + static let startDate = APIRequestQueryItem.Name("start_date") + static let endDate = APIRequestQueryItem.Name("end_date") + + init(startDate: Date?, endDate: Date?, page: Int?) { + self.init() + + if let startDate { + self[Self.startDate] = DateFormatter.theMovieDatabase.string(from: startDate) + } + + if let endDate { + self[Self.endDate] = DateFormatter.theMovieDatabase.string(from: endDate) + } + + if let page { + self[.page] = page + } + } + +} diff --git a/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesChangesRequest.swift b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesChangesRequest.swift new file mode 100644 index 00000000..2f501f53 --- /dev/null +++ b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesChangesRequest.swift @@ -0,0 +1,42 @@ +// +// TVSeriesChangesRequest.swift +// TMDb +// +// Copyright © 2026 Adam Young. +// + +import Foundation + +final class TVSeriesChangesRequest: DecodableAPIRequest { + + init(id: TVSeries.ID, startDate: Date? = nil, endDate: Date? = nil, page: Int? = nil) { + let path = "/tv/\(id)/changes" + let queryItems = APIRequestQueryItems(startDate: startDate, endDate: endDate, page: page) + + super.init(path: path, queryItems: queryItems) + } + +} + +private extension APIRequestQueryItems { + + static let startDate = APIRequestQueryItem.Name("start_date") + static let endDate = APIRequestQueryItem.Name("end_date") + + init(startDate: Date?, endDate: Date?, page: Int?) { + self.init() + + if let startDate { + self[Self.startDate] = DateFormatter.theMovieDatabase.string(from: startDate) + } + + if let endDate { + self[Self.endDate] = DateFormatter.theMovieDatabase.string(from: endDate) + } + + if let page { + self[.page] = page + } + } + +} diff --git a/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesDeleteRatingRequest.swift b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesDeleteRatingRequest.swift new file mode 100644 index 00000000..7f8c4f6d --- /dev/null +++ b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesDeleteRatingRequest.swift @@ -0,0 +1,28 @@ +// +// TVSeriesDeleteRatingRequest.swift +// TMDb +// +// Copyright © 2026 Adam Young. +// + +import Foundation + +final class TVSeriesDeleteRatingRequest: DecodableAPIRequest { + + init(tvSeriesID: TVSeries.ID, sessionID: String) { + let path = "/tv/\(tvSeriesID)/rating" + let queryItems = APIRequestQueryItems(sessionID: sessionID) + + super.init(path: path, queryItems: queryItems, method: .delete) + } + +} + +private extension APIRequestQueryItems { + + init(sessionID: String) { + self.init() + self[.sessionID] = sessionID + } + +} diff --git a/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesKeywordsRequest.swift b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesKeywordsRequest.swift new file mode 100644 index 00000000..8b82ea37 --- /dev/null +++ b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesKeywordsRequest.swift @@ -0,0 +1,18 @@ +// +// TVSeriesKeywordsRequest.swift +// TMDb +// +// Copyright © 2026 Adam Young. +// + +import Foundation + +final class TVSeriesKeywordsRequest: DecodableAPIRequest { + + init(id: TVSeries.ID) { + let path = "/tv/\(id)/keywords" + + super.init(path: path) + } + +} diff --git a/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesListsRequest.swift b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesListsRequest.swift new file mode 100644 index 00000000..10e4cdf0 --- /dev/null +++ b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesListsRequest.swift @@ -0,0 +1,35 @@ +// +// TVSeriesListsRequest.swift +// TMDb +// +// Copyright © 2026 Adam Young. +// + +import Foundation + +final class TVSeriesListsRequest: DecodableAPIRequest { + + init(id: TVSeries.ID, page: Int? = nil, language: String? = nil) { + let path = "/tv/\(id)/lists" + let queryItems = APIRequestQueryItems(page: page, language: language) + + super.init(path: path, queryItems: queryItems) + } + +} + +private extension APIRequestQueryItems { + + init(page: Int?, language: String?) { + self.init() + + if let page { + self[.page] = page + } + + if let language { + self[.language] = language + } + } + +} diff --git a/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesTranslationsRequest.swift b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesTranslationsRequest.swift new file mode 100644 index 00000000..6a98706d --- /dev/null +++ b/Sources/TMDb/Domain/Services/TVSeries/Requests/TVSeriesTranslationsRequest.swift @@ -0,0 +1,19 @@ +// +// TVSeriesTranslationsRequest.swift +// TMDb +// +// Copyright © 2026 Adam Young. +// + +import Foundation + +final class TVSeriesTranslationsRequest: + DecodableAPIRequest> { + + init(id: TVSeries.ID) { + let path = "/tv/\(id)/translations" + + super.init(path: path) + } + +} diff --git a/Sources/TMDb/Domain/Services/TVSeries/TMDbTVSeriesService.swift b/Sources/TMDb/Domain/Services/TVSeries/TMDbTVSeriesService.swift index abc7c63c..37dd4a59 100644 --- a/Sources/TMDb/Domain/Services/TVSeries/TMDbTVSeriesService.swift +++ b/Sources/TMDb/Domain/Services/TVSeries/TMDbTVSeriesService.swift @@ -268,4 +268,166 @@ final class TMDbTVSeriesService: TVSeriesService { return linksCollection } + func accountStates(forTVSeries tvSeriesID: TVSeries.ID, session: Session) async throws + -> AccountStates { + let request = TVSeriesAccountStatesRequest(id: tvSeriesID, sessionID: session.sessionID) + + let accountStates: AccountStates + do { + accountStates = try await apiClient.perform(request) + } catch let error { + throw TMDbError(error: error) + } + + return accountStates + } + + func addRating( + _ rating: Double, + toTVSeries tvSeriesID: TVSeries.ID, + session: Session + ) async throws { + guard (0.5...10.0).contains(rating), rating.truncatingRemainder(dividingBy: 0.5) == 0 else { + throw TMDbError.invalidRating + } + + let request = TVSeriesAddRatingRequest( + rating: rating, + tvSeriesID: tvSeriesID, + sessionID: session.sessionID + ) + + do { + _ = try await apiClient.perform(request) + } catch let error { + throw TMDbError(error: error) + } + } + + func deleteRating(forTVSeries tvSeriesID: TVSeries.ID, session: Session) async throws { + let request = TVSeriesDeleteRatingRequest(tvSeriesID: tvSeriesID, sessionID: session.sessionID) + + do { + _ = try await apiClient.perform(request) + } catch let error { + throw TMDbError(error: error) + } + } + + func keywords(forTVSeries tvSeriesID: TVSeries.ID) async throws -> KeywordCollection { + let request = TVSeriesKeywordsRequest(id: tvSeriesID) + + let keywordCollection: KeywordCollection + do { + keywordCollection = try await apiClient.perform(request) + } catch let error { + throw TMDbError(error: error) + } + + return keywordCollection + } + + func alternativeTitles(forTVSeries tvSeriesID: TVSeries.ID) async throws + -> AlternativeTitleCollection { + let request = TVSeriesAlternativeTitlesRequest(id: tvSeriesID) + + let alternativeTitleCollection: AlternativeTitleCollection + do { + alternativeTitleCollection = try await apiClient.perform(request) + } catch let error { + throw TMDbError(error: error) + } + + return alternativeTitleCollection + } + + func translations(forTVSeries tvSeriesID: TVSeries.ID) async throws + -> TranslationCollection { + let request = TVSeriesTranslationsRequest(id: tvSeriesID) + + let translationCollection: TranslationCollection + do { + translationCollection = try await apiClient.perform(request) + } catch let error { + throw TMDbError(error: error) + } + + return translationCollection + } + + func lists( + forTVSeries tvSeriesID: TVSeries.ID, + page: Int? = nil, + language: String? = nil + ) async throws -> MediaPageableList { + let languageCode = language ?? configuration.defaultLanguage + let request = TVSeriesListsRequest(id: tvSeriesID, page: page, language: languageCode) + + let mediaList: MediaPageableList + do { + mediaList = try await apiClient.perform(request) + } catch let error { + throw TMDbError(error: error) + } + + return mediaList + } + + func changes( + forTVSeries tvSeriesID: TVSeries.ID, + startDate: Date? = nil, + endDate: Date? = nil, + page: Int? = nil + ) async throws -> ChangeCollection { + let request = TVSeriesChangesRequest( + id: tvSeriesID, + startDate: startDate, + endDate: endDate, + page: page + ) + + let changeCollection: ChangeCollection + do { + changeCollection = try await apiClient.perform(request) + } catch let error { + throw TMDbError(error: error) + } + + return changeCollection + } + + func latest() async throws -> TVSeries { + let request = LatestTVSeriesRequest() + + let tvSeries: TVSeries + do { + tvSeries = try await apiClient.perform(request) + } catch let error { + throw TMDbError(error: error) + } + + return tvSeries + } + + func changes( + startDate: Date? = nil, + endDate: Date? = nil, + page: Int? = nil + ) async throws -> ChangedIDCollection { + let request = TVSeriesChangesListRequest( + startDate: startDate, + endDate: endDate, + page: page + ) + + let changedIDCollection: ChangedIDCollection + do { + changedIDCollection = try await apiClient.perform(request) + } catch let error { + throw TMDbError(error: error) + } + + return changedIDCollection + } + } diff --git a/Sources/TMDb/Domain/Services/TVSeries/TVSeriesService.swift b/Sources/TMDb/Domain/Services/TVSeries/TVSeriesService.swift index 0165bb09..159ce207 100644 --- a/Sources/TMDb/Domain/Services/TVSeries/TVSeriesService.swift +++ b/Sources/TMDb/Domain/Services/TVSeries/TVSeriesService.swift @@ -301,6 +301,170 @@ public protocol TVSeriesService: Sendable { /// - Returns: Content ratings for the TV series grouped by country. /// func contentRatings(forTVSeries tvSeriesID: TVSeries.ID) async throws -> [ContentRating] + + /// + /// Returns the user's rating, favorite, and watchlist state for a TV series. + /// + /// [TMDb API - TV Series: Account States](https://developer.themoviedb.org/reference/tv-series-account-states) + /// + /// - Parameters: + /// - tvSeriesID: The identifier of the TV series. + /// - session: The session. + /// + /// - Throws: TMDb error ``TMDbError``. + /// + /// - Returns: The user's account states for the TV series. + /// + func accountStates(forTVSeries tvSeriesID: TVSeries.ID, session: Session) async throws -> AccountStates + + /// + /// Adds a rating for a TV series. + /// + /// [TMDb API - TV Series: Add Rating](https://developer.themoviedb.org/reference/tv-series-add-rating) + /// + /// - Precondition: `rating` must be between 0.5 and 10.0, in increments of 0.5. + /// + /// - Parameters: + /// - rating: The rating value. + /// - tvSeriesID: The identifier of the TV series. + /// - session: The session. + /// + /// - Throws: TMDb error ``TMDbError``. + /// + func addRating(_ rating: Double, toTVSeries tvSeriesID: TVSeries.ID, session: Session) async throws + + /// + /// Deletes the user's rating for a TV series. + /// + /// [TMDb API - TV Series: Delete Rating](https://developer.themoviedb.org/reference/tv-series-delete-rating) + /// + /// - Parameters: + /// - tvSeriesID: The identifier of the TV series. + /// - session: The session. + /// + /// - Throws: TMDb error ``TMDbError``. + /// + func deleteRating(forTVSeries tvSeriesID: TVSeries.ID, session: Session) async throws + + /// + /// Returns keywords for a TV series. + /// + /// [TMDb API - TV Series: Keywords](https://developer.themoviedb.org/reference/tv-series-keywords) + /// + /// - Parameter tvSeriesID: The identifier of the TV series. + /// + /// - Throws: TMDb error ``TMDbError``. + /// + /// - Returns: A collection of keywords for the TV series. + /// + func keywords(forTVSeries tvSeriesID: TVSeries.ID) async throws -> KeywordCollection + + /// + /// Returns alternative titles for a TV series. + /// + /// [TMDb API - TV Series: Alternative Titles](https://developer.themoviedb.org/reference/tv-series-alternative-titles) + /// + /// - Parameter tvSeriesID: The identifier of the TV series. + /// + /// - Throws: TMDb error ``TMDbError``. + /// + /// - Returns: A collection of alternative titles for the TV series. + /// + func alternativeTitles(forTVSeries tvSeriesID: TVSeries.ID) async throws -> AlternativeTitleCollection + + /// + /// Returns translations for a TV series. + /// + /// [TMDb API - TV Series: Translations](https://developer.themoviedb.org/reference/tv-series-translations) + /// + /// - Parameter tvSeriesID: The identifier of the TV series. + /// + /// - Throws: TMDb error ``TMDbError``. + /// + /// - Returns: A collection of translations for the TV series. + /// + func translations(forTVSeries tvSeriesID: TVSeries.ID) async throws + -> TranslationCollection + + /// + /// Returns lists that contain the TV series. + /// + /// [TMDb API - TV Series: Lists](https://developer.themoviedb.org/reference/tv-series-lists) + /// + /// - Precondition: `page` can be between `1` and `1000`. + /// + /// - Parameters: + /// - tvSeriesID: The identifier of the TV series. + /// - page: The page of results to return. + /// - language: ISO 639-1 language code to display results in. Defaults to the client's configured default + /// language. + /// + /// - Throws: TMDb error ``TMDbError``. + /// + /// - Returns: Lists containing the TV series as a pageable list. + /// + func lists( + forTVSeries tvSeriesID: TVSeries.ID, + page: Int?, + language: String? + ) async throws -> MediaPageableList + + /// + /// Returns change history for a TV series. + /// + /// [TMDb API - TV Series: Changes](https://developer.themoviedb.org/reference/tv-series-changes) + /// + /// - Precondition: `page` can be between `1` and `1000`. + /// + /// - Parameters: + /// - tvSeriesID: The identifier of the TV series. + /// - startDate: The start date for changes. + /// - endDate: The end date for changes. + /// - page: The page of results to return. + /// + /// - Throws: TMDb error ``TMDbError``. + /// + /// - Returns: A collection of changes for the TV series. + /// + func changes( + forTVSeries tvSeriesID: TVSeries.ID, + startDate: Date?, + endDate: Date?, + page: Int? + ) async throws -> ChangeCollection + + /// + /// Returns the latest TV series added to TMDb. + /// + /// [TMDb API - TV Series: Latest](https://developer.themoviedb.org/reference/tv-series-latest-id) + /// + /// - Throws: TMDb error ``TMDbError``. + /// + /// - Returns: The latest TV series. + /// + func latest() async throws -> TVSeries + + /// + /// Returns a list of TV series IDs that have changed. + /// + /// [TMDb API - Changes: TV List](https://developer.themoviedb.org/reference/changes-tv-list) + /// + /// - Precondition: `page` can be between `1` and `1000`. + /// + /// - Parameters: + /// - startDate: The start date for changes. + /// - endDate: The end date for changes. + /// - page: The page of results to return. + /// + /// - Throws: TMDb error ``TMDbError``. + /// + /// - Returns: A collection of TV series IDs that have changed. + /// + func changes( + startDate: Date?, + endDate: Date?, + page: Int? + ) async throws -> ChangedIDCollection } public extension TVSeriesService { @@ -589,4 +753,84 @@ public extension TVSeriesService { try await topRated(page: page, language: language) } + /// + /// Returns lists that contain the TV series. + /// + /// [TMDb API - TV Series: Lists](https://developer.themoviedb.org/reference/tv-series-lists) + /// + /// - Precondition: `page` can be between `1` and `1000`. + /// + /// - Parameters: + /// - tvSeriesID: The identifier of the TV series. + /// - page: The page of results to return. + /// - language: ISO 639-1 language code to display results in. Defaults to the client's configured default + /// language. + /// + /// - Throws: TMDb error ``TMDbError``. + /// + /// - Returns: Lists containing the TV series as a pageable list. + /// + func lists( + forTVSeries tvSeriesID: TVSeries.ID, + page: Int? = nil, + language: String? = nil + ) async throws -> MediaPageableList { + try await lists(forTVSeries: tvSeriesID, page: page, language: language) + } + + /// + /// Returns change history for a TV series. + /// + /// [TMDb API - TV Series: Changes](https://developer.themoviedb.org/reference/tv-series-changes) + /// + /// - Precondition: `page` can be between `1` and `1000`. + /// + /// - Parameters: + /// - tvSeriesID: The identifier of the TV series. + /// - startDate: The start date for changes. + /// - endDate: The end date for changes. + /// - page: The page of results to return. + /// + /// - Throws: TMDb error ``TMDbError``. + /// + /// - Returns: A collection of changes for the TV series. + /// + func changes( + forTVSeries tvSeriesID: TVSeries.ID, + startDate: Date? = nil, + endDate: Date? = nil, + page: Int? = nil + ) async throws -> ChangeCollection { + try await changes( + forTVSeries: tvSeriesID, + startDate: startDate, + endDate: endDate, + page: page + ) + } + + /// + /// Returns a list of TV series IDs that have changed. + /// + /// [TMDb API - Changes: TV List](https://developer.themoviedb.org/reference/changes-tv-list) + /// + /// - Precondition: `page` can be between `1` and `1000`. + /// + /// - Parameters: + /// - startDate: The start date for changes. + /// - endDate: The end date for changes. + /// - page: The page of results to return. + /// + /// - Throws: TMDb error ``TMDbError``. + /// + /// - Returns: A collection of TV series IDs that have changed. + /// + func changes( + startDate: Date? = nil, + endDate: Date? = nil, + page: Int? = nil + ) async throws -> ChangedIDCollection { + try await changes(startDate: startDate, endDate: endDate, page: page) + } + } diff --git a/Sources/TMDb/TMDb.docc/Extensions/TVSeriesService.md b/Sources/TMDb/TMDb.docc/Extensions/TVSeriesService.md index f80f9bd8..79cf87c7 100644 --- a/Sources/TMDb/TMDb.docc/Extensions/TVSeriesService.md +++ b/Sources/TMDb/TMDb.docc/Extensions/TVSeriesService.md @@ -25,8 +25,31 @@ - ``recommendations(forTVSeries:page:language:)`` - ``similar(toTVSeries:page:language:)`` - ``popular(page:language:)`` +- ``lists(forTVSeries:page:language:)`` + +### User Interactions + +- ``accountStates(forTVSeries:session:)`` +- ``addRating(_:toTVSeries:session:)`` +- ``deleteRating(forTVSeries:session:)`` + +### Content Discovery + +- ``keywords(forTVSeries:)`` +- ``alternativeTitles(forTVSeries:)`` +- ``translations(forTVSeries:)`` + +### Change Tracking + +- ``changes(forTVSeries:startDate:endDate:page:)`` +- ``changes(startDate:endDate:page:)`` +- ``latest()`` ### Other - ``watchProviders(forTVSeries:)`` - ``externalLinks(forTVSeries:)`` +- ``contentRatings(forTVSeries:)`` +- ``airingToday(page:timezone:language:)`` +- ``onTheAir(page:timezone:language:)`` +- ``topRated(page:language:)`` diff --git a/Sources/TMDb/TMDb.docc/TMDb.md b/Sources/TMDb/TMDb.docc/TMDb.md index d4bf81d1..16dbdafd 100644 --- a/Sources/TMDb/TMDb.docc/TMDb.md +++ b/Sources/TMDb/TMDb.docc/TMDb.md @@ -87,6 +87,18 @@ Watch providers provided by [JustWatch](https://www.justwatch.com). - ``TVSeriesImageFilter`` - ``TVSeriesVideoFilter`` - ``ContentRating`` +- ``KeywordCollection`` +- ``AlternativeTitleCollection`` +- ``AlternativeTitle`` +- ``TranslationCollection`` +- ``Translation`` +- ``TVSeriesTranslationData`` +- ``ChangeCollection`` +- ``Change`` +- ``ChangeItem`` +- ``ChangeValue`` +- ``ChangedIDCollection`` +- ``ChangedID`` ### TV Seasons @@ -177,6 +189,10 @@ Watch providers provided by [JustWatch](https://www.justwatch.com). - ``CollectionTranslation`` - ``CollectionTranslationData`` +### Account States + +- ``AccountStates`` + ### Companies - ``CompanyService`` @@ -192,6 +208,7 @@ Watch providers provided by [JustWatch](https://www.justwatch.com). - ``KeywordService`` - ``Keyword`` +- ``KeywordCollection`` ### Networks diff --git a/Tests/TMDbIntegrationTests/TVSeriesServiceTests.swift b/Tests/TMDbIntegrationTests/TVSeriesServiceTests.swift index 8288d130..86c06cdf 100644 --- a/Tests/TMDbIntegrationTests/TVSeriesServiceTests.swift +++ b/Tests/TMDbIntegrationTests/TVSeriesServiceTests.swift @@ -220,4 +220,73 @@ struct TVSeriesServiceTests { let usProvider = result.first { $0.countryCode == "US" } #expect(usProvider != nil) } + + @Test("keywords") + func keywords() async throws { + let tvSeriesID = 1396 // Breaking Bad + + let keywordCollection = try await tvSeriesService.keywords(forTVSeries: tvSeriesID) + + #expect(keywordCollection.id == tvSeriesID) + #expect(!keywordCollection.keywords.isEmpty) + } + + @Test("alternativeTitles") + func alternativeTitles() async throws { + let tvSeriesID = 1396 // Breaking Bad + + let titleCollection = try await tvSeriesService.alternativeTitles(forTVSeries: tvSeriesID) + + #expect(titleCollection.id == tvSeriesID) + #expect(!titleCollection.titles.isEmpty) + } + + @Test("translations") + func translations() async throws { + let tvSeriesID = 1396 // Breaking Bad + + let translationCollection = try await tvSeriesService.translations(forTVSeries: tvSeriesID) + + #expect(translationCollection.id == tvSeriesID) + #expect(!translationCollection.translations.isEmpty) + + let enTranslation = translationCollection.translations.first { $0.languageCode == "en" } + #expect(enTranslation != nil) + } + + // Note: The /tv/{series_id}/lists endpoint appears to return data in a format + // that doesn't include mediaType, which is required by the Media model. + // This test is disabled pending investigation of the actual API response format. + // @Test("lists") + // func lists() async throws { + // let tvSeriesID = 1396 // Breaking Bad + // + // let mediaList = try await tvSeriesService.lists(forTVSeries: tvSeriesID) + // + // #expect(!mediaList.results.isEmpty) + // } + + @Test("latest") + func latest() async throws { + let tvSeries = try await tvSeriesService.latest() + + #expect(tvSeries.id > 0) + } + + @Test("changesForTVSeries") + func changesForTVSeries() async throws { + let tvSeriesID = 1396 // Breaking Bad + + let changeCollection = try await tvSeriesService.changes(forTVSeries: tvSeriesID) + + // May be empty if no recent changes + #expect(changeCollection.changes.count >= 0) + } + + @Test("changesForAllTVSeries") + func changesForAllTVSeries() async throws { + let changedIDCollection = try await tvSeriesService.changes() + + #expect(!changedIDCollection.results.isEmpty) + } } diff --git a/Tests/TMDbTests/Resources/json/tv-series-account-states.json b/Tests/TMDbTests/Resources/json/tv-series-account-states.json new file mode 100644 index 00000000..471b9e3e --- /dev/null +++ b/Tests/TMDbTests/Resources/json/tv-series-account-states.json @@ -0,0 +1,8 @@ +{ + "id": 1396, + "favorite": false, + "rated": { + "value": 8.5 + }, + "watchlist": true +} diff --git a/Tests/TMDbTests/Resources/json/tv-series-alternative-titles.json b/Tests/TMDbTests/Resources/json/tv-series-alternative-titles.json new file mode 100644 index 00000000..6672766f --- /dev/null +++ b/Tests/TMDbTests/Resources/json/tv-series-alternative-titles.json @@ -0,0 +1,20 @@ +{ + "id": 1396, + "results": [ + { + "iso_3166_1": "BG", + "title": "В обувките на Сатаната", + "type": "" + }, + { + "iso_3166_1": "BR", + "title": "Breaking Bad: A Química do Mal", + "type": "altered title for broadcast on tv channel Record" + }, + { + "iso_3166_1": "RU", + "title": "Во все тяжкие", + "type": "" + } + ] +} diff --git a/Tests/TMDbTests/Resources/json/tv-series-changes-list.json b/Tests/TMDbTests/Resources/json/tv-series-changes-list.json new file mode 100644 index 00000000..10db45be --- /dev/null +++ b/Tests/TMDbTests/Resources/json/tv-series-changes-list.json @@ -0,0 +1,19 @@ +{ + "results": [ + { + "id": 93749, + "adult": false + }, + { + "id": 312972, + "adult": false + }, + { + "id": 114155, + "adult": false + } + ], + "page": 1, + "total_pages": 31, + "total_results": 3038 +} diff --git a/Tests/TMDbTests/Resources/json/tv-series-changes.json b/Tests/TMDbTests/Resources/json/tv-series-changes.json new file mode 100644 index 00000000..c75cb229 --- /dev/null +++ b/Tests/TMDbTests/Resources/json/tv-series-changes.json @@ -0,0 +1,18 @@ +{ + "changes": [ + { + "key": "name", + "items": [ + { + "id": "5e9b3a7c0b9f8a001f8c4567", + "action": "updated", + "time": "2020-04-18T15:30:00.000Z", + "iso_639_1": "en", + "iso_3166_1": "US", + "value": "Breaking Bad", + "original_value": "Breaking Bad - Old" + } + ] + } + ] +} diff --git a/Tests/TMDbTests/Resources/json/tv-series-keywords.json b/Tests/TMDbTests/Resources/json/tv-series-keywords.json new file mode 100644 index 00000000..04f1fca2 --- /dev/null +++ b/Tests/TMDbTests/Resources/json/tv-series-keywords.json @@ -0,0 +1,17 @@ +{ + "id": 1396, + "results": [ + { + "name": "new mexico", + "id": 1508 + }, + { + "name": "drug dealer", + "id": 2231 + }, + { + "name": "crystal meth", + "id": 239108 + } + ] +} diff --git a/Tests/TMDbTests/Resources/json/tv-series-translations.json b/Tests/TMDbTests/Resources/json/tv-series-translations.json new file mode 100644 index 00000000..48b0d029 --- /dev/null +++ b/Tests/TMDbTests/Resources/json/tv-series-translations.json @@ -0,0 +1,29 @@ +{ + "id": 1396, + "translations": [ + { + "iso_3166_1": "US", + "iso_639_1": "en", + "name": "English", + "english_name": "English", + "data": { + "name": "", + "overview": "Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live.", + "homepage": "", + "tagline": "Change the equation." + } + }, + { + "iso_3166_1": "FR", + "iso_639_1": "fr", + "name": "Français", + "english_name": "French", + "data": { + "name": "", + "overview": "Un professeur de chimie atteint d'un cancer s'associe à un ancien élève pour fabriquer et vendre de la méthamphétamine afin d'assurer l'avenir financier de sa famille.", + "homepage": "", + "tagline": "Souviens-toi de mon nom." + } + } + ] +}