Files
yattee/Yattee/Services/RemotePlaylistsCache.swift
Arkadiusz Fal 4770bbe3b9 Remove dead code found by Periphery on all platforms
Delete declarations that Periphery reports unused on every platform that
compiles them (iOS, macOS, tvOS), including nine views used only by their
own previews. Platform-gated code still used on another platform, device-
only MPV render paths, migration models, CloudKit record names, App
Intents metadata and unused parameters are kept.
2026-10-08 23:57:56 +02:00

492 lines
19 KiB
Swift

//
// RemotePlaylistsCache.swift
// Yattee
//
// What the server said about an account's playlists, the last time it was
// asked. In memory, for this run of the app, and nowhere else.
//
import Foundation
import Observation
/// Holds server-side playlists for display, and routes every change to them.
///
/// **Never persisted, and never synced.** Two reasons, both fatal to the idea
/// of writing this to disk or into CloudKit: the data belongs to a login rather
/// than to this device, and Piped's rows are addressed by *position*, so a
/// stored entry id would be aimed at whatever occupies that slot the next time
/// the app opens. Offline the last fetch is shown and writes fail fast.
///
/// Mutations go through here rather than around it, so the refetch that
/// reconciles them cannot be forgotten: every change refetches what it touched
/// — on failure as much as on success, because a failed write may still have
/// landed — and the UI only ever shows what came back from the server. Nothing
/// is edited optimistically.
@MainActor
@Observable
final class RemotePlaylistsCache {
private let accountService: AccountService
init(accountService: AccountService) {
self.accountService = accountService
}
// MARK: - State
/// Last-fetched playlist lists, by account.
private var lists: [UUID: [Playlist]] = [:]
/// When each account's list was last fetched.
private var listFetchDates: [UUID: Date] = [:]
/// Last-fetched playlist snapshots, by account and playlist id.
private var snapshots: [UUID: [String: RemotePlaylistSnapshot]] = [:]
/// When each snapshot was last fetched.
private var snapshotFetchDates: [UUID: [String: Date]] = [:]
/// Which generation of an account's data the state belongs to.
///
/// Clearing a cache never cancels the request that was filling it: a fetch
/// started before a sign-out returns afterwards, and would repopulate the
/// account it was cleared for. Every fetch captures this at the start and
/// drops its result if it has moved.
private var generations: [UUID: Int] = [:]
/// In-flight list fetches, so concurrent refreshes of the same account cost
/// one round trip and all see the same answer.
private var listFetches: [UUID: Task<Void, Error>] = [:]
/// In-flight snapshot fetches, keyed by account and playlist.
private var snapshotFetches: [SnapshotKey: Task<Void, Error>] = [:]
/// Ordering for results, so an older fetch can never overwrite a newer one.
///
/// A refresh already in flight when a mutation happens describes the
/// playlist *before* the change; the reconciling refetch starts later and
/// must win however the two happen to return.
private var sequence = 0
private var appliedListSequence: [UUID: Int] = [:]
private var appliedSnapshotSequence: [SnapshotKey: Int] = [:]
private struct SnapshotKey: Hashable {
let accountID: UUID
let playlistID: String
}
/// What a mutation holds while it runs, so two of them cannot overlap.
///
/// A change and the refetch that reconciles it are one operation, and the
/// next change to the same playlist may not start until it is over: it
/// plans its write against the last snapshot, and a snapshot taken before
/// the previous change landed describes a playlist that no longer exists.
/// Piped is where this bites — its rows are addressed by *position*, so a
/// second removal that preflights the pre-removal snapshot passes its check
/// and then deletes whatever shifted into the slot.
///
/// Creation queues on the account's *list* instead, and deliberately not
/// with the playlists: nothing it does depends on any playlist's contents,
/// and the list refetches that two mutations race over are already ordered
/// by `sequence`. One queue for the account would only make changes to
/// unrelated playlists wait for each other.
private enum MutationKey: Hashable {
case list(accountID: UUID)
case playlist(accountID: UUID, playlistID: String)
}
/// Who is queued behind each held key, in the order they arrived. A key
/// present is held; absent is free.
private var mutationWaiters: [MutationKey: [CheckedContinuation<Void, Never>]] = [:]
// MARK: - Reads
/// The account's playlists as last fetched, or `nil` if they never were.
func playlists(for accountID: UUID) -> [Playlist]? {
lists[accountID]
}
/// One playlist as last fetched, or `nil` if it never was.
func snapshot(id: String, for accountID: UUID) -> RemotePlaylistSnapshot? {
snapshots[accountID]?[id]
}
/// When that snapshot was last fetched.
func snapshotFetchDate(id: String, for accountID: UUID) -> Date? {
snapshotFetchDates[accountID]?[id]
}
// MARK: - Refreshing
/// Refetches the account's playlist list, joining a refresh already running.
func refreshList(for account: Account) async throws {
if let inFlight = listFetches[account.id] {
return try await inFlight.value
}
try await fetchList(for: account)
}
/// Refetches one playlist, joining a refresh already running.
func refreshSnapshot(id: String, for account: Account) async throws {
let key = SnapshotKey(accountID: account.id, playlistID: id)
if let inFlight = snapshotFetches[key] {
return try await inFlight.value
}
try await fetchSnapshot(id: id, for: account)
}
// MARK: - Mutations
/// Creates a playlist and republishes the account's list from the server.
@discardableResult
func createPlaylist(
title: String,
description: String?,
privacy: PlaylistPrivacy?,
for account: Account
) async throws -> Playlist {
try await serializing(.list(accountID: account.id)) {
try await reconcilingList(for: account) {
try await accountService.createPlaylist(
title: title,
description: description,
privacy: privacy,
for: account
)
}
}
}
/// Edits a playlist and republishes both the list and the playlist itself.
func updatePlaylist(
id: String,
title: String,
description: String?,
privacy: PlaylistPrivacy?,
for account: Account
) async throws {
try await serializing(.playlist(accountID: account.id, playlistID: id)) {
try await reconciling(playlist: id, andList: true, for: account) {
try await accountService.updatePlaylist(
id: id,
title: title,
description: description,
privacy: privacy,
for: account
)
}
}
}
/// Deletes a playlist and republishes the account's list.
func deletePlaylist(id: String, for account: Account) async throws {
try await serializing(.playlist(accountID: account.id, playlistID: id)) {
do {
try await accountService.deletePlaylist(id: id, for: account)
forgetPlaylist(id: id, for: account.id)
await reconcileList(for: account)
} catch {
// The delete may have landed even though the answer did not, so
// the snapshot is refetched rather than assumed: whichever way
// it went, the server's answer replaces it — and a server that
// no longer has the playlist is answering that the delete did
// land.
await reconcileSnapshotAfterDelete(id: id, for: account)
await reconcileList(for: account)
throw error
}
}
}
/// Adds a video and republishes the playlist it was added to.
func addVideo(videoID: String, toPlaylist id: String, for account: Account) async throws {
try await serializing(.playlist(accountID: account.id, playlistID: id)) {
try await reconciling(playlist: id, andList: false, for: account) {
try await accountService.addVideo(videoID: videoID, toPlaylist: id, for: account)
}
}
}
/// Removes a row and republishes the playlist it was removed from.
func removeEntry(
_ entry: RemotePlaylistEntry,
fromPlaylist id: String,
for account: Account
) async throws {
try await serializing(.playlist(accountID: account.id, playlistID: id)) {
try await reconciling(playlist: id, andList: false, for: account) {
try await accountService.removeEntry(entry, fromPlaylist: id, for: account)
}
}
}
// MARK: - Clearing
/// Drops everything known about one account, and disowns its in-flight
/// fetches so none of them can put it back.
func clear(accountID: UUID) {
generations[accountID, default: 0] += 1
lists[accountID] = nil
listFetchDates[accountID] = nil
snapshots[accountID] = nil
snapshotFetchDates[accountID] = nil
listFetches.removeValue(forKey: accountID)?.cancel()
for key in snapshotFetches.keys where key.accountID == accountID {
snapshotFetches.removeValue(forKey: key)?.cancel()
}
}
/// Drops everything, for a change of Apple account or of identity scope.
func clearAll() {
for accountID in Set(lists.keys)
.union(snapshots.keys)
.union(listFetches.keys)
.union(snapshotFetches.keys.map(\.accountID))
.union(generations.keys) {
clear(accountID: accountID)
}
}
// MARK: - Fetching
private func fetchList(for account: Account) async throws {
let accountID = account.id
let generation = generations[accountID, default: 0]
let sequence = nextSequence()
let task = Task<Void, Error> { [weak self] in
guard let self else { return }
let playlists = try await self.accountService.userPlaylists(for: account)
guard self.isCurrent(generation, for: accountID),
sequence > self.appliedListSequence[accountID, default: 0]
else { return }
self.appliedListSequence[accountID] = sequence
self.lists[accountID] = playlists
self.listFetchDates[accountID] = Date()
}
listFetches[accountID] = task
defer {
if listFetches[accountID] == task {
listFetches[accountID] = nil
}
}
try await task.value
}
private func fetchSnapshot(id: String, for account: Account) async throws {
let accountID = account.id
let key = SnapshotKey(accountID: accountID, playlistID: id)
let generation = generations[accountID, default: 0]
let sequence = nextSequence()
let task = Task<Void, Error> { [weak self] in
guard let self else { return }
let snapshot = try await self.accountService.playlistSnapshot(id: id, for: account)
guard self.isCurrent(generation, for: accountID),
sequence > self.appliedSnapshotSequence[key, default: 0]
else { return }
self.appliedSnapshotSequence[key] = sequence
self.snapshots[accountID, default: [:]][id] = snapshot
self.snapshotFetchDates[accountID, default: [:]][id] = Date()
self.carryCountToList(from: snapshot, id: id, for: accountID)
}
snapshotFetches[key] = task
defer {
if snapshotFetches[key] == task {
snapshotFetches[key] = nil
}
}
try await task.value
}
/// Carries a refetched playlist's video count into the account's list.
///
/// The list row counts the playlist's videos, and only `fetchList` rewrites
/// the list — so a video added or removed from the detail view left the row
/// counting what the playlist held before, until something happened to
/// refetch the whole list. Nothing is invented here and no request is
/// added: the count is the one the server just answered this snapshot with,
/// and a playlist the list does not carry is left out of it rather than
/// put in.
private func carryCountToList(from snapshot: RemotePlaylistSnapshot, id: String, for accountID: UUID) {
guard let index = lists[accountID]?.firstIndex(where: { $0.id.playlistID == id }),
let existing = lists[accountID]?[index],
existing.videoCount != snapshot.playlist.videoCount
else { return }
lists[accountID]?[index] = Playlist(
id: existing.id,
title: existing.title,
description: existing.description,
author: existing.author,
videoCount: snapshot.playlist.videoCount,
thumbnailURL: existing.thumbnailURL,
videos: existing.videos,
isLocal: existing.isLocal
)
}
// MARK: - Reconciliation
/// Runs a change and refetches the playlist it touched — and the list too,
/// when the change is one the list shows — however the change turned out.
private func reconciling<T>(
playlist id: String,
andList refreshesList: Bool,
for account: Account,
_ mutation: () async throws -> T
) async throws -> T {
do {
let value = try await mutation()
await reconcileSnapshot(id: id, for: account)
if refreshesList {
await reconcileList(for: account)
}
return value
} catch {
// A write that reported failure may still have been applied — a
// connection dropped after the server acted looks exactly like one
// dropped before. The refetch is what settles it.
await reconcileSnapshot(id: id, for: account)
if refreshesList {
await reconcileList(for: account)
}
throw error
}
}
private func reconcilingList<T>(
for account: Account,
_ mutation: () async throws -> T
) async throws -> T {
do {
let value = try await mutation()
await reconcileList(for: account)
return value
} catch {
await reconcileList(for: account)
throw error
}
}
/// Refetches after a change, always as a new request: joining one that
/// started earlier would republish the playlist as it was *before*.
///
/// Its own failure is swallowed. The mutation's outcome is what the caller
/// is told about; a reconciling refetch that could not reach the server
/// leaves the previous data on screen, which is what every other read does
/// offline.
private func reconcileList(for account: Account) async {
try? await fetchList(for: account)
}
private func reconcileSnapshot(id: String, for account: Account) async {
try? await fetchSnapshot(id: id, for: account)
}
/// The same refetch, after a delete that reported failure — where the
/// refetch is also the verdict.
///
/// A playlist the server says it does not have is gone, whatever the delete
/// said, so the stored snapshot is dropped rather than left to be shown by
/// a detail view that is still open on it. Every other failure is swallowed
/// like any reconciling refetch: not reaching the server says nothing about
/// whether the playlist still exists.
private func reconcileSnapshotAfterDelete(id: String, for account: Account) async {
do {
try await fetchSnapshot(id: id, for: account)
} catch let error as APIError where Self.saysPlaylistIsGone(error) {
forgetPlaylist(id: id, for: account.id)
} catch {
// Swallowed: see the doc comment on `reconcileList(for:)`.
}
}
private static func saysPlaylistIsGone(_ error: APIError) -> Bool {
switch error {
case .notFound:
return true
case .httpError(let statusCode, _):
return statusCode == 404
default:
return false
}
}
/// 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
lists[accountID]?.removeAll { $0.id.playlistID == id }
}
// MARK: - Serialization
/// Runs a mutation with its key held, from the write to the end of the
/// refetch that reconciles it, and hands the key to whoever queued behind.
///
/// First come, first served: waiters are resumed in arrival order, so two
/// removals tapped in sequence are sent in that sequence. Nothing here
/// blocks a *read* — refreshes stay coalesced and unqueued, and only a
/// change waits for a change.
private func serializing<T>(_ key: MutationKey, _ body: () async throws -> T) async throws -> T {
await acquire(key)
defer { release(key) }
return try await body()
}
private func acquire(_ key: MutationKey) async {
guard mutationWaiters[key] != nil else {
mutationWaiters[key] = []
return
}
await withCheckedContinuation { continuation in
mutationWaiters[key, default: []].append(continuation)
}
}
private func release(_ key: MutationKey) {
guard var waiting = mutationWaiters[key], !waiting.isEmpty else {
mutationWaiters[key] = nil
return
}
let next = waiting.removeFirst()
mutationWaiters[key] = waiting
next.resume()
}
// MARK: - Fences
private func isCurrent(_ generation: Int, for accountID: UUID) -> Bool {
generations[accountID, default: 0] == generation
}
private func nextSequence() -> Int {
sequence += 1
return sequence
}
}