mirror of
https://github.com/yattee/yattee.git
synced 2026-10-27 15:07:18 +00:00
Give remote playlist rows a name of their own
Playlist fetches threw away the only handle a row could be removed by: Invidious hands out an index/indexId pair per entry and both were dropped on conversion, so nothing downstream could say which row it meant. Both authenticated fetches now also return a snapshot — the playlist plus its addressable entries. Invidious entries carry the server's indexId and survive refetches; Piped has no such token, so its entries are addressed by position and are only valid inside the snapshot that minted them. The entry id carries the playlist id in both cases, so position 3 of one playlist can never be mistaken for position 3 of another. A row needs both an indexId and an index to be a target: rows missing either are still displayed but never appear in entries, so no removal can be aimed at a row the server refuses to name. Page-overlap dedupe stays keyed on index and never on videoId — the same video may sit in a playlist twice, and each copy is its own removable row.
This commit is contained in:
@@ -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<Int>()
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user