Read a playlist back before writing over it

Ten review findings in the remote-playlist work, all of them the same
shape: something acted on what it remembered instead of what the server
says now.

- A confirmed-gone playlist is forgotten whole — snapshot, list entry,
  and both ordering fences — so a refresh that started before the delete
  can no longer republish it, and a list refetch that fails offline no
  longer leaves it on screen.
- `.accountsDidChange` discards account-scoped content when the account
  it belonged to is no longer the effective one, as the status
  notification already did.
- A tab that stops being offered no longer stays selected: the browse
  view moves to the first available one, which reloads by itself.
- A Handoff account id is accepted only when that account is on the
  instance the playlist lives on.
- Composite writes moved up into `AccountService`, over a transport whose
  every method is exactly one request: create-with-privacy, rename, and
  set-description. The credential is rechecked between each pair, so a
  sign-out between the two halves of a create or an edit can no longer
  send the second half under the previous user's secret.
- Editing a playlist no longer forces it private. Invidious serialises
  visibility as `isListed` — `privacy.public?`, so unlisted and private
  are indistinguishable — which the snapshot now carries; the picker
  starts there, and sends nothing at all unless it was moved. Invidious
  keeps the stored visibility when the PATCH omits it (measured), so an
  untouched picker leaves it exactly as it was.
- Tapping Edit on a server-side playlist awaits an authenticated refetch
  first and populates the form from it; a failed refetch shows an error
  and no sheet.
- Playlist rows are keyed by row rather than by video id, which two rows
  of the same video share.
- The add-to-playlist sheet offers only accounts whose instance is
  enabled and that hold a credential, alongside the write capability.
This commit is contained in:
Arkadiusz Fal
2026-09-02 23:59:31 +02:00
parent 24b43ac866
commit b4855f5be9
12 changed files with 599 additions and 132 deletions

View File

