From e2ad28ca3207d5349eb611ebe81e1dc5e53078c0 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:45:12 +0200 Subject: [PATCH] fix(downloads): retain offline media after logout --- lib/providers/download_provider.dart | 21 +++++++++++++++- lib/screens/profile/profile_teardown.dart | 11 +++++---- test/providers/download_provider_test.dart | 28 ++++++++++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 76723c32..8dd6f848 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -89,6 +89,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin final Map _syncRules = {}; String? _activeProfileId; + Future? _profileScopedReloadFuture; OfflineModeSource? _offlineSource; @@ -141,7 +142,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin void setActiveProfileId(String? profileId) { if (_activeProfileId == profileId) return; _activeProfileId = profileId; - unawaited(_reloadProfileScopedStateForActiveProfile()); + final reload = _reloadProfileScopedStateForActiveProfile(); + _profileScopedReloadFuture = reload; + unawaited(reload); } Future _reloadProfileScopedStateForActiveProfile() async { @@ -219,6 +222,22 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin safeNotifyListeners(); } + /// Preserve physical downloads across a full logout while detaching them + /// from profiles that are about to be deleted. The next selected profile + /// adopts the ownerless rows through [_loadDownloadOwners]. + Future detachDownloadsForLogout() async { + // Invalidate any in-flight profile reload before waiting for it: an old + // reload may still finish its DB adoption, but cannot repopulate the + // active-profile view after this point. + _activeProfileId = null; + await _initFuture; + await _profileScopedReloadFuture; + await _database.clearAllDownloadOwners(); + _ownedDownloadKeys.clear(); + _syncRules.clear(); + safeNotifyListeners(); + } + /// Remove ownership rows for [profileId] that belong to the removed /// connection's public server ids. Physical files stay when any other valid /// owner remains. diff --git a/lib/screens/profile/profile_teardown.dart b/lib/screens/profile/profile_teardown.dart index 551f39d5..bcc8cddf 100644 --- a/lib/screens/profile/profile_teardown.dart +++ b/lib/screens/profile/profile_teardown.dart @@ -230,7 +230,10 @@ Future logoutAllProfiles(BuildContext context) async { await companionRemote.resetForLogout(); await userProfileProvider.logout(); - await scope.downloads.deleteAllDownloads(); + // Downloads are device-local data, not credentials. Keep their physical + // files and pinned metadata, but detach profile ownership before deleting + // the profiles so the next selected profile can adopt them. + await scope.downloads.detachDownloadsForLogout(); scope.multiServer.clearAllConnections(); // Drop the profile/connection rows so the next sign-in starts clean and // doesn't bind to stale tokens or orphaned profile rows. @@ -246,9 +249,9 @@ Future logoutAllProfiles(BuildContext context) async { // through the next sign-in's clients). await scope.database.clearAllWatchActions(); await scope.database.clearAllSyncRules(); - // Downloads were removed above, so no pinned cache rows need to survive this - // app-global logout into the next sign-in. - await ApiCache.instance.clearAll(); + // Preserve pinned rows backing offline downloads; all session/API data is + // volatile and must not cross into the next sign-in. + await ApiCache.instance.clearVolatile(); await scope.hiddenLibraries?.refresh(); playbackState.clearShuffle(); diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index 14f81b15..790ae46f 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -156,6 +156,34 @@ void main() { p.dispose(); }); + + test('logout detaches ownership without deleting physical downloads', () async { + const globalKey = 'srv:preserved'; + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'preserved', + globalKey: globalKey, + type: 'movie', + status: DownloadStatus.completed.index, + ); + await db.addDownloadOwner(profileId: 'test-profile', globalKey: globalKey); + + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + p.debugSeedState( + downloads: {globalKey: const DownloadProgress(globalKey: globalKey, status: DownloadStatus.completed)}, + ownedDownloadKeys: {globalKey}, + ); + expect(p.downloads, contains(globalKey)); + + await p.detachDownloadsForLogout(); + + expect(await db.getDownloadedMedia(globalKey), isNotNull); + expect(await db.hasDownloadOwner(globalKey), isFalse); + expect(p.downloads, isEmpty); + + p.dispose(); + }); }); group('DownloadProvider — local file selection', () {