From 95d3893f4633982ad09c40200a1582bcbd2702ca Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 7 Oct 2026 23:59:07 +0200 Subject: [PATCH] Resolve @handle channel links on Piped Piped's /channel/{id} rejects handles, so channel pages, tabs and subscribe links for youtube.com/@name failed. Resolve the handle through /c/@handle once per instance and use the channel ID. --- Yattee/Services/API/PipedAPI.swift | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/Yattee/Services/API/PipedAPI.swift b/Yattee/Services/API/PipedAPI.swift index f97d4843..51a1aefa 100644 --- a/Yattee/Services/API/PipedAPI.swift +++ b/Yattee/Services/API/PipedAPI.swift @@ -16,6 +16,9 @@ actor PipedAPI: InstanceAPI { /// Populated when `channel()` or `channelVideos()` fetches `/channel/{id}`. private var channelTabsCache: [String: [PipedChannelTab]] = [:] + /// `@handle` channel IDs (youtube.com/@name links) resolved to UC… IDs, per instance. + private var resolvedChannelHandles: [String: String] = [:] + init(httpClient: HTTPClient) { self.httpClient = httpClient } @@ -87,7 +90,24 @@ actor PipedAPI: InstanceAPI { return response.toVideo(instanceURL: instance.url, videoId: id) } + /// Piped's `/channel/{id}` only takes UC… IDs; `/c/@handle` resolves a handle. + private func channelID(for id: String, instance: Instance) async throws -> String { + guard id.hasPrefix("@") else { return id } + let key = "\(instance.url.absoluteString)|\(id.lowercased())" + if let cached = resolvedChannelHandles[key] { return cached } + + let endpoint = GenericEndpoint.get("/c/\(id)") + let response: PipedChannelResponse = try await httpClient.fetch(endpoint, baseURL: instance.url) + guard !response.id.isEmpty else { return id } + resolvedChannelHandles[key] = response.id + if let tabs = response.tabs { + channelTabsCache[response.id] = tabs + } + return response.id + } + func channel(id: String, instance: Instance) async throws -> Channel { + let id = try await channelID(for: id, instance: instance) let endpoint = GenericEndpoint.get("/channel/\(id)") let response: PipedChannelResponse = try await httpClient.fetch(endpoint, baseURL: instance.url) if let tabs = response.tabs { @@ -97,6 +117,7 @@ actor PipedAPI: InstanceAPI { } func channelVideos(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage { + let id = try await channelID(for: id, instance: instance) if let continuation { // Fetch next page of channel videos let endpoint = GenericEndpoint.get("/nextpage/channel/\(id)", query: ["nextpage": continuation]) @@ -184,7 +205,7 @@ actor PipedAPI: InstanceAPI { // MARK: - Channel Tabs func channelShorts(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage { - let page = try await fetchTab(name: "shorts", channelID: id, instance: instance, continuation: continuation) + let page = try await fetchTab(name: "shorts", channelID: channelID(for: id, instance: instance), instance: instance, continuation: continuation) let videos: [Video] = page.items.compactMap { if case .stream(let video) = $0 { return video.toVideo(instanceURL: instance.url) } return nil @@ -193,7 +214,7 @@ actor PipedAPI: InstanceAPI { } func channelStreams(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage { - let page = try await fetchTab(name: "livestreams", channelID: id, instance: instance, continuation: continuation) + let page = try await fetchTab(name: "livestreams", channelID: channelID(for: id, instance: instance), instance: instance, continuation: continuation) let videos: [Video] = page.items.compactMap { if case .stream(let video) = $0 { return video.toVideo(instanceURL: instance.url) } return nil @@ -202,7 +223,7 @@ actor PipedAPI: InstanceAPI { } func channelPlaylists(id: String, instance: Instance, continuation: String?) async throws -> ChannelPlaylistsPage { - let page = try await fetchTab(name: "playlists", channelID: id, instance: instance, continuation: continuation) + let page = try await fetchTab(name: "playlists", channelID: channelID(for: id, instance: instance), instance: instance, continuation: continuation) let playlists: [Playlist] = page.items.compactMap { if case .playlist(let p) = $0 { return p.toPlaylist() } return nil