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