mirror of
https://github.com/yattee/yattee.git
synced 2026-11-03 10:27:19 +00:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user