diff --git a/Yattee/Services/API/InvidiousAPI.swift b/Yattee/Services/API/InvidiousAPI.swift index 8043fa40..18e0b003 100644 --- a/Yattee/Services/API/InvidiousAPI.swift +++ b/Yattee/Services/API/InvidiousAPI.swift @@ -175,9 +175,41 @@ actor InvidiousAPI: InstanceAPI { } func playlist(id: String, instance: Instance) async throws -> Playlist { - let endpoint = GenericEndpoint.get("/api/v1/playlists/\(id)") - let response: InvidiousPlaylist = try await httpClient.fetch(endpoint, baseURL: instance.url) - return response.toPlaylist(baseURL: instance.url) + let firstEndpoint = GenericEndpoint.get("/api/v1/playlists/\(id)") + let firstResponse: InvidiousPlaylist = try await httpClient.fetch(firstEndpoint, baseURL: instance.url) + var allVideos = firstResponse.videos ?? [] + let maxPages = 50 + + if firstResponse.videoCount > 0 { + var page = 2 + while page <= maxPages { + let endpoint = GenericEndpoint.get("/api/v1/playlists/\(id)", query: ["page": String(page)]) + let response: InvidiousPlaylist = try await httpClient.fetch(endpoint, baseURL: instance.url) + let pageVideos = response.videos ?? [] + if pageVideos.isEmpty { break } + allVideos.append(contentsOf: pageVideos) + page += 1 + } + } + + // Invidious pagination uses overlapping pages — deduplicate by playlist index + var seenIndices = Set() + allVideos = allVideos.filter { item in + if case .video(let video) = item, let index = video.index { + return seenIndices.insert(index).inserted + } + return true + } + + return InvidiousPlaylist( + playlistId: firstResponse.playlistId, + title: firstResponse.title, + description: firstResponse.description, + author: firstResponse.author, + authorId: firstResponse.authorId, + videoCount: firstResponse.videoCount, + videos: allVideos + ).toPlaylist(baseURL: instance.url) } /// Fetches a user's playlist using authenticated endpoint. @@ -188,13 +220,50 @@ 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 { - let endpoint = GenericEndpoint.get("/api/v1/auth/playlists/\(id)") - let response: InvidiousPlaylist = try await httpClient.fetch( - endpoint, - baseURL: instance.url, - customHeaders: ["Cookie": "SID=\(sid)"] + let headers = ["Cookie": "SID=\(sid)"] + let firstEndpoint = GenericEndpoint( + path: "/api/v1/auth/playlists/\(id)", + queryItems: nil, + headers: headers ) - return response.toPlaylist(baseURL: instance.url) + let firstResponse: InvidiousPlaylist = try await httpClient.fetch(firstEndpoint, baseURL: instance.url) + var allVideos = firstResponse.videos ?? [] + let maxPages = 50 + + if firstResponse.videoCount > 0 { + var page = 2 + while page <= maxPages { + let endpoint = GenericEndpoint( + path: "/api/v1/auth/playlists/\(id)", + queryItems: [URLQueryItem(name: "page", value: String(page))], + 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) + page += 1 + } + } + + // Invidious pagination uses overlapping pages — deduplicate by playlist index + var seenIndices = Set() + allVideos = allVideos.filter { item in + if case .video(let video) = item, let index = video.index { + return seenIndices.insert(index).inserted + } + return true + } + + return InvidiousPlaylist( + playlistId: firstResponse.playlistId, + title: firstResponse.title, + description: firstResponse.description, + author: firstResponse.author, + authorId: firstResponse.authorId, + videoCount: firstResponse.videoCount, + videos: allVideos + ).toPlaylist(baseURL: instance.url) } func comments(videoID: String, instance: Instance, continuation: String?) async throws -> CommentsPage { @@ -286,8 +355,9 @@ actor InvidiousAPI: InstanceAPI { "password": password, "action": "signin" ] + let formAllowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~")) let bodyString = bodyComponents - .map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" } + .map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: formAllowed) ?? $0.value)" } .joined(separator: "&") guard let bodyData = bodyString.data(using: .utf8) else { @@ -627,6 +697,7 @@ private struct InvidiousAuthFeedResponse: Decodable, Sendable { private struct InvidiousVideo: Decodable, Sendable { let videoId: String + let index: Int? let title: String let description: String? let author: String diff --git a/Yattee/Services/API/PipedAPI.swift b/Yattee/Services/API/PipedAPI.swift index df94c557..723a50dd 100644 --- a/Yattee/Services/API/PipedAPI.swift +++ b/Yattee/Services/API/PipedAPI.swift @@ -121,8 +121,33 @@ actor PipedAPI: InstanceAPI { func playlist(id: String, instance: Instance) async throws -> Playlist { let endpoint = GenericEndpoint.get("/playlists/\(id)") - let response: PipedPlaylistResponse = try await httpClient.fetch(endpoint, baseURL: instance.url) - return response.toPlaylist(instanceURL: instance.url) + let firstResponse: PipedPlaylistResponse = try await httpClient.fetch(endpoint, baseURL: instance.url) + var allStreams = firstResponse.relatedStreams ?? [] + let maxPages = 50 + + var nextpage = firstResponse.nextpage + var page = 1 + while let token = nextpage, page < maxPages { + let nextEndpoint = GenericEndpoint.get("/nextpage/playlists/\(id)", query: ["nextpage": token]) + let nextResponse: PipedPlaylistNextPageResponse = try await httpClient.fetch(nextEndpoint, baseURL: instance.url) + let pageStreams = nextResponse.relatedStreams ?? [] + if pageStreams.isEmpty { break } + allStreams.append(contentsOf: pageStreams) + nextpage = nextResponse.nextpage + page += 1 + } + + return PipedPlaylistResponse( + name: firstResponse.name, + description: firstResponse.description, + uploader: firstResponse.uploader, + uploaderUrl: firstResponse.uploaderUrl, + uploaderAvatar: firstResponse.uploaderAvatar, + videos: firstResponse.videos, + relatedStreams: allStreams, + thumbnailUrl: firstResponse.thumbnailUrl, + nextpage: nil + ).toPlaylist(instanceURL: instance.url) } func comments(videoID: String, instance: Instance, continuation: String?) async throws -> CommentsPage { @@ -341,13 +366,43 @@ actor PipedAPI: InstanceAPI { /// - authToken: The auth token from login /// - Returns: Playlist with videos func userPlaylist(id: String, instance: Instance, authToken: String) async throws -> Playlist { - let endpoint = GenericEndpoint( + let headers = ["Authorization": authToken] + let firstEndpoint = GenericEndpoint( path: "/playlists/\(id)", method: .get, - headers: ["Authorization": authToken] + headers: headers ) - let response: PipedPlaylistResponse = try await httpClient.fetch(endpoint, baseURL: instance.url) - return response.toPlaylist(instanceURL: instance.url, playlistID: id) + let firstResponse: PipedPlaylistResponse = try await httpClient.fetch(firstEndpoint, baseURL: instance.url) + var allStreams = firstResponse.relatedStreams ?? [] + let maxPages = 50 + + var nextpage = firstResponse.nextpage + var page = 1 + while let token = nextpage, page < maxPages { + let nextEndpoint = GenericEndpoint( + path: "/nextpage/playlists/\(id)", + queryItems: [URLQueryItem(name: "nextpage", value: token)], + headers: headers + ) + let nextResponse: PipedPlaylistNextPageResponse = try await httpClient.fetch(nextEndpoint, baseURL: instance.url) + let pageStreams = nextResponse.relatedStreams ?? [] + if pageStreams.isEmpty { break } + allStreams.append(contentsOf: pageStreams) + nextpage = nextResponse.nextpage + page += 1 + } + + return PipedPlaylistResponse( + name: firstResponse.name, + description: firstResponse.description, + uploader: firstResponse.uploader, + uploaderUrl: firstResponse.uploaderUrl, + uploaderAvatar: firstResponse.uploaderAvatar, + videos: firstResponse.videos, + relatedStreams: allStreams, + thumbnailUrl: firstResponse.thumbnailUrl, + nextpage: nil + ).toPlaylist(instanceURL: instance.url, playlistID: id) } } @@ -885,6 +940,7 @@ private struct PipedPlaylistResponse: Decodable, Sendable { let videos: Int? let relatedStreams: [PipedPlaylistItem]? let thumbnailUrl: String? + let nextpage: String? nonisolated func toPlaylist(instanceURL: URL, playlistID: String? = nil) -> Playlist { // Extract only valid videos, skipping malformed items @@ -913,6 +969,12 @@ private struct PipedPlaylistResponse: Decodable, Sendable { } } +/// Response from `/nextpage/playlists/{id}?nextpage=...` for paginated playlist videos. +private struct PipedPlaylistNextPageResponse: Decodable, Sendable { + let relatedStreams: [PipedPlaylistItem]? + let nextpage: String? +} + /// User playlist from Piped `/user/playlists` endpoint. private struct PipedUserPlaylist: Decodable, Sendable { let id: String