From 8a97c6da56333a4002c9bac412b99c1193abb19b Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 30 Aug 2026 23:30:57 +0200 Subject: [PATCH] Tie an import to the listing it was chosen from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credential baseline was recorded before the list it describes had arrived, and cleared alongside it. A reload landing while an import was still running therefore moved the baseline under it: the import passed its check against a login that had never listed the row it was working on, and a batch resumed through rows that no longer existed. A failed reload left the same wrong baseline standing with the old rows still on screen. The baseline is now recorded where the rows are, so a failed load leaves the previous login in place, and a generation names the listing itself — captured by the batch and by each import, so replacing the list stops what was chosen from it rather than re-authorising it. --- .../Views/Settings/ImportPlaylistsView.swift | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/Yattee/Views/Settings/ImportPlaylistsView.swift b/Yattee/Views/Settings/ImportPlaylistsView.swift index 51912d21..93b9d6af 100644 --- a/Yattee/Views/Settings/ImportPlaylistsView.swift +++ b/Yattee/Views/Settings/ImportPlaylistsView.swift @@ -34,6 +34,10 @@ struct ImportPlaylistsView: View { /// than against whatever is current — a replacement is a different /// person's account, and its playlist IDs mean something else entirely. @State private var loadedCredential: String? + /// Which listing the imports in flight belong to. Reloading the list + /// invalidates them: they were chosen from rows that are being replaced, + /// and the credential they are checked against is about to move. + @State private var listGeneration = 0 // Merge warning state @State private var showMergeWarning = false @@ -283,7 +287,8 @@ struct ImportPlaylistsView: View { isLoading = true error = nil - loadedCredential = credential + // Anything still importing was chosen from the rows being replaced. + listGeneration += 1 loginChangedDuringImport = false do { @@ -303,6 +308,11 @@ struct ImportPlaylistsView: View { } playlists = fetchedPlaylists.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + // Recorded only now: the baseline is whichever login actually + // produced the rows on screen. A failed load leaves the previous + // one standing, so an import still running keeps being checked + // against the login that listed it. + loadedCredential = credential // Check which playlists are already imported (by matching title) let localPlaylists = appEnvironment.dataManager.playlists() @@ -362,6 +372,11 @@ struct ImportPlaylistsView: View { // Returning quietly would leave the row importing forever and let a // batch carry on; reading it under a replacement would fetch whatever // that ID means to somebody else. + // The listing this playlist was chosen from can be replaced while the + // import runs, which re-baselines the credential it is checked + // against. Captured so a reload cannot quietly re-authorise it. + let generation = listGeneration + guard let credential = appEnvironment.credential(forInstance: instance), credential == loadedCredential else { loginChangedDuringImport = true @@ -424,7 +439,9 @@ struct ImportPlaylistsView: View { // write rather than only at the start. What has already been // written stays — it was imported under the login that asked // for it. - guard appEnvironment.credential(forInstance: instance) == credential else { + guard generation == listGeneration, + appEnvironment.credential(forInstance: instance) == credential, + credential == loadedCredential else { throw ImportError.loginChanged } @@ -472,8 +489,13 @@ struct ImportPlaylistsView: View { private func addAllPlaylists() async { loginChangedDuringImport = false + let generation = listGeneration for playlist in unimportedPlaylists { + // The rows this batch is working through have been replaced, and + // the remaining ones no longer describe what is on screen. + guard generation == listGeneration else { break } + await addPlaylist(playlist) // If merge warning is shown, wait for user interaction