diff --git a/Yattee/Models/RemotePlaylistEntry.swift b/Yattee/Models/RemotePlaylistEntry.swift new file mode 100644 index 00000000..4e288f5e --- /dev/null +++ b/Yattee/Models/RemotePlaylistEntry.swift @@ -0,0 +1,75 @@ +// +// RemotePlaylistEntry.swift +// Yattee +// +// Per-entry identity for playlists that live on an instance, so a later +// mutation can name exactly which row it removes. +// + +import Foundation + +/// Identifies one row of a server-side playlist. +/// +/// The playlist ID is part of every case on purpose: entry identifiers are +/// compared and stored across playlists (caches, selections, pending +/// mutations), and position 3 of one playlist must never collide with +/// position 3 of another. +/// +/// The two backends offer very different guarantees, and the case you get +/// tells you which one you are holding: +/// +/// - ``invidious(playlistID:indexID:)`` wraps the server's `indexId` token, +/// which names the same row across refetches even as videos above it are +/// removed. +/// - ``piped(playlistID:position:)`` is **snapshot-scoped only**. Piped +/// removes by position, so an id minted from one fetch is meaningless once +/// the playlist changes. This is safe because the caching layer refetches +/// the whole playlist after every mutation — never persist these, and never +/// compare one against an id from an earlier snapshot. +enum RemotePlaylistEntryID: Hashable, Sendable { + /// Stable across snapshots — Invidious hands out a per-entry `indexId`. + case invidious(playlistID: String, indexID: String) + + /// Snapshot-scoped only — Piped addresses entries by their position. + case piped(playlistID: String, position: Int) +} + +/// One video in a server-side playlist, paired with the identity needed to +/// remove it. +/// +/// Two entries holding the same video are still distinct values: ``Video`` +/// hashes by id alone, but ``id`` carries the per-entry token, so a playlist +/// containing the same video twice yields two entries that never collapse. +struct RemotePlaylistEntry: Identifiable, Hashable, Sendable { + /// The backend-specific handle for this row. + let id: RemotePlaylistEntryID + + /// The video shown for this row. + let video: Video + + /// Zero-based position within the playlist as the snapshot saw it. + let position: Int + + init(id: RemotePlaylistEntryID, video: Video, position: Int) { + self.id = id + self.video = video + self.position = position + } +} + +/// A playlist as fetched at one moment, together with its addressable entries. +/// +/// `entries` may be shorter than `playlist.videos`: a video the server did not +/// hand an identity to is still displayed, it just cannot be a mutation target. +struct RemotePlaylistSnapshot: Hashable, Sendable { + /// The playlist for display. + let playlist: Playlist + + /// The rows that can be addressed for removal, in playlist order. + let entries: [RemotePlaylistEntry] + + init(playlist: Playlist, entries: [RemotePlaylistEntry]) { + self.playlist = playlist + self.entries = entries + } +} diff --git a/Yattee/Services/API/InvidiousAPI.swift b/Yattee/Services/API/InvidiousAPI.swift index e4c3d233..31ceb1f0 100644 --- a/Yattee/Services/API/InvidiousAPI.swift +++ b/Yattee/Services/API/InvidiousAPI.swift @@ -220,6 +220,21 @@ actor InvidiousAPI: InstanceAPI { /// - sid: The session ID from login /// - Returns: The playlist with videos func userPlaylist(id: String, instance: Instance, sid: String) async throws -> Playlist { + try await userPlaylistSnapshot(id: id, instance: instance, sid: sid).playlist + } + + /// Fetches a user's playlist along with the per-entry identities needed to + /// mutate it. + /// + /// Same request as ``userPlaylist(id:instance:sid:)`` — that one is a thin + /// wrapper over this — but it keeps the `index`/`indexId` tokens Invidious + /// hands out for every row instead of dropping them on conversion. + /// - Parameters: + /// - id: The playlist ID + /// - instance: The Invidious instance + /// - sid: The session ID from login + /// - Returns: The playlist plus its addressable entries + func userPlaylistSnapshot(id: String, instance: Instance, sid: String) async throws -> RemotePlaylistSnapshot { let headers = ["Cookie": "SID=\(sid)"] let firstEndpoint = GenericEndpoint( path: "/api/v1/auth/playlists/\(id)", @@ -227,7 +242,7 @@ actor InvidiousAPI: InstanceAPI { headers: headers ) let firstResponse: InvidiousPlaylist = try await httpClient.fetch(firstEndpoint, baseURL: instance.url) - var allVideos = firstResponse.videos ?? [] + var additionalPages: [InvidiousPlaylist] = [] let maxPages = 50 if firstResponse.videoCount > 0 { @@ -239,14 +254,41 @@ actor InvidiousAPI: InstanceAPI { headers: headers ) let response: InvidiousPlaylist = try await httpClient.fetch(endpoint, baseURL: instance.url) - let pageVideos = response.videos ?? [] - if pageVideos.isEmpty { break } - allVideos.append(contentsOf: pageVideos) + if (response.videos ?? []).isEmpty { break } + additionalPages.append(response) page += 1 } } - // Invidious pagination uses overlapping pages — deduplicate by playlist index + return Self.buildAuthPlaylistSnapshot( + firstPage: firstResponse, + additionalPages: additionalPages, + playlistID: id, + baseURL: instance.url + ) + } + + /// Builds a snapshot from already-fetched pages of + /// `/api/v1/auth/playlists/{id}`. + /// + /// Pure: the network path and ``authPlaylistSnapshot(fromPageJSON:playlistID:baseURL:)`` + /// both funnel through here, so the merge, dedupe and ordering rules have + /// exactly one implementation. + private static func buildAuthPlaylistSnapshot( + firstPage: InvidiousPlaylist, + additionalPages: [InvidiousPlaylist], + playlistID: String, + baseURL: URL + ) -> RemotePlaylistSnapshot { + var allVideos = firstPage.videos ?? [] + for page in additionalPages { + allVideos.append(contentsOf: page.videos ?? []) + } + + // Invidious pagination uses overlapping pages — deduplicate by playlist + // index. The key is deliberately never `videoId`: a playlist may hold + // the same video twice at different indexes, and each copy is its own + // removable row with its own indexId. var seenIndices = Set() allVideos = allVideos.filter { item in if case .video(let video) = item, let index = video.index { @@ -255,15 +297,72 @@ actor InvidiousAPI: InstanceAPI { return true } - return InvidiousPlaylist( - playlistId: firstResponse.playlistId, - title: firstResponse.title, - description: firstResponse.description, - author: firstResponse.author, - authorId: firstResponse.authorId, - videoCount: firstResponse.videoCount, + // Order by the server's index so entry positions ascend even if a page + // arrives out of order. Rows the server left unindexed keep their + // arrival order and sort last; the sort is stable either way. + allVideos = allVideos.enumerated().sorted { lhs, rhs in + switch (lhs.element.playlistIndex, rhs.element.playlistIndex) { + case let (left?, right?): + return left == right ? lhs.offset < rhs.offset : left < right + case (_?, nil): + return true + case (nil, _?): + return false + case (nil, nil): + return lhs.offset < rhs.offset + } + }.map(\.element) + + let playlist = InvidiousPlaylist( + playlistId: firstPage.playlistId, + title: firstPage.title, + description: firstPage.description, + author: firstPage.author, + authorId: firstPage.authorId, + videoCount: firstPage.videoCount, videos: allVideos - ).toPlaylist(baseURL: instance.url) + ).toPlaylist(baseURL: baseURL) + + // A row needs both tokens to be a mutation target: `indexId` names it + // and `index` places it. Rows missing either are still displayed — + // they stay in `playlist.videos` — they just never appear in `entries`, + // so no removal can be aimed at a row the server refuses to name. + let entries: [RemotePlaylistEntry] = allVideos.compactMap { item in + guard case .video(let video) = item, + let indexID = video.indexId, + let index = video.index + else { return nil } + + return RemotePlaylistEntry( + id: .invidious(playlistID: playlistID, indexID: indexID), + video: video.toVideo(baseURL: baseURL), + position: index + ) + } + + return RemotePlaylistSnapshot(playlist: playlist, entries: entries) + } + + /// Runs the snapshot assembly over raw page payloads, decoding them the way + /// the network path does. Lets the merge/dedupe/ordering rules be exercised + /// without a server. + static func authPlaylistSnapshot( + fromPageJSON pages: [Data], + playlistID: String, + baseURL: URL + ) throws -> RemotePlaylistSnapshot { + let decoder = HTTPClient.makeAPIDecoder() + let decoded = try pages.map { try decoder.decode(InvidiousPlaylist.self, from: $0) } + guard let firstPage = decoded.first else { + throw APIError.decodingError("No playlist pages to decode") + } + + return buildAuthPlaylistSnapshot( + firstPage: firstPage, + additionalPages: Array(decoded.dropFirst()), + playlistID: playlistID, + baseURL: baseURL + ) } func comments(videoID: String, instance: Instance, continuation: String?) async throws -> CommentsPage { @@ -812,6 +911,9 @@ private struct InvidiousAuthFeedResponse: Decodable, Sendable { private struct InvidiousVideo: Decodable, Sendable { let videoId: String let index: Int? + /// Per-entry token from the authenticated playlist endpoints; the handle + /// Invidious accepts when removing this exact row. + let indexId: String? let title: String let description: String? let author: String @@ -1525,6 +1627,15 @@ private enum InvidiousPlaylistItem: Decodable, Sendable { self = .unknown } } + + /// The playlist index of the wrapped video, when the item is a video the + /// server indexed. + var playlistIndex: Int? { + if case .video(let video) = self { + return video.index + } + return nil + } } private struct InvidiousPlaylist: Decodable, Sendable { diff --git a/Yattee/Services/API/PipedAPI.swift b/Yattee/Services/API/PipedAPI.swift index 809d5e2c..b3edb720 100644 --- a/Yattee/Services/API/PipedAPI.swift +++ b/Yattee/Services/API/PipedAPI.swift @@ -371,6 +371,22 @@ actor PipedAPI: InstanceAPI { /// - authToken: The auth token from login /// - Returns: Playlist with videos func userPlaylist(id: String, instance: Instance, authToken: String) async throws -> Playlist { + try await userPlaylistSnapshot(id: id, instance: instance, authToken: authToken).playlist + } + + /// Fetches a user playlist along with the per-entry identities needed to + /// mutate it. + /// + /// Same request as ``userPlaylist(id:instance:authToken:)`` — that one is a + /// thin wrapper over this. Piped has no per-entry token, so entries are + /// addressed by their position in this fetch and are only valid until the + /// playlist next changes. + /// - Parameters: + /// - id: The playlist ID (UUID) + /// - instance: The Piped instance + /// - authToken: The auth token from login + /// - Returns: The playlist plus its addressable entries + func userPlaylistSnapshot(id: String, instance: Instance, authToken: String) async throws -> RemotePlaylistSnapshot { let headers = ["Authorization": authToken] let firstEndpoint = GenericEndpoint( path: "/playlists/\(id)", @@ -397,17 +413,79 @@ actor PipedAPI: InstanceAPI { page += 1 } - return PipedPlaylistResponse( - name: firstResponse.name, - description: firstResponse.description, - uploader: firstResponse.uploader, - uploaderUrl: firstResponse.uploaderUrl, - uploaderAvatar: firstResponse.uploaderAvatar, - videos: firstResponse.videos, + return Self.buildUserPlaylistSnapshot( + firstPage: firstResponse, + allStreams: allStreams, + playlistID: id, + instanceURL: instance.url + ) + } + + /// Builds a snapshot from an already-fetched first page plus every stream + /// paged in after it. + /// + /// Pure: the network path and ``userPlaylistSnapshot(fromPageJSON:playlistID:instanceURL:)`` + /// both funnel through here, so positions are assigned in exactly one place. + private static func buildUserPlaylistSnapshot( + firstPage: PipedPlaylistResponse, + allStreams: [PipedVideoItem], + playlistID: String, + instanceURL: URL + ) -> RemotePlaylistSnapshot { + let playlist = PipedPlaylistResponse( + name: firstPage.name, + description: firstPage.description, + uploader: firstPage.uploader, + uploaderUrl: firstPage.uploaderUrl, + uploaderAvatar: firstPage.uploaderAvatar, + videos: firstPage.videos, relatedStreams: allStreams, - thumbnailUrl: firstResponse.thumbnailUrl, + thumbnailUrl: firstPage.thumbnailUrl, nextpage: nil - ).toPlaylist(instanceURL: instance.url, playlistID: id) + ).toPlaylist(instanceURL: instanceURL, playlistID: playlistID) + + // Positions run 0-based across every page in fetch order and are taken + // over the same list the playlist displays, so `entries[n]` and + // `playlist.videos[n]` are always the same row. Piped removes by + // position, so these ids die with this snapshot. + let entries = playlist.videos.enumerated().map { offset, video in + RemotePlaylistEntry( + id: .piped(playlistID: playlistID, position: offset), + video: video, + position: offset + ) + } + + return RemotePlaylistSnapshot(playlist: playlist, entries: entries) + } + + /// Runs the snapshot assembly over raw page payloads, decoding them the way + /// the network path does. Lets the paging and position rules be exercised + /// without a server: the first payload is a `/playlists/{id}` response, + /// every later one a `/nextpage/playlists/{id}` response. + static func userPlaylistSnapshot( + fromPageJSON pages: [Data], + playlistID: String, + instanceURL: URL + ) throws -> RemotePlaylistSnapshot { + let decoder = HTTPClient.makeAPIDecoder() + guard let firstData = pages.first else { + throw APIError.decodingError("No playlist pages to decode") + } + + let firstPage = try decoder.decode(PipedPlaylistResponse.self, from: firstData) + var allStreams = firstPage.relatedStreams ?? [] + for data in pages.dropFirst() { + let nextPage = try decoder.decode(PipedPlaylistNextPageResponse.self, from: data) + allStreams.append(contentsOf: nextPage.relatedStreams ?? []) + } + + return buildUserPlaylistSnapshot( + firstPage: firstPage, + allStreams: allStreams, + playlistID: playlistID, + instanceURL: instanceURL + ) } } diff --git a/Yattee/Services/Networking/HTTPClient.swift b/Yattee/Services/Networking/HTTPClient.swift index 61a200ea..dba3525e 100644 --- a/Yattee/Services/Networking/HTTPClient.swift +++ b/Yattee/Services/Networking/HTTPClient.swift @@ -28,9 +28,23 @@ actor HTTPClient { self.session = session self.decoder = decoder - // Configure decoder for common API patterns - self.decoder.keyDecodingStrategy = .convertFromSnakeCase - self.decoder.dateDecodingStrategy = .iso8601 + Self.configureForAPIs(self.decoder) + } + + /// Applies the decoding rules every instance API response is parsed with. + /// + /// Exposed so code that parses an already-fetched payload — a merged page + /// set, say — decodes it exactly the way the network path would. + nonisolated static func configureForAPIs(_ decoder: JSONDecoder) { + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .iso8601 + } + + /// A decoder configured the way this client decodes API responses. + nonisolated static func makeAPIDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + configureForAPIs(decoder) + return decoder } // MARK: - Configuration diff --git a/YatteeTests/RemotePlaylistEntryTests.swift b/YatteeTests/RemotePlaylistEntryTests.swift new file mode 100644 index 00000000..50f5ef0c --- /dev/null +++ b/YatteeTests/RemotePlaylistEntryTests.swift @@ -0,0 +1,470 @@ +// +// RemotePlaylistEntryTests.swift +// YatteeTests +// +// A remote playlist row has to survive being named: the token that identifies +// it must come back from parsing intact, and a row the server refuses to name +// must never look addressable. +// + +import Testing +import Foundation +@testable import Yattee + +// MARK: - Helpers + +private let testBaseURL = URL(string: "https://invidious.example.com")! +private let pipedBaseURL = URL(string: "https://piped.example.com")! + +/// Builds one video object of an Invidious authenticated playlist page. +private func invidiousVideoJSON( + videoId: String, + title: String, + index: Int?, + indexId: String? +) -> String { + var fields: [String] = [ + "\"type\": \"video\"", + "\"videoId\": \"\(videoId)\"", + "\"title\": \"\(title)\"", + "\"author\": \"Test Channel\"", + "\"authorId\": \"UCtest\"", + "\"authorUrl\": \"/channel/UCtest\"", + "\"lengthSeconds\": 120", + "\"videoThumbnails\": []" + ] + if let index { + fields.append("\"index\": \(index)") + } + if let indexId { + fields.append("\"indexId\": \"\(indexId)\"") + } + return "{\(fields.joined(separator: ", "))}" +} + +/// Builds one page of `/api/v1/auth/playlists/{id}`. +private func invidiousPageJSON( + playlistID: String = "IVPLtest", + videoCount: Int, + videos: [String] +) -> Data { + """ + { + "type": "invidiousPlaylist", + "title": "Watch Later", + "playlistId": "\(playlistID)", + "author": "arek", + "authorId": "UCowner", + "description": "A private playlist", + "videoCount": \(videoCount), + "videos": [\(videos.joined(separator: ", "))] + } + """.data(using: .utf8)! +} + +/// Builds one video object of a Piped playlist response. +private func pipedStreamJSON(videoId: String, title: String) -> String { + """ + { + "url": "/watch?v=\(videoId)", + "title": "\(title)", + "uploaderName": "Test Channel", + "uploaderUrl": "/channel/UCtest", + "duration": 120, + "thumbnail": "https://piped.example.com/thumb.jpg" + } + """ +} + +/// Builds the first page of `/playlists/{id}`. +private func pipedFirstPageJSON(streams: [String], nextpage: String? = nil) -> Data { + let nextpageValue = nextpage.map { "\"\($0)\"" } ?? "null" + return """ + { + "name": "My Piped Playlist", + "description": "Playlist description", + "uploader": "arek", + "uploaderUrl": "/channel/UCowner", + "videos": \(streams.count), + "relatedStreams": [\(streams.joined(separator: ", "))], + "nextpage": \(nextpageValue) + } + """.data(using: .utf8)! +} + +/// Builds a `/nextpage/playlists/{id}` page. +private func pipedNextPageJSON(streams: [String], nextpage: String? = nil) -> Data { + let nextpageValue = nextpage.map { "\"\($0)\"" } ?? "null" + return """ + { + "relatedStreams": [\(streams.joined(separator: ", "))], + "nextpage": \(nextpageValue) + } + """.data(using: .utf8)! +} + +// MARK: - Invidious + +@Suite("Invidious Remote Playlist Snapshot") +struct InvidiousRemotePlaylistSnapshotTests { + + @Test("Every indexed row keeps its indexId and its server position") + func snapshotRetainsIndexTokens() throws { + let page = invidiousPageJSON(videoCount: 3, videos: [ + invidiousVideoJSON(videoId: "aaa", title: "First", index: 0, indexId: "TOKEN0"), + invidiousVideoJSON(videoId: "bbb", title: "Second", index: 1, indexId: "TOKEN1"), + invidiousVideoJSON(videoId: "ccc", title: "Third", index: 2, indexId: "TOKEN2") + ]) + + let snapshot = try InvidiousAPI.authPlaylistSnapshot( + fromPageJSON: [page], + playlistID: "IVPLtest", + baseURL: testBaseURL + ) + + #expect(snapshot.playlist.videos.count == 3) + #expect(snapshot.entries.count == 3) + #expect(snapshot.entries.map(\.position) == [0, 1, 2]) + #expect(snapshot.entries.map(\.video.id.videoID) == ["aaa", "bbb", "ccc"]) + #expect(snapshot.entries.map(\.id) == [ + .invidious(playlistID: "IVPLtest", indexID: "TOKEN0"), + .invidious(playlistID: "IVPLtest", indexID: "TOKEN1"), + .invidious(playlistID: "IVPLtest", indexID: "TOKEN2") + ]) + } + + @Test("The same video twice is two entries, not one") + func duplicateVideoYieldsDistinctEntries() throws { + let page = invidiousPageJSON(videoCount: 3, videos: [ + invidiousVideoJSON(videoId: "dup", title: "Duplicated", index: 0, indexId: "TOKEN0"), + invidiousVideoJSON(videoId: "other", title: "Other", index: 1, indexId: "TOKEN1"), + invidiousVideoJSON(videoId: "dup", title: "Duplicated", index: 2, indexId: "TOKEN2") + ]) + + let snapshot = try InvidiousAPI.authPlaylistSnapshot( + fromPageJSON: [page], + playlistID: "IVPLtest", + baseURL: testBaseURL + ) + + #expect(snapshot.playlist.videos.count == 3) + #expect(snapshot.entries.count == 3) + + let duplicates = snapshot.entries.filter { $0.video.id.videoID == "dup" } + #expect(duplicates.count == 2) + #expect(duplicates[0].id != duplicates[1].id) + #expect(duplicates.map(\.position) == [0, 2]) + // Video hashes by id alone; the entry id is what keeps the two apart. + #expect(duplicates[0].video == duplicates[1].video) + #expect(Set(snapshot.entries).count == 3) + } + + @Test("A row without an indexId is displayed but never addressable") + func rowWithoutIndexIDIsDisplayOnly() throws { + let page = invidiousPageJSON(videoCount: 3, videos: [ + invidiousVideoJSON(videoId: "aaa", title: "First", index: 0, indexId: "TOKEN0"), + invidiousVideoJSON(videoId: "nameless", title: "Unnamed", index: 1, indexId: nil), + invidiousVideoJSON(videoId: "ccc", title: "Third", index: 2, indexId: "TOKEN2") + ]) + + let snapshot = try InvidiousAPI.authPlaylistSnapshot( + fromPageJSON: [page], + playlistID: "IVPLtest", + baseURL: testBaseURL + ) + + #expect(snapshot.playlist.videos.map(\.id.videoID) == ["aaa", "nameless", "ccc"]) + #expect(snapshot.entries.map(\.video.id.videoID) == ["aaa", "ccc"]) + #expect(snapshot.entries.map(\.position) == [0, 2]) + } + + @Test("A row without an index is display-only and sorts last") + func rowWithoutIndexSortsLast() throws { + let page = invidiousPageJSON(videoCount: 3, videos: [ + invidiousVideoJSON(videoId: "floating", title: "Unindexed", index: nil, indexId: "TOKENX"), + invidiousVideoJSON(videoId: "aaa", title: "First", index: 0, indexId: "TOKEN0"), + invidiousVideoJSON(videoId: "bbb", title: "Second", index: 1, indexId: "TOKEN1") + ]) + + let snapshot = try InvidiousAPI.authPlaylistSnapshot( + fromPageJSON: [page], + playlistID: "IVPLtest", + baseURL: testBaseURL + ) + + #expect(snapshot.playlist.videos.map(\.id.videoID) == ["aaa", "bbb", "floating"]) + #expect(snapshot.entries.map(\.video.id.videoID) == ["aaa", "bbb"]) + } + + @Test("Overlapping pages collapse by index, keeping the first copy") + func overlappingPagesDedupeByIndex() throws { + let firstPage = invidiousPageJSON(videoCount: 4, videos: [ + invidiousVideoJSON(videoId: "aaa", title: "First", index: 0, indexId: "TOKEN0"), + invidiousVideoJSON(videoId: "bbb", title: "Second", index: 1, indexId: "TOKEN1"), + invidiousVideoJSON(videoId: "ccc", title: "Third", index: 2, indexId: "TOKEN2") + ]) + let secondPage = invidiousPageJSON(videoCount: 4, videos: [ + invidiousVideoJSON(videoId: "ccc", title: "Third", index: 2, indexId: "TOKEN2"), + invidiousVideoJSON(videoId: "ddd", title: "Fourth", index: 3, indexId: "TOKEN3") + ]) + + let snapshot = try InvidiousAPI.authPlaylistSnapshot( + fromPageJSON: [firstPage, secondPage], + playlistID: "IVPLtest", + baseURL: testBaseURL + ) + + #expect(snapshot.playlist.videos.map(\.id.videoID) == ["aaa", "bbb", "ccc", "ddd"]) + #expect(snapshot.entries.map(\.position) == [0, 1, 2, 3]) + #expect(snapshot.entries.map(\.id) == [ + .invidious(playlistID: "IVPLtest", indexID: "TOKEN0"), + .invidious(playlistID: "IVPLtest", indexID: "TOKEN1"), + .invidious(playlistID: "IVPLtest", indexID: "TOKEN2"), + .invidious(playlistID: "IVPLtest", indexID: "TOKEN3") + ]) + } + + @Test("Dedupe never collapses a repeated video across pages") + func dedupeKeepsRepeatedVideoAcrossPages() throws { + let firstPage = invidiousPageJSON(videoCount: 3, videos: [ + invidiousVideoJSON(videoId: "dup", title: "Duplicated", index: 0, indexId: "TOKEN0"), + invidiousVideoJSON(videoId: "bbb", title: "Second", index: 1, indexId: "TOKEN1") + ]) + let secondPage = invidiousPageJSON(videoCount: 3, videos: [ + invidiousVideoJSON(videoId: "bbb", title: "Second", index: 1, indexId: "TOKEN1"), + invidiousVideoJSON(videoId: "dup", title: "Duplicated", index: 2, indexId: "TOKEN2") + ]) + + let snapshot = try InvidiousAPI.authPlaylistSnapshot( + fromPageJSON: [firstPage, secondPage], + playlistID: "IVPLtest", + baseURL: testBaseURL + ) + + #expect(snapshot.entries.count == 3) + #expect(snapshot.entries.map(\.video.id.videoID) == ["dup", "bbb", "dup"]) + #expect(snapshot.entries.map(\.position) == [0, 1, 2]) + } + + @Test("Out-of-order pages are sorted back into index order") + func pagesArrivingOutOfOrderAreSorted() throws { + let firstPage = invidiousPageJSON(videoCount: 3, videos: [ + invidiousVideoJSON(videoId: "ccc", title: "Third", index: 2, indexId: "TOKEN2"), + invidiousVideoJSON(videoId: "aaa", title: "First", index: 0, indexId: "TOKEN0") + ]) + let secondPage = invidiousPageJSON(videoCount: 3, videos: [ + invidiousVideoJSON(videoId: "bbb", title: "Second", index: 1, indexId: "TOKEN1") + ]) + + let snapshot = try InvidiousAPI.authPlaylistSnapshot( + fromPageJSON: [firstPage, secondPage], + playlistID: "IVPLtest", + baseURL: testBaseURL + ) + + #expect(snapshot.entries.map(\.position) == [0, 1, 2]) + #expect(snapshot.playlist.videos.map(\.id.videoID) == ["aaa", "bbb", "ccc"]) + } + + @Test("Playlist metadata comes from the first page") + func playlistMetadataSurvives() throws { + let page = invidiousPageJSON(videoCount: 1, videos: [ + invidiousVideoJSON(videoId: "aaa", title: "First", index: 0, indexId: "TOKEN0") + ]) + + let snapshot = try InvidiousAPI.authPlaylistSnapshot( + fromPageJSON: [page], + playlistID: "IVPLtest", + baseURL: testBaseURL + ) + + #expect(snapshot.playlist.title == "Watch Later") + #expect(snapshot.playlist.description == "A private playlist") + #expect(snapshot.playlist.videoCount == 1) + #expect(snapshot.playlist.id.playlistID == "IVPLtest") + } + + @Test("An empty page set is a decoding failure, not an empty playlist") + func emptyPageSetThrows() { + #expect(throws: APIError.self) { + try InvidiousAPI.authPlaylistSnapshot( + fromPageJSON: [], + playlistID: "IVPLtest", + baseURL: testBaseURL + ) + } + } +} + +// MARK: - Piped + +@Suite("Piped Remote Playlist Snapshot") +struct PipedRemotePlaylistSnapshotTests { + + @Test("Positions run sequentially across every page in fetch order") + func positionsAreSequentialAcrossPages() throws { + let firstPage = pipedFirstPageJSON(streams: [ + pipedStreamJSON(videoId: "aaa", title: "First"), + pipedStreamJSON(videoId: "bbb", title: "Second") + ], nextpage: "token") + let secondPage = pipedNextPageJSON(streams: [ + pipedStreamJSON(videoId: "ccc", title: "Third"), + pipedStreamJSON(videoId: "ddd", title: "Fourth") + ]) + + let snapshot = try PipedAPI.userPlaylistSnapshot( + fromPageJSON: [firstPage, secondPage], + playlistID: "playlist-uuid", + instanceURL: pipedBaseURL + ) + + #expect(snapshot.entries.count == 4) + #expect(snapshot.entries.map(\.position) == [0, 1, 2, 3]) + #expect(snapshot.entries.map(\.video.id.videoID) == ["aaa", "bbb", "ccc", "ddd"]) + } + + @Test("Entry ids embed the playlist ID") + func entryIDsEmbedPlaylistID() throws { + let page = pipedFirstPageJSON(streams: [ + pipedStreamJSON(videoId: "aaa", title: "First"), + pipedStreamJSON(videoId: "bbb", title: "Second") + ]) + + let snapshot = try PipedAPI.userPlaylistSnapshot( + fromPageJSON: [page], + playlistID: "playlist-uuid", + instanceURL: pipedBaseURL + ) + + #expect(snapshot.entries.map(\.id) == [ + .piped(playlistID: "playlist-uuid", position: 0), + .piped(playlistID: "playlist-uuid", position: 1) + ]) + #expect(snapshot.playlist.id.playlistID == "playlist-uuid") + } + + @Test("Entry n is the same row as playlist.videos[n]") + func entriesAlignWithDisplayedVideos() throws { + let page = pipedFirstPageJSON(streams: [ + pipedStreamJSON(videoId: "aaa", title: "First"), + pipedStreamJSON(videoId: "dup", title: "Duplicated"), + pipedStreamJSON(videoId: "dup", title: "Duplicated") + ]) + + let snapshot = try PipedAPI.userPlaylistSnapshot( + fromPageJSON: [page], + playlistID: "playlist-uuid", + instanceURL: pipedBaseURL + ) + + #expect(snapshot.entries.count == snapshot.playlist.videos.count) + for entry in snapshot.entries { + #expect(snapshot.playlist.videos[entry.position].id == entry.video.id) + } + // Two rows of the same video stay two distinct entries. + #expect(Set(snapshot.entries).count == 3) + } + + @Test("A playlist with no streams yields no entries") + func emptyPlaylistYieldsNoEntries() throws { + let page = pipedFirstPageJSON(streams: []) + + let snapshot = try PipedAPI.userPlaylistSnapshot( + fromPageJSON: [page], + playlistID: "playlist-uuid", + instanceURL: pipedBaseURL + ) + + #expect(snapshot.entries.isEmpty) + #expect(snapshot.playlist.videos.isEmpty) + #expect(snapshot.playlist.title == "My Piped Playlist") + } + + @Test("An empty page set is a decoding failure") + func emptyPageSetThrows() { + #expect(throws: APIError.self) { + try PipedAPI.userPlaylistSnapshot( + fromPageJSON: [], + playlistID: "playlist-uuid", + instanceURL: pipedBaseURL + ) + } + } +} + +// MARK: - Entry identity + +@Suite("Remote Playlist Entry ID") +struct RemotePlaylistEntryIDTests { + + @Test("The same position in two playlists is two different entries") + func positionsDoNotCollideAcrossPlaylists() { + let left = RemotePlaylistEntryID.piped(playlistID: "one", position: 3) + let right = RemotePlaylistEntryID.piped(playlistID: "two", position: 3) + + #expect(left != right) + #expect(Set([left, right]).count == 2) + } + + @Test("The same indexId in two playlists is two different entries") + func indexIDsDoNotCollideAcrossPlaylists() { + let left = RemotePlaylistEntryID.invidious(playlistID: "one", indexID: "TOKEN") + let right = RemotePlaylistEntryID.invidious(playlistID: "two", indexID: "TOKEN") + + #expect(left != right) + #expect(Set([left, right]).count == 2) + } + + @Test("Identical cases are equal and hash together") + func identicalCasesMatch() { + let invidious = RemotePlaylistEntryID.invidious(playlistID: "one", indexID: "TOKEN") + let piped = RemotePlaylistEntryID.piped(playlistID: "one", position: 3) + + #expect(invidious == .invidious(playlistID: "one", indexID: "TOKEN")) + #expect(piped == .piped(playlistID: "one", position: 3)) + #expect(Set([invidious, .invidious(playlistID: "one", indexID: "TOKEN")]).count == 1) + #expect(Set([piped, .piped(playlistID: "one", position: 3)]).count == 1) + } + + @Test("An Invidious id is never equal to a Piped id") + func backendsNeverCollide() { + let invidious = RemotePlaylistEntryID.invidious(playlistID: "one", indexID: "3") + let piped = RemotePlaylistEntryID.piped(playlistID: "one", position: 3) + + #expect(invidious != piped) + #expect(Set([invidious, piped]).count == 2) + } + + @Test("Entries differing only by identity stay distinct") + func entriesWithSameVideoStayDistinct() { + let video = Video( + id: .global("aaa"), + title: "Same video", + description: nil, + author: Author(id: "UCtest", name: "Test Channel"), + duration: 120, + publishedAt: nil, + publishedText: nil, + viewCount: nil, + likeCount: nil, + thumbnails: [], + isLive: false, + isUpcoming: false, + scheduledStartTime: nil + ) + + let first = RemotePlaylistEntry( + id: .invidious(playlistID: "one", indexID: "TOKEN0"), + video: video, + position: 0 + ) + let second = RemotePlaylistEntry( + id: .invidious(playlistID: "one", indexID: "TOKEN2"), + video: video, + position: 2 + ) + + #expect(first != second) + #expect(Set([first, second]).count == 2) + } +}