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:
Arkadiusz Fal
2026-09-02 19:44:12 +02:00
parent 9b5d2b001f
commit de4ee9a728
5 changed files with 773 additions and 25 deletions

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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
)
}
}

View File

@@ -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

View File

@@ -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)
}
}