diff --git a/Yattee/Core/AccountsManager.swift b/Yattee/Core/AccountsManager.swift index c0521816..aa9783a2 100644 --- a/Yattee/Core/AccountsManager.swift +++ b/Yattee/Core/AccountsManager.swift @@ -338,6 +338,29 @@ final class AccountsManager { NotificationCenter.default.post(name: .dataContextDidChange, object: nil) } + /// Repairs a context still naming an account this device knows was deleted. + /// + /// The deletion paths repair as they run, so this is for a selection that + /// was left behind anyway: a tombstone recorded while the context named + /// something else, or a launch that reads both back from disk. Repaired + /// exactly like a deletion — same sibling preference, same timestamp, never + /// published — because it *is* one, only noticed later. + /// + /// The tombstone is the whole authorisation. An account merely missing from + /// the store has not been deleted, it has not arrived, and moving the + /// selection on that is how a slow sync costs the user their choice. + /// + /// - Returns: `true` when the selection actually moved. + @discardableResult + func correctContextIfAccountDeleted() -> Bool { + guard case let .account(id) = storedContext.context else { return false } + guard deletedAccountIDs.contains(id) else { return false } + + correctContextAfterDeletion(onInstance: deletedAccountInstanceIDs[id]) + save() + return true + } + /// Where a context whose account was deleted should land. /// /// Another signed-in login on the same server is far closer to what the user @@ -487,7 +510,19 @@ final class AccountsManager { } save() + + // The legacy selection may name exactly this account. Migration refuses + // to adopt a context whose account is not there yet, and on a fresh + // install the account arrives from iCloud long after the migration ran — + // so it is retried here rather than left until the next launch. Free to + // repeat: it never runs once a context has been chosen. + let contextBefore = storedContext.context + migrateLegacyContextIfNeeded() + NotificationCenter.default.post(name: .accountsDidChange, object: nil) + if storedContext.context != contextBefore { + NotificationCenter.default.post(name: .dataContextDidChange, object: nil) + } return false } @@ -577,6 +612,7 @@ final class AccountsManager { @discardableResult func applySyncedContext(_ incoming: StoredDataContext) -> Bool { var incoming = incoming + var wasRepaired = false // A tombstone dominates the account everywhere, contexts included. An // offline device can select an account another device has deleted and @@ -592,6 +628,7 @@ final class AccountsManager { context: repairTarget(onInstance: deletedAccountInstanceIDs[accountID]), changedAt: incoming.changedAt ) + wasRepaired = true LoggingService.shared.info( "Ignoring a synced context that names a deleted account", @@ -605,24 +642,38 @@ final class AccountsManager { // constructed, so a synced choice made yesterday would lose to it and a // fresh device would never adopt the selection at all. guard hasStoredContext else { - storedContext = incoming - hasStoredContext = true - save() - NotificationCenter.default.post(name: .dataContextDidChange, object: nil) + adoptSyncedContext(incoming, wasRepaired: wasRepaired) return false } guard storedContext.isOutranked(by: incoming) else { + // The local choice is the newer one and survives untouched — a + // repair that lost the ordering never gets to clear what that + // choice still owes the server. guard incoming.isOutranked(by: storedContext) else { return false } pendingContextUpload = true save() return true } + adoptSyncedContext(incoming, wasRepaired: wasRepaired) + return false + } + + /// Takes on a context that arrived from another device. + /// + /// A repaired value is an automatic fallback rather than a choice, so any + /// upload the replaced selection still owed is dropped along with it: + /// sending it now would publish this fallback under that choice's + /// timestamp, which is exactly what keeping the timestamp is meant to + /// prevent — the same reasoning as `correctContextAfterDeletion`. + private func adoptSyncedContext(_ incoming: StoredDataContext, wasRepaired: Bool) { storedContext = incoming hasStoredContext = true + if wasRepaired { + pendingContextUpload = false + } save() NotificationCenter.default.post(name: .dataContextDidChange, object: nil) - return false } // MARK: - Status diff --git a/Yattee/Services/API/PipedAPI.swift b/Yattee/Services/API/PipedAPI.swift index 0f0711cf..f5528806 100644 --- a/Yattee/Services/API/PipedAPI.swift +++ b/Yattee/Services/API/PipedAPI.swift @@ -943,7 +943,7 @@ private struct PipedStreamResponse: Decodable, Sendable { publishedAt: PipedSentinel.uploadedDate(uploaded), publishedText: uploadDate, viewCount: PipedSentinel.count(views), - likeCount: likes.map { Int($0) }, + likeCount: PipedSentinel.count(likes), thumbnails: thumbnails, isLive: livestream ?? false, isUpcoming: false, @@ -1116,8 +1116,8 @@ private struct PipedSearchItem: Decodable, Sendable { id: .global(channelId ?? ""), name: name ?? "", description: description, - subscriberCount: subscribers.map { Int($0) }, - videoCount: videos.map { Int($0) }, + subscriberCount: PipedSentinel.count(subscribers), + videoCount: PipedSentinel.count(videos), thumbnailURL: thumbnail.flatMap { URL(string: $0) }, isVerified: uploaderVerified ?? false ) @@ -1128,7 +1128,7 @@ private struct PipedSearchItem: Decodable, Sendable { id: .global(playlistId ?? ""), title: name ?? "", author: uploaderName.map { Author(id: "", name: $0) }, - videoCount: videos.map { Int($0) } ?? 0, + videoCount: PipedSentinel.count(videos) ?? 0, thumbnailURL: thumbnail.flatMap { URL(string: $0) } ) } @@ -1151,7 +1151,7 @@ private struct PipedChannelResponse: Decodable, Sendable { id: .global(id), name: name, description: description, - subscriberCount: subscriberCount.map { Int($0) }, + subscriberCount: PipedSentinel.count(subscriberCount), thumbnailURL: avatarUrl.flatMap { URL(string: $0) }, bannerURL: bannerUrl.flatMap { URL(string: $0) }, isVerified: verified ?? false @@ -1221,7 +1221,7 @@ private struct PipedTabPlaylist: Decodable, Sendable { id: .global(playlistId ?? ""), title: name ?? "", author: uploaderName.map { Author(id: uploaderUrl?.replacingOccurrences(of: "/channel/", with: "") ?? "", name: $0) }, - videoCount: videos.map { Int($0) } ?? 0, + videoCount: PipedSentinel.count(videos) ?? 0, thumbnailURL: thumbnail.flatMap { URL(string: $0) } ) } @@ -1278,7 +1278,9 @@ private struct PipedPlaylistResponse: Decodable, Sendable { let uploader: String? let uploaderUrl: String? let uploaderAvatar: String? - let videos: Int? + /// Read as 64-bit like every other Piped count, so the `-1` sentinel goes + /// through the same reading as the rest. + let videos: Int64? let relatedStreams: [PipedVideoItem]? let thumbnailUrl: String? let nextpage: String? @@ -1303,7 +1305,7 @@ private struct PipedPlaylistResponse: Decodable, Sendable { thumbnailURL: uploaderAvatar.flatMap { URL(string: $0) } ) }, - videoCount: videos ?? validVideos.count, + videoCount: PipedSentinel.count(videos) ?? validVideos.count, thumbnailURL: thumbnailUrl.flatMap { URL(string: $0) }, videos: validVideos ) @@ -1322,14 +1324,14 @@ private struct PipedUserPlaylist: Decodable, Sendable { let name: String let shortDescription: String? let thumbnail: String? - let videos: Int? + let videos: Int64? nonisolated func toPlaylist() -> Playlist { Playlist( id: .global(id), title: name, description: shortDescription, - videoCount: videos ?? 0, + videoCount: PipedSentinel.count(videos) ?? 0, thumbnailURL: thumbnail.flatMap { URL(string: $0) } ) } diff --git a/Yattee/Services/Subscriptions/DataContextValidator.swift b/Yattee/Services/Subscriptions/DataContextValidator.swift index 948a89d8..184740eb 100644 --- a/Yattee/Services/Subscriptions/DataContextValidator.swift +++ b/Yattee/Services/Subscriptions/DataContextValidator.swift @@ -161,28 +161,30 @@ final class DataContextValidator { /// Corrects the context only when the selected account is provably gone. /// - /// Called after deletions. A timeout, a failed fetch or a missing record - /// must never reach here — those are all "unknown", and treating them as - /// removal is how a user loses their selection during a slow sync. + /// Called after deletions and on foreground activation. A timeout, a failed + /// fetch or a record that simply has not arrived must never move anything — + /// those are all "unknown", and treating them as removal is how a user + /// loses their selection during a slow sync. Only the deletion tombstone + /// counts, and the repair it triggers is never published. func correctIfContextRemoved() { - // An unresolved identity has not loaded anybody's accounts yet, so an - // account "missing" from that state proves nothing — least of all a - // deletion, which is the only thing allowed to move the context here. + // An unresolved identity has not loaded anybody's accounts yet, so + // neither the selection nor the tombstones read here are the ones this + // device will end up with. guard accountsManager.isIdentityResolved else { return } - guard case let .account(id) = accountsManager.currentContext else { return } - guard accountsManager.account(id: id) == nil else { return } - // Only an explicit deletion clears the account from the store, so - // reaching here means the account really is gone. - accountsManager.setContext(.local) + // The manager owns the decision: it holds the tombstones, and it + // repairs the same way an account deleted right now would be — onto a + // signed-in login on the same server when there is one, keeping the + // replaced choice's timestamp and publishing nothing. + guard accountsManager.correctContextIfAccountDeleted() else { return } toastManager.showInfo( String(localized: "subscriptions.accountSwitched.title"), - subtitle: String(localized: "subscriptions.account.local") + subtitle: displayName(for: accountsManager.currentContext) ) LoggingService.shared.info( - "Selected account was deleted — data context reset to local", + "Selected account was deleted — data context repaired", category: .general ) } diff --git a/Yattee/Views/Channel/ChannelView.swift b/Yattee/Views/Channel/ChannelView.swift index 163d0bbc..f53233d9 100644 --- a/Yattee/Views/Channel/ChannelView.swift +++ b/Yattee/Views/Channel/ChannelView.swift @@ -29,6 +29,10 @@ struct ChannelView: View { @State private var isLoadingMore = false @State private var errorMessage: String? @State private var isSubscribed = false + /// Which subscription lookup the button state belongs to. A channel change + /// or a change of who is being read as bumps it, so a reply from the + /// previous one is dropped instead of answering for the channel on screen. + @State private var subscriptionGeneration = 0 @State private var showingUnsubscribeConfirmation = false #if os(tvOS) @State private var showingSubscriptionDestinations = false @@ -196,6 +200,15 @@ struct ChannelView: View { .onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in loadWatchEntries() } + // Whose subscriptions the button speaks for just changed — by selection, + // or by the credential behind the selected account being replaced — so + // it has to be asked again rather than left showing the last answer. + .onReceive(NotificationCenter.default.publisher(for: .dataContextDidChange)) { _ in + refreshSubscription() + } + .onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in + refreshSubscription() + } } /// Applies the channel `.searchable` field and its search handlers around the given @@ -1324,8 +1337,13 @@ struct ChannelView: View { /// A destination row toggled something. Only the account on screen changes /// what the main button says, and notification preferences are local and /// keyed by channel, so they follow the same rule. + /// + /// Compared against the *effective* context, which is what the button reads + /// from: while the selected account is unavailable the button speaks for + /// local data, and the menu offers Local precisely so the user can act on it + /// — a toggle there has to be the one the button then shows. private func destinationDidToggle(_ context: DataContext, subscribed: Bool) { - guard context == appEnvironment?.accountsManager.currentContext else { return } + guard context == appEnvironment?.dataContextValidator.effectiveContext else { return } if subscribed, appEnvironment?.settingsManager.defaultNotificationsForNewChannels == true, @@ -2326,9 +2344,19 @@ struct ChannelView: View { isSubscribed = appEnvironment?.dataManager.subscription(for: effectiveChannelID) != nil } + subscriptionGeneration += 1 + let generation = subscriptionGeneration + Task { if let service = appEnvironment?.subscriptionService { - isSubscribed = await service.isSubscribed(to: effectiveChannelID) + let subscribed = await service.isSubscribed(to: effectiveChannelID) + // This view is reused as the user navigates from channel to + // channel, and the answer may have been asked for under another + // account entirely. Either way it no longer describes the button + // it would be written to. + guard generation == subscriptionGeneration, + (channel?.id.channelID ?? channelID) == effectiveChannelID else { return } + isSubscribed = subscribed } } } diff --git a/Yattee/Views/Components/SubscriptionDestinationMenu.swift b/Yattee/Views/Components/SubscriptionDestinationMenu.swift index 2f6244d7..601357b0 100644 --- a/Yattee/Views/Components/SubscriptionDestinationMenu.swift +++ b/Yattee/Views/Components/SubscriptionDestinationMenu.swift @@ -104,6 +104,18 @@ struct SubscriptionDestinationRows: View { @State private var membership = SubscriptionDestinationMembership() @State private var busy: Set = [] + /// Bumped when the logins change, so the answers are asked for again. + @State private var membershipRound = 0 + + /// What a round of membership answers belongs to. + /// + /// The channel alone is not enough: the same channel on the same rows means + /// something different once a credential has been replaced, and the picker + /// deliberately stays open across such a change on tvOS. + private struct MembershipRequest: Equatable { + let channel: Channel.ID + let round: Int + } var body: some View { ForEach(destinations, id: \.self) { context in @@ -121,9 +133,28 @@ struct SubscriptionDestinationRows: View { // Membership is only asked for once the rows are on screen: an unwarmed // provider answers by fetching that account's whole subscription list, // which is far too much to spend on a menu nobody opened. - .task(id: channel.id) { + .task(id: MembershipRequest(channel: channel.id, round: membershipRound)) { await loadMembership() } + // The credential behind an account was replaced, or the accounts + // themselves changed: every checkmark on screen describes the logins + // that were there before. + .onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in + invalidateMembership() + } + .onReceive(NotificationCenter.default.publisher(for: .accountsDidChange)) { _ in + invalidateMembership() + } + } + + /// Forgets what is known and asks again. + /// + /// Forgetting first, rather than waiting for the replacement answers: a row + /// toggled in between would otherwise toggle against the previous login's + /// membership and do the opposite of what its checkmark promises. + private func invalidateMembership() { + membership.invalidate() + membershipRound += 1 } @ViewBuilder diff --git a/Yattee/Views/Settings/SubscriptionsSettingsView.swift b/Yattee/Views/Settings/SubscriptionsSettingsView.swift index 22e9ee21..b2b32789 100644 --- a/Yattee/Views/Settings/SubscriptionsSettingsView.swift +++ b/Yattee/Views/Settings/SubscriptionsSettingsView.swift @@ -61,6 +61,16 @@ struct SubscriptionsSettingsView: View { appEnvironment?.accountsManager.currentContext ?? .local } + /// What the counts and the export are actually read from. + /// + /// While the selected account is unavailable that is the local store — so a + /// count or a file described as the account's would name a source it never + /// came from. The selection itself is untouched, and the moment the account + /// is usable again this follows it back. + private var effectiveContext: DataContext { + appEnvironment?.dataContextValidator.effectiveContext ?? currentContext + } + var body: some View { List { accountSection @@ -68,7 +78,10 @@ struct SubscriptionsSettingsView: View { exportSection localDataSection } - .task(id: currentContext) { + // Keyed to the effective context, not the selection: an account going + // unavailable — or coming back — changes which store the count is read + // from without the selection moving at all. + .task(id: effectiveContext) { await refreshAccountSubscriptionCount() } .navigationTitle(String(localized: "settings.subscriptions.title")) @@ -282,11 +295,15 @@ struct SubscriptionsSettingsView: View { } } - /// Exports subscriptions of the active account: the local store in local - /// mode, or the server's list for Invidious/Piped accounts. + /// Exports subscriptions of whatever is being read from: the local store in + /// local mode, or the server's list for Invidious/Piped accounts. + /// + /// The branch follows the effective context because the fetch below does — + /// branching on the selection instead would take the server path only to + /// export local data through it. private func performExport() async { let subscriptions: [Subscription] - if currentContext.isLocal { + if effectiveContext.isLocal { guard let dataManager else { return } subscriptions = dataManager.allSubscriptions } else { @@ -342,7 +359,7 @@ struct SubscriptionsSettingsView: View { if let cached = subscriptionService.cachedSubscriptionCount { accountSubscriptionCount = cached } - if !subscriptionService.currentContext.isLocal { + if !effectiveContext.isLocal { if let channels = try? await subscriptionService.fetchSubscriptions() { accountSubscriptionCount = channels.count } diff --git a/YatteeTests/AccountsManagerTests.swift b/YatteeTests/AccountsManagerTests.swift index 7dca3408..957c6825 100644 --- a/YatteeTests/AccountsManagerTests.swift +++ b/YatteeTests/AccountsManagerTests.swift @@ -157,6 +157,49 @@ struct AccountsManagerTests { #expect(manager2.currentContext == .local) } + @Test("A legacy selection migrates when its account finally syncs") + func legacyContextMigratesOnLateAccountArrival() { + let defaults = makeDefaults() + let instanceID = UUID() + + let legacy = try! JSONEncoder().encode(SubscriptionAccount.invidious(instanceID)) + defaults.set(legacy, forKey: "subscriptionAccount") + + let manager = makeManager(defaults: defaults) + // On a fresh install the accounts arrive from iCloud long after launch, + // so the migration finds nothing to point at and adopts nothing. + manager.migrateLegacyContextIfNeeded() + #expect(manager.currentContext == .local) + + // The account the legacy value names, arriving at last. A migrated + // account reuses its instance's UUID, which is what the legacy mapping + // resolves to. + manager.applySyncedAccount(Account.migrated(from: instanceID, dateAdded: Date())) + + #expect(manager.currentContext == .account(instanceID)) + } + + @Test("A choice made while the account was in transit is never overridden") + func lateAccountArrivalNeverOverridesAChoice() { + let defaults = makeDefaults() + let instanceID = UUID() + + let legacy = try! JSONEncoder().encode(SubscriptionAccount.invidious(instanceID)) + defaults.set(legacy, forKey: "subscriptionAccount") + + let manager = makeManager(defaults: defaults) + manager.migrateLegacyContextIfNeeded() + + // The user picked something in the meantime; a guess at what they meant + // before the upgrade never replaces what they actually chose after it. + let chosen = manager.addAccount(instanceID: UUID()) + manager.setContext(.account(chosen.id)) + + manager.applySyncedAccount(Account.migrated(from: instanceID, dateAdded: Date())) + + #expect(manager.currentContext == .account(chosen.id)) + } + @Test("A legacy instance selection resolves to a real account") func legacySelectionResolvesToRealAccount() { let manager = makeManager() @@ -553,8 +596,8 @@ struct DataContextValidatorTests { instances.remove(instance) } - @Test("Only an account that is genuinely gone moves the context") - func correctionRunsOnlyOnAbsence() { + @Test("Only a deletion the app can point at moves the context") + func correctionRequiresATombstone() { let instances = InstancesManager() var instance = Instance(type: .invidious, url: URL(string: "https://inv-correct.example.com")!) instances.add(instance) @@ -579,15 +622,48 @@ struct DataContextValidatorTests { validator.correctIfContextRemoved() #expect(accounts.currentContext == .account(account.id)) - // An account the store has never heard of is the one case that counts. - accounts.setContext(.account(UUID())) + // An account the store has never heard of has not been proven deleted: + // it may be a record the fetch has yet to deliver, and this runs on + // foreground activation — before that fetch. + let pending = UUID() + accounts.setContext(.account(pending)) validator.correctIfContextRemoved() - #expect(accounts.currentContext == .local) + #expect(accounts.currentContext == .account(pending)) - // Persisted, not merely held in memory: the correction has to survive - // the relaunch that would otherwise re-read the dead selection. - let (reloaded, _) = makeValidator(instances: instances, defaults: defaults) - #expect(reloaded.currentContext == .local) + // Nor is it corrected on the way back in: a relaunch reads the same + // absence, and the user's choice has to survive it. + let (reloaded, reloadedValidator) = makeValidator(instances: instances, defaults: defaults) + reloadedValidator.correctIfContextRemoved() + #expect(reloaded.currentContext == .account(pending)) + } + + @Test("A tombstoned selection is repaired without being published") + func correctionFollowsTheTombstone() { + let instances = InstancesManager() + let instance = Instance(type: .invidious, url: URL(string: "https://inv-tombstone.example.com")!) + instances.add(instance) + defer { instances.remove(instance) } + + let (accounts, validator) = makeValidator(instances: instances) + let deleted = accounts.addAccount(instanceID: instance.id) + let sibling = accounts.addAccount(instanceID: instance.id) + accounts.setStatus(.signedIn, for: sibling.id) + + // Deleted while this device pointed somewhere else, then selected again + // by a stale choice that arrived afterwards — the case the deletion + // paths themselves never see. + accounts.setContext(.local) + accounts.applyRemoteTombstone(deleted.id) + accounts.setContext(.account(deleted.id)) + let chosenAt = accounts.storedContext.changedAt + + validator.correctIfContextRemoved() + + // Repaired like any deletion: the signed-in login on the same server, + // the replaced choice's timestamp, and nothing left to publish. + #expect(accounts.currentContext == .account(sibling.id)) + #expect(accounts.storedContext.changedAt == chosenAt) + #expect(!accounts.pendingContextUpload) } @Test("Only accounts the app can actually resolve are offered") @@ -742,6 +818,62 @@ struct DataContextRepairTests { #expect(manager.currentContext == .local) } + + @Test("Adopting a repaired synced context drops what the replaced choice owed") + func syncedTombstoneRepairIsNeverPublished() { + let instances = InstancesManager() + let instance = makeInstance("inv") + instances.add(instance) + defer { instances.remove(instance) } + + let manager = makeManager(instances: instances) + let deleted = manager.addAccount(instanceID: instance.id) + let sibling = manager.addAccount(instanceID: instance.id) + manager.setStatus(.signedIn, for: sibling.id) + + // A choice made here that has not reached the server yet. + manager.setContext(.local) + manager.removeAccount(deleted.id) + #expect(manager.pendingContextUpload) + + let incoming = StoredDataContext( + context: .account(deleted.id), + changedAt: Date().addingTimeInterval(60) + ) + manager.applySyncedContext(incoming) + + // The repair replaced that choice, so the upload it owed goes with it — + // sending it now would publish the fallback under its timestamp. + #expect(manager.currentContext == .account(sibling.id)) + #expect(!manager.pendingContextUpload) + } + + @Test("A local choice that outranks a repair keeps its upload") + func repairThatLosesLeavesTheChoiceDirty() { + let instances = InstancesManager() + let instance = makeInstance("inv") + instances.add(instance) + defer { instances.remove(instance) } + + let manager = makeManager(instances: instances) + let deleted = manager.addAccount(instanceID: instance.id) + let sibling = manager.addAccount(instanceID: instance.id) + manager.setStatus(.signedIn, for: sibling.id) + + manager.removeAccount(deleted.id) + manager.setContext(.local) + + // The other device's selection — and so its repair — is older than the + // choice made here. + let incoming = StoredDataContext( + context: .account(deleted.id), + changedAt: Date().addingTimeInterval(-60) + ) + #expect(manager.applySyncedContext(incoming)) + + #expect(manager.currentContext == .local) + #expect(manager.pendingContextUpload) + } } @Suite("Account capabilities") diff --git a/YatteeTests/PipedResponseTests.swift b/YatteeTests/PipedResponseTests.swift index a1a2e3b1..03e09603 100644 --- a/YatteeTests/PipedResponseTests.swift +++ b/YatteeTests/PipedResponseTests.swift @@ -41,14 +41,15 @@ private func pipedStreamJSON( return "{\(fields.joined(separator: ", "))}" } -/// Builds the first page of `/playlists/{id}`. -private func pipedPlaylistPageJSON(streams: [String]) -> Data { +/// Builds the first page of `/playlists/{id}`. `videos` defaults to the number +/// of rows the page carries, which is what a healthy server sends. +private func pipedPlaylistPageJSON(streams: [String], videos: Int64? = nil) -> Data { Data(""" { "name": "Watch Later", "uploader": "arek", "uploaderUrl": "/channel/UCowner", - "videos": \(streams.count), + "videos": \(videos ?? Int64(streams.count)), "relatedStreams": [\(streams.joined(separator: ",\n"))], "nextpage": null } @@ -127,6 +128,44 @@ struct PipedSentinelTests { #expect(video.viewCount == 4200) #expect(video.publishedAt == Date(timeIntervalSince1970: 1_609_459_200)) } + + @Test("A playlist that says it holds -1 videos is counted by its rows") + func playlistCountSentinelFallsBackToTheRows() throws { + let page = pipedPlaylistPageJSON( + streams: [ + pipedStreamJSON(videoId: "aaa", title: "First", views: 10, uploaded: nil), + pipedStreamJSON(videoId: "bbb", title: "Second", views: 20, uploaded: nil) + ], + videos: -1 + ) + + let snapshot = try PipedAPI.userPlaylistSnapshot( + fromPageJSON: [page], + playlistID: "playlist-uuid", + instanceURL: pipedBaseURL + ) + + // "-1 videos" would show as a row count in the list, and reads as a + // playlist that lost everything in it. + #expect(snapshot.playlist.videoCount == 2) + } + + @Test("A playlist count the server has beats the row count") + func playlistCountIsTakenFromTheServer() throws { + // More than the page carries — the rest is behind the pager. + let page = pipedPlaylistPageJSON( + streams: [pipedStreamJSON(videoId: "aaa", title: "First", views: 10, uploaded: nil)], + videos: 42 + ) + + let snapshot = try PipedAPI.userPlaylistSnapshot( + fromPageJSON: [page], + playlistID: "playlist-uuid", + instanceURL: pipedBaseURL + ) + + #expect(snapshot.playlist.videoCount == 42) + } } // MARK: - Write Responses