diff --git a/NexusPVR/Core/Models/DispatcharrVOD.swift b/NexusPVR/Core/Models/DispatcharrVOD.swift new file mode 100644 index 0000000..855d960 --- /dev/null +++ b/NexusPVR/Core/Models/DispatcharrVOD.swift @@ -0,0 +1,268 @@ +// +// DispatcharrVOD.swift +// nextpvr-apple-client +// +// VOD (video on demand) models for Dispatcharr (#17). +// +// All five structs are derived from the API contract in the pinned +// comment on issue #17, which itself is derived from reading the +// current Dispatcharr source (`apps/vod/`). Wire format is +// snake_case; we map to Swift camelCase via explicit CodingKeys. +// +// All fields are optional where the server can legitimately omit +// them, so the model survives a Dispatcharr upgrade that adds a +// new field without a corresponding model bump on our side. +// +// Scope of this file: models only. The `DispatcherClient` +// extensions and view skeletons land in follow-up PRs — see the +// PR body for the breakdown. +// + +import Foundation + +// MARK: - VODLogo + +nonisolated struct VODLogo: Codable, Equatable, Sendable { + let id: Int + let url: String + let cacheURL: String + + enum CodingKeys: String, CodingKey { + case id + case url + case cacheURL = "cache_url" + } +} + +// MARK: - VODCategory + +nonisolated struct VODCategory: Codable, Identifiable, Equatable, Sendable { + let id: Int + let name: String + /// "movie" | "series" (Dispatcharr's two category_type values) + let categoryType: String + + enum CodingKeys: String, CodingKey { + case id + case name + case categoryType = "category_type" + } +} + +// MARK: - VODItem (browse shape) + +nonisolated struct VODItem: Codable, Identifiable, Equatable, Sendable { + let id: Int + let uuid: String + let name: String + let description: String? + let year: Int? + let rating: String? + let genre: String? + /// Seconds; nil for series (where total runtime is per-episode) + let duration: Int? + /// "movie" | "series" + let contentType: String + let logo: VODLogo? + + enum CodingKeys: String, CodingKey { + case id + case uuid + case name + case description + case year + case rating + case genre + case duration + case contentType = "content_type" + case logo + } +} + +// MARK: - VODPage (paginated browse response) + +nonisolated struct VODPage: Codable, Equatable, Sendable { + let count: Int + let next: Bool + let previous: Bool + let results: [VODItem] +} + +// MARK: - VODMovieDetail + +nonisolated struct VODMovieDetail: Codable, Equatable, Sendable { + let id: Int + let uuid: String + /// Stream ID — used as `?stream_id=` to pick a specific provider + /// in the multi-provider case (issue #17 spec §"Streaming"). + let streamId: String + let name: String + let description: String? + let plot: String? + let year: Int? + let genre: String? + let director: String? + let actors: String? + let country: String? + let rating: String? + let tmdbId: String? + let imdbId: String? + let youtubeTrailer: String? + let durationSecs: Int? + let backdropPath: [String]? + let coverBig: String? + let containerExtension: String? + + enum CodingKeys: String, CodingKey { + case id + case uuid + case streamId = "stream_id" + case name + case description + case plot + case year + case genre + case director + case actors + case country + case rating + case tmdbId = "tmdb_id" + case imdbId = "imdb_id" + case youtubeTrailer = "youtube_trailer" + case durationSecs = "duration_secs" + case backdropPath = "backdrop_path" + case coverBig = "cover_big" + case containerExtension = "container_extension" + } +} + +// MARK: - VODEpisode + +nonisolated struct VODEpisode: Codable, Identifiable, Equatable, Sendable { + let id: Int + let uuid: String + let name: String + let episodeNumber: Int? + let seasonNumber: Int? + let description: String? + /// ISO date string "YYYY-MM-DD"; not parsed to `Date` because + /// the server emits it in mixed shapes (sometimes "2008-01-20", + /// sometimes a full timestamp) and the UI only needs the string. + let airDate: String? + let durationSecs: Int? + let containerExtension: String? + + enum CodingKeys: String, CodingKey { + case id + case uuid + case name + case episodeNumber = "episode_number" + case seasonNumber = "season_number" + case description + case airDate = "air_date" + case durationSecs = "duration_secs" + case containerExtension = "container_extension" + } +} + +// MARK: - VODSeriesDetail + +nonisolated struct VODSeriesDetail: Codable, Equatable, Sendable { + let id: Int + let name: String + let description: String? + let year: Int? + let genre: String? + let rating: String? + let tmdbId: String? + let imdbId: String? + let cover: VODLogo? + /// Episodes keyed by season number as a string ("1", "2", ...) + /// because that's how Dispatcharr emits the dictionary and JSON + /// keys are always strings. The view layer converts to Int for + /// display and ordering. + let episodes: [String: [VODEpisode]] + + enum CodingKeys: String, CodingKey { + case id + case name + case description + case year + case genre + case rating + case tmdbId = "tmdb_id" + case imdbId = "imdb_id" + case cover + case episodes + } +} + +// MARK: - VODProviders (multi-provider movie response) + +nonisolated struct VODProvider: Codable, Identifiable, Equatable, Sendable { + let streamId: String + let containerExtension: String? + let quality: String? + let resolution: String? + let m3uAccountId: Int? + let m3uAccountName: String? + + var id: String { streamId } + + /// Explicit memberwise initializer so the model can be + /// constructed directly in tests (the custom `init(from:)` + /// below would otherwise hide the synthesized memberwise form). + init(streamId: String, containerExtension: String? = nil, quality: String? = nil, resolution: String? = nil, m3uAccountId: Int? = nil, m3uAccountName: String? = nil) { + self.streamId = streamId + self.containerExtension = containerExtension + self.quality = quality + self.resolution = resolution + self.m3uAccountId = m3uAccountId + self.m3uAccountName = m3uAccountName + } + + enum CodingKeys: String, CodingKey { + case streamId = "stream_id" + case containerExtension = "container_extension" + case quality + case resolution + case m3uAccountId = "m3u_account" + case m3uAccountName = "m3u_account_name" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + streamId = try c.decode(String.self, forKey: .streamId) + containerExtension = try c.decodeIfPresent(String.self, forKey: .containerExtension) + quality = try c.decodeIfPresent(String.self, forKey: .quality) + resolution = try c.decodeIfPresent(String.self, forKey: .resolution) + // `m3u_account` is a nested {id, name} dict on the server; + // flatten it for caller convenience. + if let account = try? c.nestedContainer(keyedBy: M3UAccountKeys.self, forKey: .m3uAccountId) { + m3uAccountId = try account.decodeIfPresent(Int.self, forKey: .id) + m3uAccountName = try account.decodeIfPresent(String.self, forKey: .name) + } else { + m3uAccountId = nil + m3uAccountName = nil + } + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(streamId, forKey: .streamId) + try c.encodeIfPresent(containerExtension, forKey: .containerExtension) + try c.encodeIfPresent(quality, forKey: .quality) + try c.encodeIfPresent(resolution, forKey: .resolution) + // Round-trip the nested m3u_account dict. + if m3uAccountId != nil || m3uAccountName != nil { + var account = c.nestedContainer(keyedBy: M3UAccountKeys.self, forKey: .m3uAccountId) + try account.encodeIfPresent(m3uAccountId, forKey: .id) + try account.encodeIfPresent(m3uAccountName, forKey: .name) + } + } + + private enum M3UAccountKeys: String, CodingKey { + case id + case name + } +} \ No newline at end of file diff --git a/NexusPVR/Core/Services/DispatcherClient.swift b/NexusPVR/Core/Services/DispatcherClient.swift index ebc2893..bf7458f 100644 --- a/NexusPVR/Core/Services/DispatcherClient.swift +++ b/NexusPVR/Core/Services/DispatcherClient.swift @@ -1681,6 +1681,110 @@ final class DispatcherClient: ObservableObject, PVRClientProtocol { return try await authenticatedRequest(url) } + // MARK: - VOD / Movies / Series (#17) + + /// All VOD categories, both movies and series. The view layer + /// filters client-side by `categoryType`. The server-side + /// endpoint accepts `?category_type=movie` / `category_type=series` + /// but the broad listing is what the picker wants. + func fetchVODCategories(categoryType: String? = nil) async throws -> [VODCategory] { + guard !config.isDemoMode else { return [] } + if useOutputEndpoints { return [] } + var components = URLComponents(string: "\(baseURL)/api/vod/categories/")! + if let categoryType { + components.queryItems = [URLQueryItem(name: "category_type", value: categoryType)] + } + guard let url = components.url else { throw PVRClientError.invalidResponse } + return try await authenticatedRequest(url) + } + + /// Unified browse — movies + series in a single paginated + /// list. The view layer filters by `category` query param (the + /// format is `"CategoryName|movie"` or `"CategoryName|series"` + /// per issue #17 spec). `search` is a server-side substring + /// match on name. + func fetchVODContent(page: Int = 1, pageSize: Int = 20, search: String? = nil, category: String? = nil) async throws -> VODPage { + guard !config.isDemoMode else { return VODPage(count: 0, next: false, previous: false, results: []) } + if useOutputEndpoints { return VODPage(count: 0, next: false, previous: false, results: []) } + var components = URLComponents(string: "\(baseURL)/api/vod/all/")! + var items: [URLQueryItem] = [ + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "page_size", value: String(pageSize)) + ] + if let search, !search.isEmpty { + items.append(URLQueryItem(name: "search", value: search)) + } + if let category, !category.isEmpty { + items.append(URLQueryItem(name: "category", value: category)) + } + components.queryItems = items + guard let url = components.url else { throw PVRClientError.invalidResponse } + return try await authenticatedRequest(url) + } + + /// Movie detail including provider info, plot, cast, backdrop. + /// Pass `forceRefresh: true` to bypass Dispatcharr's TMDB cache + /// (rare; the cached shape is what you want). + func fetchMovieDetail(id: Int, forceRefresh: Bool = false) async throws -> VODMovieDetail { + guard !config.isDemoMode else { throw PVRClientError.invalidResponse } + if useOutputEndpoints { throw PVRClientError.invalidResponse } + var components = URLComponents(string: "\(baseURL)/api/vod/movies/\(id)/provider-info/")! + if forceRefresh { + components.queryItems = [URLQueryItem(name: "force_refresh", value: "true")] + } + guard let url = components.url else { throw PVRClientError.invalidResponse } + return try await authenticatedRequest(url) + } + + /// Multi-provider list for a movie. The `VODMovieDetail.streamId` + /// is the default; this returns the alternatives if the movie + /// exists across multiple M3U accounts. + func fetchMovieProviders(id: Int) async throws -> [VODProvider] { + guard !config.isDemoMode else { return [] } + if useOutputEndpoints { return [] } + guard let url = URL(string: "\(baseURL)/api/vod/movies/\(id)/providers/") else { + throw PVRClientError.invalidResponse + } + return try await authenticatedRequest(url) + } + + /// Series detail with episodes. `includeEpisodes` defaults to + /// true because that's what the SeriesDetailView needs; pass + /// false only if you want a lightweight header. + func fetchSeriesDetail(id: Int, includeEpisodes: Bool = true) async throws -> VODSeriesDetail { + guard !config.isDemoMode else { throw PVRClientError.invalidResponse } + if useOutputEndpoints { throw PVRClientError.invalidResponse } + var components = URLComponents(string: "\(baseURL)/api/vod/series/\(id)/provider-info/")! + if includeEpisodes { + components.queryItems = [URLQueryItem(name: "include_episodes", value: "true")] + } + guard let url = components.url else { throw PVRClientError.invalidResponse } + return try await authenticatedRequest(url) + } + + /// Build the proxy URL for a VOD movie or episode. `URLSession` + /// will follow the 301 to the session URL automatically with + /// `allowRedirects: true` (the Swift default), which is how + /// Dispatcharr reuses the upstream slot for connection + /// persistence. Optional `m3uAccountId` / `streamId` pick a + /// specific provider when the content exists across multiple + /// M3U accounts; pass nil to let the server pick the highest + /// priority one. + func vodStreamURL(contentType: String, uuid: String, m3uAccountId: Int? = nil, streamId: String? = nil) -> URL? { + var components = URLComponents(string: "\(baseURL)/proxy/vod/\(contentType)/\(uuid)")! + var items: [URLQueryItem] = [] + if let m3uAccountId { + items.append(URLQueryItem(name: "m3u_account_id", value: String(m3uAccountId))) + } + if let streamId { + items.append(URLQueryItem(name: "stream_id", value: streamId)) + } + if !items.isEmpty { + components.queryItems = items + } + return components.url + } + // MARK: - Stream Switching /// Streams assigned to a channel, in the channel's configured order. diff --git a/NexusPVRTests/DispatcharrVODTests.swift b/NexusPVRTests/DispatcharrVODTests.swift new file mode 100644 index 0000000..6d6b68b --- /dev/null +++ b/NexusPVRTests/DispatcharrVODTests.swift @@ -0,0 +1,334 @@ +// +// DispatcharrVODTests.swift +// NexusPVRTests +// +// Tests for the Dispatcharr VOD models (#17). All fixtures use +// synthetic UUIDs (nil-UUID with variant nibble set, e.g. +// "00000000-0000-0000-0000-000000000001") and synthetic names so +// the public test file carries no real user data. +// +// Wire-format coverage: every Codable struct is decoded from a +// realistic Dispatcharr JSON payload, the snake_case keys are +// pinned, and round-trip through JSONEncoder confirms no field +// loss. +// + +import Testing +import Foundation +@testable import NextPVR + +@MainActor +struct DispatcharrVODTests { + + // MARK: - VODLogo + + @Test("VODLogo decodes the snake_case cache_url") + func vodLogoDecode() throws { + let json = #"{"id": 7, "url": "https://example.invalid/logo.png", "cache_url": "/api/vod/vodlogos/7/cache/"}"# + let logo = try JSONDecoder().decode(VODLogo.self, from: Data(json.utf8)) + #expect(logo.id == 7) + #expect(logo.url == "https://example.invalid/logo.png") + #expect(logo.cacheURL == "/api/vod/vodlogos/7/cache/") + } + + @Test("VODLogo round-trips via Codable") + func vodLogoRoundTrip() throws { + let original = VODLogo(id: 1, url: "u", cacheURL: "c") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(VODLogo.self, from: data) + #expect(decoded == original) + } + + // MARK: - VODCategory + + @Test("VODCategory decodes the snake_case category_type") + func vodCategoryDecode() throws { + let json = #"{"id": 3, "name": "Drama", "category_type": "movie"}"# + let cat = try JSONDecoder().decode(VODCategory.self, from: Data(json.utf8)) + #expect(cat.id == 3) + #expect(cat.name == "Drama") + #expect(cat.categoryType == "movie") + } + + @Test("VODCategory round-trips via Codable") + func vodCategoryRoundTrip() throws { + let original = VODCategory(id: 3, name: "Drama", categoryType: "movie") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(VODCategory.self, from: data) + #expect(decoded == original) + } + + // MARK: - VODItem (browse) + + @Test("VODItem decodes the unified /all/ shape") + func vodItemDecode() throws { + let json = #""" + { + "id": 42, + "uuid": "00000000-0000-0000-0000-000000000001", + "name": "Example Series", + "description": "A test fixture.", + "year": 2024, + "rating": "TV-14", + "genre": "Drama", + "duration": null, + "content_type": "series", + "logo": { "id": 7, "url": "https://example.invalid/l.png", "cache_url": "/api/vod/vodlogos/7/cache/" } + } + """# + let item = try JSONDecoder().decode(VODItem.self, from: Data(json.utf8)) + #expect(item.id == 42) + #expect(item.uuid == "00000000-0000-0000-0000-000000000001") + #expect(item.name == "Example Series") + #expect(item.contentType == "series") + #expect(item.duration == nil, "duration is nil for series per the spec") + #expect(item.logo?.id == 7) + } + + @Test("VODItem tolerates missing optional fields") + func vodItemOptionalFields() throws { + // Bare-minimum VODItem: just id, uuid, name, content_type. + let json = #"{"id": 1, "uuid": "00000000-0000-0000-0000-000000000002", "name": "Bare Item", "content_type": "movie"}"# + let item = try JSONDecoder().decode(VODItem.self, from: Data(json.utf8)) + #expect(item.description == nil) + #expect(item.year == nil) + #expect(item.rating == nil) + #expect(item.genre == nil) + #expect(item.duration == nil) + #expect(item.logo == nil) + } + + @Test("VODItem snake_case wire format pin") + func vodItemWireFormatPin() throws { + // Issue #17 contract: server emits snake_case content_type + // and snake_case everywhere. Pin the wire format explicitly + // so a future rename is caught at PR-review time. + let json = #"{"id": 1, "uuid": "00000000-0000-0000-0000-000000000003", "name": "X", "content_type": "movie"}"# + let decoded = try JSONDecoder().decode(VODItem.self, from: Data(json.utf8)) + #expect(decoded.contentType == "movie") + + let encoded = String(data: try JSONEncoder().encode(decoded), encoding: .utf8)! + #expect(encoded.contains("\"content_type\"")) + #expect(!encoded.contains("\"contentType\""), "must encode snake_case to match server") + } + + // MARK: - VODPage + + @Test("VODPage decodes the paginated response shape") + func vodPageDecode() throws { + let json = #""" + { + "count": 200, + "next": true, + "previous": false, + "results": [ + {"id": 1, "uuid": "00000000-0000-0000-0000-000000000010", "name": "Item 1", "content_type": "movie"}, + {"id": 2, "uuid": "00000000-0000-0000-0000-000000000011", "name": "Item 2", "content_type": "series"} + ] + } + """# + let page = try JSONDecoder().decode(VODPage.self, from: Data(json.utf8)) + #expect(page.count == 200) + #expect(page.next == true) + #expect(page.previous == false) + #expect(page.results.count == 2) + #expect(page.results[0].name == "Item 1") + #expect(page.results[1].contentType == "series") + } + + @Test("VODPage decodes an empty result set") + func vodPageEmpty() throws { + let json = #"{"count": 0, "next": false, "previous": false, "results": []}"# + let page = try JSONDecoder().decode(VODPage.self, from: Data(json.utf8)) + #expect(page.results.isEmpty) + #expect(page.next == false) + } + + // MARK: - VODMovieDetail + + @Test("VODMovieDetail decodes the canonical provider-info payload") + func vodMovieDetailDecode() throws { + let json = #""" + { + "id": 99, + "uuid": "00000000-0000-0000-0000-000000000099", + "stream_id": "12345", + "name": "Example Movie", + "description": "A test fixture movie.", + "plot": "Full plot text.", + "year": 2023, + "genre": "Action", + "director": "Director Name", + "actors": "Actor One, Actor Two", + "country": "US", + "rating": "PG-13", + "tmdb_id": "550000", + "imdb_id": "tt550000", + "youtube_trailer": "https://example.invalid/trailer", + "duration_secs": 7320, + "backdrop_path": ["https://example.invalid/back1.jpg", "https://example.invalid/back2.jpg"], + "cover_big": "https://example.invalid/cover.jpg", + "container_extension": "mp4" + } + """# + let movie = try JSONDecoder().decode(VODMovieDetail.self, from: Data(json.utf8)) + #expect(movie.id == 99) + #expect(movie.streamId == "12345") + #expect(movie.name == "Example Movie") + #expect(movie.year == 2023) + #expect(movie.tmdbId == "550000") + #expect(movie.imdbId == "tt550000") + #expect(movie.durationSecs == 7320) + #expect(movie.backdropPath?.count == 2) + #expect(movie.containerExtension == "mp4") + } + + @Test("VODMovieDetail tolerates missing optional fields") + func vodMovieDetailOptionalFields() throws { + // Minimal payload — only required fields populated. + let json = #""" + { + "id": 99, + "uuid": "00000000-0000-0000-0000-000000000099", + "stream_id": "12345", + "name": "Minimal Movie" + } + """# + let movie = try JSONDecoder().decode(VODMovieDetail.self, from: Data(json.utf8)) + #expect(movie.plot == nil) + #expect(movie.director == nil) + #expect(movie.tmdbId == nil) + #expect(movie.backdropPath == nil) + } + + // MARK: - VODEpisode + + @Test("VODEpisode decodes the per-episode shape") + func vodEpisodeDecode() throws { + let json = #""" + { + "id": 10, + "uuid": "00000000-0000-0000-0000-0000000000aa", + "name": "Pilot", + "episode_number": 1, + "season_number": 1, + "description": "First episode.", + "air_date": "2024-01-15", + "duration_secs": 2700, + "container_extension": "mkv" + } + """# + let ep = try JSONDecoder().decode(VODEpisode.self, from: Data(json.utf8)) + #expect(ep.id == 10) + #expect(ep.name == "Pilot") + #expect(ep.episodeNumber == 1) + #expect(ep.seasonNumber == 1) + #expect(ep.airDate == "2024-01-15") + #expect(ep.durationSecs == 2700) + #expect(ep.containerExtension == "mkv") + } + + // MARK: - VODSeriesDetail + + @Test("VODSeriesDetail decodes episodes keyed by season-string") + func vodSeriesDetailDecode() throws { + // Dispatcharr emits episodes as { "1": [...], "2": [...] } — + // string keys, not int. Make sure the model respects that + // and the view layer can still extract season numbers. + let json = #""" + { + "id": 7, + "name": "Example Series", + "description": "A test fixture series.", + "year": 2024, + "genre": "Sci-Fi", + "rating": "TV-MA", + "tmdb_id": "999000", + "imdb_id": "tt999000", + "cover": { "id": 12, "url": "https://example.invalid/c.jpg", "cache_url": "/api/vod/vodlogos/12/cache/" }, + "episodes": { + "1": [ + { "id": 100, "uuid": "00000000-0000-0000-0000-0000000000c8", "name": "S1E1", "episode_number": 1, "season_number": 1, "duration_secs": 2700 }, + { "id": 101, "uuid": "00000000-0000-0000-0000-0000000000c9", "name": "S1E2", "episode_number": 2, "season_number": 1, "duration_secs": 2800 } + ], + "2": [ + { "id": 200, "uuid": "00000000-0000-0000-0000-0000000000d0", "name": "S2E1", "episode_number": 1, "season_number": 2, "duration_secs": 2700 } + ] + } + } + """# + let series = try JSONDecoder().decode(VODSeriesDetail.self, from: Data(json.utf8)) + #expect(series.id == 7) + #expect(series.name == "Example Series") + #expect(series.tmdbId == "999000") + #expect(series.episodes.count == 2) + #expect(series.episodes["1"]?.count == 2) + #expect(series.episodes["1"]?[0].name == "S1E1") + #expect(series.episodes["2"]?.count == 1) + // Confirm the view layer can derive Int season numbers. + let seasonNumbers = series.episodes.keys.compactMap(Int.init) + #expect(seasonNumbers.contains(1)) + #expect(seasonNumbers.contains(2)) + } + + @Test("VODSeriesDetail tolerates an empty episodes dictionary") + func vodSeriesDetailEmptyEpisodes() throws { + // Forward-compat: a series with no episodes yet (mid-release, + // or unindexed) should still decode cleanly. + let json = #""" + { "id": 7, "name": "Empty Series", "episodes": {} } + """# + let series = try JSONDecoder().decode(VODSeriesDetail.self, from: Data(json.utf8)) + #expect(series.episodes.isEmpty) + #expect(series.name == "Empty Series") + } + + // MARK: - VODProvider + + @Test("VODProvider decodes and flattens nested m3u_account") + func vodProviderDecode() throws { + // Dispatcharr emits m3u_account as a nested {id, name} dict. + // The model flattens it for caller convenience. + let json = #""" + { + "stream_id": "s-12345", + "container_extension": "mp4", + "quality": "1080p", + "resolution": "1920x1080", + "m3u_account": { "id": 7, "name": "Primary M3U" } + } + """# + let p = try JSONDecoder().decode(VODProvider.self, from: Data(json.utf8)) + #expect(p.streamId == "s-12345") + #expect(p.containerExtension == "mp4") + #expect(p.quality == "1080p") + #expect(p.resolution == "1920x1080") + #expect(p.m3uAccountId == 7) + #expect(p.m3uAccountName == "Primary M3U") + } + + @Test("VODProvider tolerates missing m3u_account") + func vodProviderNoAccount() throws { + // The m3u_account sub-object can be missing (rare, but the + // server may emit it for orphan providers). Model should + // decode with m3uAccountId / m3uAccountName nil. + let json = #""" + { "stream_id": "s-99999", "container_extension": "ts" } + """# + let p = try JSONDecoder().decode(VODProvider.self, from: Data(json.utf8)) + #expect(p.streamId == "s-99999") + #expect(p.containerExtension == "ts") + #expect(p.m3uAccountId == nil) + #expect(p.m3uAccountName == nil) + } + + @Test("VODProvider round-trips with the nested m3u_account") + func vodProviderRoundTrip() throws { + let original = VODProvider(streamId: "s-1", containerExtension: "mkv", quality: "4K", resolution: "3840x2160", m3uAccountId: 5, m3uAccountName: "Backup") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(VODProvider.self, from: data) + #expect(decoded.streamId == original.streamId) + #expect(decoded.m3uAccountId == original.m3uAccountId) + #expect(decoded.m3uAccountName == original.m3uAccountName) + } +} \ No newline at end of file