@@ -307,77 +307,54 @@ actor ContentService: ContentServiceProtocol {
}
}
/// Creates a playlist for the credential's owner.
/// Creates a playlist for the credential's owner. **One request.**
///
/// Neither backend takes a description when creating, so this does not take
/// one either: setting it is a separate write, sequenced by the caller with
/// a credential recheck in between.
///
/// The returned playlist is the little the server says about what it just
/// made — an id and a title. Nothing else is invented here: callers refetch
/// the list, which is the only description that is actually the server's.
/// - Parameters:
/// - privacy: Ignored by backends without a visibility concept (Piped),
/// and defaulted to private on those that have one when not given.
/// and defaulted to private on those that have one when not given —
/// Invidious rejects a creation that carries no visibility at all.
func createPlaylist(
title: String,
description: String?,
privacy: PlaylistPrivacy?,
instance: Instance,
credential: String
) async throws -> Playlist {
switch instance.type {
case .invidious:
let api = await invidiousAPI(for: instance)
let playlist = try await api.createPlaylist(
return try await invidiousAPI(for: instance).createPlaylist(
title: title,
privacy: privacy ?? .private,
instance: instance,
sid: credential
)
// Creation takes no description, so a wanted one is a second call.
// Its failure is the caller's to see: a playlist that exists with
// the wrong description is not a silent success.
if let description, !description.isEmpty {
try await api.updatePlaylist(
id: playlist.id.playlistID,
title: title,
description: description,
privacy: privacy ?? .private,
instance: instance,
sid: credential
)
}
return playlist
case .piped:
let api = await pipedAPI(for: instance)
let playlistID = try await api.createPlaylist(
let playlistID = try await pipedAPI(for: instance).createPlaylist(
name: title,
instance: instance,
authToken: credential
)
if let description, !description.isEmpty {
try await api.updatePlaylistDescription(
id: playlistID,
description: description,
instance: instance,
authToken: credential
)
}
return Playlist(id: .global(playlistID), title: title, description: description)
return Playlist(id: .global(playlistID), title: title)
default:
throw APIError.notSupported
}
}
/// Edits one of the credential owner's playlists.
/// Renames one of the credential owner's playlists. **One request.**
///
/// - Parameters:
/// - description: `nil` leaves whatever the playlist has.
/// - privacy: `nil` keeps the private default on backends that have
/// visibility; ignored entirely on those that do not.
func updatePlaylist(
/// - Parameter privacy: the visibility to store, or `nil` to leave whatever
/// the playlist has. Ignored on backends without a visibility concept.
func renamePlaylist(
id: String,
title: String,
description: String?,
privacy: PlaylistPrivacy?,
instance: Instance,
credential: String
@@ -387,32 +364,55 @@ actor ContentService: ContentServiceProtocol {
try await invidiousAPI(for: instance).updatePlaylist(
id: id,
title: title,
description: description,
privacy: privacy ?? .private,
description: nil,
privacy: privacy,
instance: instance,
sid: credential
)
case .piped:
// Two calls, no transaction: Piped edits the name and the
// description through separate endpoints. The rename goes first so
// a failure of the second leaves the visible half applied, and the
// caller's refetch shows exactly what landed.
let api = await pipedAPI(for: instance)
try await api.renamePlaylist(
try await pipedAPI(for: instance).renamePlaylist(
id: id,
newName: title,
instance: instance,
authToken: credential
)
if let description {
try await api.updatePlaylistDescription(
id: id,
description: description,
instance: instance,
authToken: credential
)
}
default:
throw APIError.notSupported
}
}
/// Stores a playlist's description. **One request.**
///
/// - Parameter title: the playlist's title, resent unchanged. Invidious
/// edits every attribute through one endpoint and refuses a body without
/// a title; Piped has a description endpoint of its own and ignores this.
func setPlaylistDescription(
id: String,
description: String,
title: String,
instance: Instance,
credential: String
) async throws {
switch instance.type {
case .invidious:
try await invidiousAPI(for: instance).updatePlaylist(
id: id,
title: title,
description: description,
privacy: nil,
instance: instance,
sid: credential
)
case .piped:
try await pipedAPI(for: instance).updatePlaylistDescription(
id: id,
description: description,
instance: instance,
authToken: credential
)
default:
throw APIError.notSupported

View File

@@ -208,6 +208,7 @@ actor InvidiousAPI: InstanceAPI {
author: firstResponse.author,
authorId: firstResponse.authorId,
videoCount: firstResponse.videoCount,
isListed: firstResponse.isListed,
videos: allVideos
).toPlaylist(baseURL: instance.url)
}
@@ -320,6 +321,7 @@ actor InvidiousAPI: InstanceAPI {
author: firstPage.author,
authorId: firstPage.authorId,
videoCount: firstPage.videoCount,
isListed: firstPage.isListed,
videos: allVideos
).toPlaylist(baseURL: baseURL)
@@ -351,7 +353,7 @@ actor InvidiousAPI: InstanceAPI {
)
}
return RemotePlaylistSnapshot(playlist: playlist, entries: entries)
return RemotePlaylistSnapshot(playlist: playlist, entries: entries, isListed: firstPage.isListed)
}
/// Runs the snapshot assembly over raw page payloads, decoding them the way
@@ -704,35 +706,38 @@ actor InvidiousAPI: InstanceAPI {
/// Edits a playlist's title, description or privacy.
///
/// Title and privacy are sent even when unchanged: Invidious reads a missing
/// field as "leave it alone" for the description but validates the other two
/// on every request, so a partial body is rejected rather than merged.
/// Answers 204 with no body.
/// The title is sent even when unchanged: Invidious reads it out of the body
/// without a default and answers 403 `Missing hash key: "title"` when it is
/// absent (measured). The other two fields fall back to what is stored, so
/// omitting them is how the server is told to keep them — which is the only
/// way to leave a playlist's visibility alone, since the API never says
/// which of unlisted and private it currently is. Answers 204 with no body.
/// - Parameters:
/// - id: The playlist ID
/// - title: The title to store
/// - description: The description to store, or `nil` to leave it alone
/// - privacy: The visibility to store
/// - privacy: The visibility to store, or `nil` to leave it alone
/// - instance: The Invidious instance
/// - sid: The session ID from login
func updatePlaylist(
id: String,
title: String,
description: String?,
privacy: PlaylistPrivacy,
privacy: PlaylistPrivacy?,
instance: Instance,
sid: String
) async throws {
struct UpdateRequest: Encodable, Sendable {
let title: String
let privacy: String
/// Omitted from the JSON when nil, which keeps the stored visibility.
let privacy: String?
/// Omitted from the JSON when nil, which is how the server is told
/// to keep the description it already has.
let description: String?
}
let body = try JSONEncoder().encode(
UpdateRequest(title: title, privacy: privacy.rawValue, description: description)
UpdateRequest(title: title, privacy: privacy?.rawValue, description: description)
)
let endpoint = GenericEndpoint(
path: "/api/v1/auth/playlists/\(id)",
@@ -1818,6 +1823,9 @@ private struct InvidiousPlaylist: Decodable, Sendable {
let author: String?
let authorId: String?
let videoCount: Int
/// The whole of what Invidious says about visibility: `privacy.public?`,
/// serialised. Unlisted and private are both `false` and indistinguishable.
let isListed: Bool?
let videos: [InvidiousPlaylistItem]?
nonisolated func toPlaylist(baseURL: URL) -> Playlist {

View File

@@ -15,6 +15,13 @@ import Foundation
/// conforms to it, and tests script it. Deliberately stateless and
/// credential-carrying — resolving *which* credential is this service's job,
/// and the transport must never guess.
///
/// **Every method here is exactly one request to the server.** Nothing composes
/// two of them, because the credential can be replaced between any two requests
/// and only this service can tell: a composite that awaited its own first call
/// would send the second with a secret that has since changed hands. Backends
/// that need several writes to change one playlist are sequenced above, with a
/// recheck between each pair.
protocol RemotePlaylistTransport: Sendable {
func userPlaylists(instance: Instance, credential: String) async throws -> [Playlist]
@@ -26,21 +33,27 @@ protocol RemotePlaylistTransport: Sendable {
func createPlaylist(
title: String,
description: String?,
privacy: PlaylistPrivacy?,
instance: Instance,
credential: String
) async throws -> Playlist
func updatePlaylist(
func renamePlaylist(
id: String,
title: String,
description: String?,
privacy: PlaylistPrivacy?,
instance: Instance,
credential: String
) async throws
func setPlaylistDescription(
id: String,
description: String,
title: String,
instance: Instance,
credential: String
) async throws
func deletePlaylist(id: String, instance: Instance, credential: String) async throws
func addVideoToPlaylist(
@@ -133,6 +146,12 @@ final class AccountService {
/// Creates a playlist on the account's instance.
///
/// Two requests when a description was asked for, because no backend takes
/// one at creation — and the credential is checked again in between, since
/// it can be replaced while the first one is out. The second request's
/// failure is the caller's to see: a playlist that exists with the wrong
/// description is not a silent success.
///
/// - Parameter privacy: dropped on backends without a visibility concept,
/// rather than refused: the playlist is still perfectly creatable there,
/// and failing the whole call over a setting the server does not have
@@ -147,18 +166,44 @@ final class AccountService {
let privacy = supportedPrivacy(privacy, for: account)
try requireStillCurrent(credential, for: account)
return try await tracking(account, credential: credential) {
let playlist = try await tracking(account, credential: credential) {
try await transport.createPlaylist(
title: title,
description: description,
privacy: privacy,
instance: instance,
credential: credential
)
}
if let description, !description.isEmpty {
try requireStillCurrent(credential, for: account)
try await tracking(account, credential: credential) {
try await transport.setPlaylistDescription(
id: playlist.id.playlistID,
description: description,
title: title,
instance: instance,
credential: credential
)
}
}
return playlist
}
/// Edits a playlist on the account's instance.
///
/// Two requests, no transaction, when the description is part of the edit:
/// Piped stores the name and the description behind separate endpoints, and
/// Invidious refuses to take a description without a title. The rename goes
/// first so a failure of the second leaves the visible half applied, and the
/// caller's refetch shows exactly what landed — with the credential checked
/// again in between, because it can be replaced while the rename is out.
///
/// - Parameters:
/// - description: `nil` leaves whatever the playlist has.
/// - privacy: `nil` leaves whatever visibility the playlist has; ignored
/// entirely on backends without one.
func updatePlaylist(
id: String,
title: String,
@@ -171,15 +216,27 @@ final class AccountService {
try requireStillCurrent(credential, for: account)
try await tracking(account, credential: credential) {
try await transport.updatePlaylist(
try await transport.renamePlaylist(
id: id,
title: title,
description: description,
privacy: privacy,
instance: instance,
credential: credential
)
}
if let description {
try requireStillCurrent(credential, for: account)
try await tracking(account, credential: credential) {
try await transport.setPlaylistDescription(
id: id,
description: description,
title: title,
instance: instance,
credential: credential
)
}
}
}
/// Deletes a playlist from the account's instance.

View File

@@ -158,7 +158,7 @@ final class RemotePlaylistsCache {
func deletePlaylist(id: String, for account: Account) async throws {
do {
try await accountService.deletePlaylist(id: id, for: account)
forgetSnapshot(id: id, for: account.id)
forgetPlaylist(id: id, for: account.id)
await reconcileList(for: account)
} catch {
// The delete may have landed even though the answer did not, so the
@@ -347,8 +347,7 @@ final class RemotePlaylistsCache {
do {
try await fetchSnapshot(id: id, for: account)
} catch let error as APIError where Self.saysPlaylistIsGone(error) {
forgetSnapshot(id: id, for: account.id)
forgetListEntry(id: id, for: account.id)
forgetPlaylist(id: id, for: account.id)
} catch {
// Swallowed: see the doc comment on `reconcileList(for:)`.
}
@@ -365,17 +364,22 @@ final class RemotePlaylistsCache {
}
}
private func forgetSnapshot(id: String, for accountID: UUID) {
/// Drops a playlist the server is known no longer to have.
///
/// Three things, and each one is load-bearing. The snapshot goes, so a
/// detail view still open on it has nothing left to show. The list entry
/// goes, because the list is refetched right after but that refetch is
/// allowed to fail — offline, a playlist known to be gone would otherwise
/// sit in the list until the next successful fetch. And both fences move
/// past every request already in flight: a refresh that started before the
/// delete describes a playlist that still existed, and arriving late does
/// not make it exist again.
private func forgetPlaylist(id: String, for accountID: UUID) {
appliedSnapshotSequence[SnapshotKey(accountID: accountID, playlistID: id)] = nextSequence()
appliedListSequence[accountID] = nextSequence()
snapshots[accountID]?[id] = nil
snapshotFetchDates[accountID]?[id] = nil
}
/// Takes a playlist out of the account's last-fetched list.
///
/// The list is refetched right after, which normally says the same thing —
/// but a refetch that cannot reach the server would otherwise leave a
/// playlist that is known to be gone sitting in it.
private func forgetListEntry(id: String, for accountID: UUID) {
lists[accountID]?.removeAll { $0.id.playlistID == id }
}