From 13b1fe9c0f113b6912e265c4729c4d5b198f7001 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 30 Aug 2026 23:21:36 +0200 Subject: [PATCH] Close the two ways a merge outlived its login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge dialog waits on the user, so the login can go between choosing a playlist and confirming it. Reading it then returned quietly: the row stayed in its importing state with nothing running, and a batch went on to the next playlist. It now reports what happened and clears up after itself, like every other way an import stops. The merge that dialog starts is detached from it, so dismissing the dialog was never the merge finishing — a batch resumed alongside it and could begin the next playlist under whoever replaced the login. It is waited for now, which is what makes the batch sequential. --- .../Views/Settings/ImportPlaylistsView.swift | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/Yattee/Views/Settings/ImportPlaylistsView.swift b/Yattee/Views/Settings/ImportPlaylistsView.swift index 778ff29a..1269f3d2 100644 --- a/Yattee/Views/Settings/ImportPlaylistsView.swift +++ b/Yattee/Views/Settings/ImportPlaylistsView.swift @@ -26,6 +26,9 @@ struct ImportPlaylistsView: View { /// different account or leave an empty playlist behind for one that is /// gone. @State private var loginChangedDuringImport = false + /// The merge started from the confirmation dialog, which runs detached + /// from the batch that queued it. Held so the batch can wait for it. + @State private var mergeImportTask: Task? // Merge warning state @State private var showMergeWarning = false @@ -92,7 +95,7 @@ struct ImportPlaylistsView: View { ) { Button(String(localized: "import.playlists.mergeWarning.merge")) { if let playlist = pendingMergePlaylist, let localPlaylist = existingLocalPlaylist { - Task { await performImport(playlist, into: localPlaylist) } + mergeImportTask = Task { await performImport(playlist, into: localPlaylist) } } } Button(String(localized: "common.cancel"), role: .cancel) { @@ -338,8 +341,21 @@ struct ImportPlaylistsView: View { } private func performImport(_ playlist: Playlist, into localPlaylist: LocalPlaylist) async { - guard let appEnvironment, - let credential = appEnvironment.credential(forInstance: instance) else { + guard let appEnvironment else { return } + + // The login can go between choosing this playlist and reading it — + // the merge dialog in particular waits on the user. Returning quietly + // would leave the row importing forever and let a batch carry on. + guard let credential = appEnvironment.credential(forInstance: instance) else { + loginChangedDuringImport = true + importingPlaylistID = nil + importProgress = nil + pendingMergePlaylist = nil + existingLocalPlaylist = nil + appEnvironment.toastManager.showError( + String(localized: "import.playlists.failed.title"), + subtitle: ImportError.loginChanged.localizedDescription + ) return } @@ -443,14 +459,22 @@ struct ImportPlaylistsView: View { for playlist in unimportedPlaylists { await addPlaylist(playlist) - // The rest of the batch belongs to a login that is no longer - // there. Reported once, by whichever import stopped first. - if loginChangedDuringImport { break } - // If merge warning is shown, wait for user interaction while showMergeWarning { try? await Task.sleep(for: .milliseconds(100)) } + + // The merge the dialog starts is detached from it, so dismissing + // the dialog is not the merge finishing. Waiting keeps the batch + // sequential — and lets a login change the merge runs into stop + // the rest, rather than the next import beginning under whoever + // replaced it. + await mergeImportTask?.value + mergeImportTask = nil + + // The rest of the batch belongs to a login that is no longer + // there. Reported once, by whichever import stopped first. + if loginChangedDuringImport { break } } }