Files
plezy/test/providers/download_provider_test.dart
T

2916 lines
109 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart' show ApplyInterceptor, QueryExecutor, QueryInterceptor;
import 'package:plezy/media/ids.dart';
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/database/download_operations.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/models/download_models.dart';
import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/api_cache.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/offline_mode_source.dart';
import 'package:plezy/utils/deletion_notifier.dart';
import 'package:plezy/utils/notification_permission.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
import 'package:plezy/utils/active_client_scope.dart';
import '../test_helpers/media_items.dart';
/// Implements only [fetchPlayableDescendants], the surface [collectEpisodes]
/// uses. Every other call reaches [noSuchMethod] and throws.
class _ThrowingClient implements MediaServerClient {
@override
ServerId get serverId => ServerId('srv');
@override
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
throw StateError('test: fetchPlayableDescendants intentionally fails');
}
@override
MediaBackend get backend => MediaBackend.plex;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// Returns canned tracks from [fetchPlayableDescendants] (album/artist
/// expansion) and records the requested parent ids.
class _MusicExpansionClient implements MediaServerClient {
_MusicExpansionClient(this.tracks, {this.gate, this.started});
final List<MediaItem> tracks;
final Future<void>? gate;
final Completer<void>? started;
final fetchPlayableDescendantsCalls = <String>[];
@override
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
fetchPlayableDescendantsCalls.add(parentId);
if (started != null && !started!.isCompleted) started!.complete();
if (gate != null) await gate;
return tracks;
}
@override
MediaBackend get backend => MediaBackend.plex;
@override
ServerId get serverId => ServerId('srv');
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _ScopedTestClient implements MediaServerClient, ScopedMediaServerClient {
_ScopedTestClient({
required this.serverId,
required this.scopedServerId,
this.fetchItemHandler,
this.clientBackend = MediaBackend.jellyfin,
});
@override
final ServerId serverId;
@override
final String scopedServerId;
final Future<MediaItem?> Function(String id)? fetchItemHandler;
final MediaBackend clientBackend;
@override
MediaBackend get backend => clientBackend;
@override
ApiCache get cache => ApiCache.forBackend(clientBackend);
@override
Future<MediaItem?> fetchItem(String id, {bool useCache = true}) async => fetchItemHandler?.call(id);
@override
void close() {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _DownloadOwnerSelectGate extends QueryInterceptor {
Completer<void>? _started;
Completer<void>? _release;
String? _globalKey;
Future<void> get started => _started!.future;
void arm(String globalKey) {
_globalKey = globalKey;
_started = Completer<void>();
_release = Completer<void>();
}
void release() => _release!.complete();
@override
Future<List<Map<String, Object?>>> runSelect(QueryExecutor executor, String statement, List<Object?> args) async {
final started = _started;
final release = _release;
if (started != null && !started.isCompleted && statement.contains('download_owners') && args.contains(_globalKey)) {
started.complete();
await release!.future;
}
return executor.runSelect(statement, args);
}
}
class _GatedPhysicalDeletionManager extends DownloadManagerService {
_GatedPhysicalDeletionManager(AppDatabase database)
: super(
database: database,
storageService: DownloadStorageService.instance,
clientResolver: (serverId, {clientScopeId}) => null,
) {
recoveryFuture = Future<void>.value();
}
final started = Completer<void>();
final release = Completer<void>();
Future<void> _completePhysicalDeletion() async {
if (!started.isCompleted) started.complete();
await release.future;
}
@override
Future<void> cancelAndRemoveDownload(String globalKey) => _completePhysicalDeletion();
@override
Future<void> deleteDownload(String globalKey) => _completePhysicalDeletion();
}
Future<void> _insertProfile(AppDatabase db, String id) => db
.into(db.profiles)
.insert(ProfilesCompanion.insert(id: id, kind: 'local', displayName: id, configJson: '{}', createdAt: 0));
Future<void> _insertPlexConnection(AppDatabase db, ServerId serverId) => db
.into(db.connections)
.insert(
ConnectionsCompanion.insert(id: serverId, kind: 'plex', displayName: serverId, configJson: '{}', createdAt: 0),
);
Map<String, dynamic> _plexMetadata({
required String id,
required String title,
required int viewCount,
required int viewOffset,
}) => {
'MediaContainer': {
'Metadata': [
{'ratingKey': id, 'title': title, 'type': 'movie', 'viewCount': viewCount, 'viewOffset': viewOffset},
],
},
};
Future<void> _putPinnedPlexMetadata(
PlexProfileScopeId scope, {
required String id,
required String title,
required int viewCount,
required int viewOffset,
}) async {
await PlexApiCache.instance.put(
scope.cacheServerId,
'/library/metadata/$id',
_plexMetadata(id: id, title: title, viewCount: viewCount, viewOffset: viewOffset),
);
await PlexApiCache.instance.pinForOffline(scope.cacheServerId, id);
}
class _FakeOfflineModeSource extends ChangeNotifier implements OfflineModeSource {
_FakeOfflineModeSource(this._isOffline);
bool _isOffline;
@override
bool get isOffline => _isOffline;
void setOffline(bool value) {
if (_isOffline == value) return;
_isOffline = value;
notifyListeners();
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase db;
late DownloadManagerService downloadManager;
late _DownloadOwnerSelectGate downloadOwnerSelectGate;
// Swappable per-test resolver behind the constructor-injected closure.
MediaClientResolver? testClientResolver;
setUp(() {
downloadOwnerSelectGate = _DownloadOwnerSelectGate();
db = AppDatabase.forTesting(NativeDatabase.memory().interceptWith(downloadOwnerSelectGate));
// PlexApiCache is a singleton accessed eagerly inside DownloadManagerService's
// constructor; reinitialize per test so each test sees the fresh in-memory DB.
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
testClientResolver = null;
downloadManager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (serverId, {clientScopeId}) => testClientResolver?.call(serverId, clientScopeId: clientScopeId),
);
// recoveryFuture is `late final` and would otherwise be unset; we never
// exercise the recovery path in these tests but the field must be safe
// to await. Set to a completed future.
downloadManager.recoveryFuture = Future<void>.value();
});
tearDown(() async {
downloadManager.dispose();
await db.close();
});
group('DownloadManagerService — platform support', () {
test('disables downloads only for tvOS builds', () {
expect(DownloadManagerService.downloadsSupportedFor(tvosBuild: true), isFalse);
expect(DownloadManagerService.downloadsSupportedFor(tvosBuild: false), isTrue);
});
test('recovery is a no-op when downloads are unsupported', () async {
final unsupportedManager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (serverId, {clientScopeId}) => null,
downloadsSupportedOverride: false,
);
await unsupportedManager.recoverInterruptedDownloads();
unsupportedManager.dispose();
});
});
group('DownloadProvider — download location coordinator', () {
test('set and reset delegate to the manager-owned ordered transition', () async {
DownloadLocationSnapshot location = (path: null, type: null);
final events = <String>[];
final coordinator = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (_, {clientScopeId}) => null,
downloadsSupportedOverride: false,
downloadLocationReader: () => location,
downloadPathWriter: (value) async {
events.add('path:$value');
location = (path: value, type: location.type);
},
downloadPathTypeWriter: (value) async {
events.add('type:$value');
location = (path: location.path, type: value);
},
downloadStorageRefresher: () async {
events.add('refresh');
},
)..recoveryFuture = Future<void>.value();
final provider = DownloadProvider.forTesting(downloadManager: coordinator, database: db);
await provider.ensureInitialized();
await provider.setDownloadLocation(path: '/downloads', pathType: 'file');
await provider.resetDownloadLocation();
expect(events, ['path:/downloads', 'type:file', 'refresh', 'path:null', 'type:null', 'refresh']);
expect(location, (path: null, type: null));
provider.dispose();
coordinator.dispose();
});
});
group('DownloadProvider — initial state', () {
test('starts with empty downloads/metadata maps and no sync rules', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
expect(p.downloads, isEmpty);
expect(p.metadata, isEmpty);
expect(p.syncRules, isEmpty);
expect(p.downloadedShows, isEmpty);
expect(p.downloadedMovies, isEmpty);
expect(p.getMetadata('srv:none'), isNull);
expect(p.getProgress('srv:none'), isNull);
expect(p.isDownloaded('srv:none'), isFalse);
expect(p.isDownloading('srv:none'), isFalse);
expect(p.isQueued('srv:none'), isFalse);
expect(p.isQueueing('srv:none'), isFalse);
expect(p.hasSyncRule('srv:none'), isFalse);
expect(p.getSyncRule('srv:none'), isNull);
p.dispose();
});
test('downloads / metadata getters return unmodifiable views', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
expect(() => p.downloads.clear(), throwsUnsupportedError);
expect(() => p.metadata.clear(), throwsUnsupportedError);
expect(() => p.syncRules.clear(), throwsUnsupportedError);
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();
});
test('profile switch clears visible ownership and rules before starting the reload', () async {
const globalKey = 'srv:owned-a';
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'owned-a',
globalKey: globalKey,
type: 'movie',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(profileId: 'test-profile', globalKey: globalKey);
await db.insertSyncRule(
profileId: 'test-profile',
serverId: ServerId('srv'),
ratingKey: 'show-a',
globalKey: 'test-profile|srv:show-a',
targetType: 'show',
episodeCount: 1,
);
final provider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await provider.ensureInitialized();
provider.debugSeedState(
downloads: {globalKey: const DownloadProgress(globalKey: globalKey, status: DownloadStatus.completed)},
ownedDownloadKeys: {globalKey},
);
var notifications = 0;
provider.addListener(() => notifications++);
provider.setActiveProfileId('profile-b');
expect(provider.downloads, isEmpty);
expect(provider.syncRules, isEmpty);
expect(notifications, 1);
await provider.debugWaitForProfileScopedReload();
provider.dispose();
});
});
group('DownloadProvider — local file selection', () {
test('falls back to media index when caller has no source id', () async {
const globalKey = 'srv:movie-1';
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'movie-1',
globalKey: globalKey,
type: 'movie',
status: DownloadStatus.completed.index,
mediaIndex: 0,
mediaSourceId: 'source-a',
);
await db.updateVideoFilePath(globalKey, 'content://offline/movie-1-v1');
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(ownedDownloadKeys: {globalKey});
expect(await p.getVideoFilePath(globalKey, mediaIndex: 1), isNull);
expect(await p.getVideoFilePath(globalKey, mediaIndex: 0), 'content://offline/movie-1-v1');
p.dispose();
});
test('getCompletedDownload exposes the downloaded version for owned completed rows', () async {
const globalKey = 'srv:movie-1';
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'movie-1',
globalKey: globalKey,
type: 'movie',
status: DownloadStatus.completed.index,
mediaIndex: 1,
mediaSourceId: 'source-b',
);
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(ownedDownloadKeys: {globalKey});
final row = await p.getCompletedDownload(globalKey);
expect(row, isNotNull);
expect(row!.mediaIndex, 1);
expect(row.mediaSourceId, 'source-b');
p.dispose();
});
test('getCompletedDownload returns null for unowned or incomplete rows', () async {
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'unowned',
globalKey: 'srv:unowned',
type: 'movie',
status: DownloadStatus.completed.index,
);
// Owned by another profile — otherwise legacy adoption claims fully
// ownerless rows for the active profile during initialization.
await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv:unowned');
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'partial',
globalKey: 'srv:partial',
type: 'movie',
status: DownloadStatus.downloading.index,
);
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(ownedDownloadKeys: {'srv:partial'});
expect(await p.getCompletedDownload('srv:unowned'), isNull);
expect(await p.getCompletedDownload('srv:partial'), isNull);
p.dispose();
});
});
group('DownloadProvider — sync rule CRUD', () {
test('createSyncRule inserts into the database and updates the in-memory map', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
var notified = 0;
p.addListener(() => notified++);
await p.createSyncRule(
serverId: ServerId('srv'),
ratingKey: '10',
targetType: 'show',
episodeCount: 5,
includeSpecials: false,
);
final ruleKey = p.syncRuleKeyFor(ServerId('srv'), '10');
expect(p.hasSyncRule(ruleKey), isTrue);
final rule = p.getSyncRule(ruleKey);
expect(rule, isNotNull);
expect(rule!.profileId, 'test-profile');
expect(rule.targetType, 'show');
expect(rule.episodeCount, 5);
expect(rule.enabled, isTrue);
expect(rule.downloadFilter, 'unwatched'); // default
expect(rule.includeSpecials, isFalse);
// Database state matches in-memory state.
final dbRule = await db.getSyncRule(ruleKey);
expect(dbRule, isNotNull);
expect(dbRule!.targetType, 'show');
expect(dbRule.includeSpecials, isFalse);
// createSyncRule notifies once on success.
expect(notified, 1);
p.dispose();
});
test('updateSyncRuleCount mutates rule and notifies', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '10', targetType: 'show', episodeCount: 5);
final ruleKey = p.syncRuleKeyFor(ServerId('srv'), '10');
var notified = 0;
p.addListener(() => notified++);
await p.updateSyncRuleCount(ruleKey, 12);
expect(p.getSyncRule(ruleKey)!.episodeCount, 12);
expect((await db.getSyncRule(ruleKey))!.episodeCount, 12);
expect(notified, 1);
p.dispose();
});
test('updateSyncRuleFilter mutates filter and notifies', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '10', targetType: 'collection', episodeCount: 0);
final ruleKey = p.syncRuleKeyFor(ServerId('srv'), '10');
var notified = 0;
p.addListener(() => notified++);
await p.updateSyncRuleFilter(ruleKey, 'all');
expect(p.getSyncRule(ruleKey)!.downloadFilter, 'all');
expect(notified, 1);
p.dispose();
});
test('setSyncRuleEnabled toggles enabled flag', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '10', targetType: 'show', episodeCount: 5);
final ruleKey = p.syncRuleKeyFor(ServerId('srv'), '10');
expect(p.getSyncRule(ruleKey)!.enabled, isTrue);
await p.setSyncRuleEnabled(ruleKey, false);
expect(p.getSyncRule(ruleKey)!.enabled, isFalse);
expect((await db.getSyncRule(ruleKey))!.enabled, isFalse);
await p.setSyncRuleEnabled(ruleKey, true);
expect(p.getSyncRule(ruleKey)!.enabled, isTrue);
p.dispose();
});
test('deleteSyncRule removes rule from db and memory and notifies', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '10', targetType: 'show', episodeCount: 5);
await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '11', targetType: 'show', episodeCount: 5);
final ruleKey10 = p.syncRuleKeyFor(ServerId('srv'), '10');
final ruleKey11 = p.syncRuleKeyFor(ServerId('srv'), '11');
expect(p.syncRules, hasLength(2));
var notified = 0;
p.addListener(() => notified++);
await p.deleteSyncRule(ruleKey10);
expect(p.hasSyncRule(ruleKey10), isFalse);
expect(p.hasSyncRule(ruleKey11), isTrue);
expect(p.syncRules, hasLength(1));
expect(await db.getSyncRule(ruleKey10), isNull);
expect(notified, 1);
p.dispose();
});
test('deleteSyncRule releases targetMetadata when no download holds it', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
// Collection rule with stashed metadata (the "no underlying episode
// download to populate _metadata" case from createSyncRule's docs).
final target = testMediaItem(
id: '20',
backend: MediaBackend.plex,
kind: MediaKind.collection,
title: 'My Collection',
serverId: ServerId('srv'),
);
await p.createSyncRule(
serverId: ServerId('srv'),
ratingKey: '20',
targetType: 'collection',
episodeCount: 0,
targetMetadata: target,
);
expect(p.getMetadata('srv:20'), isNotNull, reason: 'targetMetadata should be stashed');
await p.deleteSyncRule(p.syncRuleKeyFor(ServerId('srv'), '20'));
expect(p.getMetadata('srv:20'), isNull, reason: 'orphan metadata should be released');
p.dispose();
});
test('deleteSyncRule preserves metadata still referenced by a download', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
final target = testMediaItem(
id: '30',
backend: MediaBackend.plex,
kind: MediaKind.show,
title: 'A Show',
serverId: ServerId('srv'),
);
await p.createSyncRule(
serverId: ServerId('srv'),
ratingKey: '30',
targetType: 'show',
episodeCount: 5,
targetMetadata: target,
);
// Simulate an active/queued download under the same key — metadata is
// still load-bearing and must not be evicted by deleteSyncRule.
p.debugSeedState(
downloads: {'srv:30': const DownloadProgress(globalKey: 'srv:30', status: DownloadStatus.queued)},
);
await p.deleteSyncRule(p.syncRuleKeyFor(ServerId('srv'), '30'));
expect(p.getMetadata('srv:30'), isNotNull, reason: 'metadata is still in use by the download');
p.dispose();
});
test('watch events target active-profile parent sync rules', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
final keys = p.syncRuleKeysForWatchEvent(
WatchStateEvent(
itemId: 'episode-1',
serverId: ServerId('jf-machine'),
cacheServerId: 'jf-machine/user-a',
changeType: WatchStateChangeType.watched,
parentChain: const ['season-1', 'show-1'],
mediaType: 'episode',
isNowWatched: true,
),
);
expect(
keys,
containsAll(<String>{
'test-profile|jf-machine:episode-1',
'test-profile|jf-machine:season-1',
'test-profile|jf-machine:show-1',
}),
);
expect(keys, hasLength(3));
p.dispose();
});
test('forTesting load reads pre-existing sync rules from database', () async {
// Pre-seed the database with a rule before the provider exists.
await db.insertSyncRule(
profileId: 'test-profile',
serverId: ServerId('srv'),
ratingKey: '99',
globalKey: 'test-profile|srv:99',
targetType: 'show',
episodeCount: 7,
);
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
expect(p.hasSyncRule('test-profile|srv:99'), isTrue);
expect(p.getSyncRule('test-profile|srv:99')!.episodeCount, 7);
p.dispose();
});
});
group('DownloadProvider — profile-scoped download ownership', () {
final movie = testMediaItem(
id: '1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Owned Movie',
serverId: ServerId('srv'),
);
test('queueDownload is a no-op when downloads are unsupported', () async {
final unsupportedManager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (serverId, {clientScopeId}) => null,
downloadsSupportedOverride: false,
)..recoveryFuture = Future<void>.value();
final p = DownloadProvider.forTesting(downloadManager: unsupportedManager, database: db);
await p.ensureInitialized();
final queued = await p.queueDownload(movie, _ScopedTestClient(serverId: ServerId('srv'), scopedServerId: 'srv'));
expect(queued, 0);
expect(p.downloads, isEmpty);
expect(await db.getDownloadedMedia('srv:1'), isNull);
p.dispose();
unsupportedManager.dispose();
});
test('download getters only expose active-profile owned physical rows', () async {
await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:1');
final p = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-a',
);
await p.ensureInitialized();
p.debugSeedState(
downloads: {
'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed),
'srv:2': const DownloadProgress(globalKey: 'srv:2', status: DownloadStatus.completed),
},
metadata: {
'srv:1': movie,
'srv:2': movie.copyWith(id: '2', title: 'Other Profile Movie'),
},
ownedDownloadKeys: const {},
);
expect(p.downloads.keys, ['srv:1']);
expect(p.getProgress('srv:1'), isNotNull);
expect(p.getProgress('srv:2'), isNull);
expect(p.downloadedMovies.map((m) => m.id), ['1']);
p.dispose();
});
test('queueDownload claims an existing physical download instead of duplicating it', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed)},
metadata: {'srv:1': movie},
ownedDownloadKeys: const {},
);
final count = await p.queueDownload(movie, _ThrowingClient());
expect(count, 1);
expect(p.downloads.keys, ['srv:1']);
expect(await db.getDownloadOwnerKeysForProfile('test-profile'), {'srv:1'});
p.dispose();
});
test('queueDownload applies the client server id before checking existing downloads', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed)},
metadata: {'srv:1': movie},
ownedDownloadKeys: const {},
);
final count = await p.queueDownload(
movie.copyWith(serverId: null),
_ScopedTestClient(serverId: ServerId('srv'), scopedServerId: 'srv'),
);
expect(count, 1);
expect(p.downloads.keys, ['srv:1']);
expect(p.downloads, isNot(contains('1')));
expect(await db.getDownloadOwnerKeysForProfile('test-profile'), {'srv:1'});
p.dispose();
});
test('queueDownload expands an album into its tracks via fetchPlayableDescendants', () async {
final album = testMediaItem(
id: 'album-1',
backend: MediaBackend.plex,
kind: MediaKind.album,
title: 'Album',
serverId: ServerId('srv'),
);
MediaItem track(String id) => testMediaItem(
id: id,
backend: MediaBackend.plex,
kind: MediaKind.track,
title: id,
parentId: 'album-1',
serverId: ServerId('srv'),
);
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
// Physical rows already exist (shared/unowned) so each expanded track
// takes the claim-existing early path — no manager/network needed.
p.debugSeedState(
downloads: {
'srv:t1': const DownloadProgress(globalKey: 'srv:t1', status: DownloadStatus.completed),
'srv:t2': const DownloadProgress(globalKey: 'srv:t2', status: DownloadStatus.completed),
},
metadata: {'srv:t1': track('t1'), 'srv:t2': track('t2')},
ownedDownloadKeys: const {},
);
final client = _MusicExpansionClient([track('t1'), track('t2')]);
final count = await p.queueDownload(album, client);
expect(count, 2);
expect(client.fetchPlayableDescendantsCalls, ['album-1']);
expect(await db.getDownloadOwnerKeysForProfile('test-profile'), {'srv:t1', 'srv:t2'});
p.dispose();
});
test('container queue ownership remains claimed until expansion finishes', () async {
final album = testMediaItem(
id: 'album-1',
backend: MediaBackend.plex,
kind: MediaKind.album,
title: 'Album',
serverId: ServerId('srv'),
);
final track = testMediaItem(
id: 't1',
backend: MediaBackend.plex,
kind: MediaKind.track,
title: 'Track',
parentId: album.id,
serverId: ServerId('srv'),
);
final provider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await provider.ensureInitialized();
provider.debugSeedState(
downloads: {track.globalKey: DownloadProgress(globalKey: track.globalKey, status: DownloadStatus.completed)},
metadata: {track.globalKey: track},
ownedDownloadKeys: const {},
);
final started = Completer<void>();
final release = Completer<void>();
final client = _MusicExpansionClient([track], gate: release.future, started: started);
final first = provider.queueDownload(album, client);
await started.future;
expect(await provider.queueDownload(album, client), 0);
expect(client.fetchPlayableDescendantsCalls, ['album-1']);
release.complete();
expect(await first, 1);
expect(client.fetchPlayableDescendantsCalls, ['album-1']);
provider.dispose();
});
test('profile switch invalidates container expansion before it can claim the new profile', () async {
final album = testMediaItem(
id: 'album-1',
backend: MediaBackend.plex,
kind: MediaKind.album,
title: 'Profile A Album',
serverId: ServerId('srv'),
);
final track = testMediaItem(
id: 'track-1',
backend: MediaBackend.plex,
kind: MediaKind.track,
title: 'Profile A Track',
parentId: album.id,
serverId: ServerId('srv'),
);
final provider = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-a',
);
addTearDown(provider.dispose);
await provider.ensureInitialized();
provider.debugSeedState(
downloads: {track.globalKey: DownloadProgress(globalKey: track.globalKey, status: DownloadStatus.completed)},
metadata: {track.globalKey: track},
ownedDownloadKeys: const {},
);
final expansionStarted = Completer<void>();
final releaseExpansion = Completer<void>();
final queueFuture = provider.queueDownload(
album,
_MusicExpansionClient([track], gate: releaseExpansion.future, started: expansionStarted),
);
await expansionStarted.future;
provider.setActiveProfileId('profile-b');
expect(provider.isQueueing(album.globalKey), isFalse);
releaseExpansion.complete();
expect(await queueFuture, 0);
expect(await db.getDownloadOwnerKeysForProfile('profile-a'), isEmpty);
expect(await db.getDownloadOwnerKeysForProfile('profile-b'), isEmpty);
expect(await db.getAllDownloadedMetadata(), isEmpty);
expect(provider.getMetadata(album.globalKey), isNull);
expect(provider.getMetadata(track.globalKey), isNull);
expect(provider.downloads, isEmpty);
expect(provider.isQueueing(album.globalKey), isFalse);
expect(await db.getSyncRules(profileId: 'profile-b'), isEmpty);
});
test('stale queue cleanup does not remove the new profile generation key', () async {
final album = testMediaItem(
id: 'album-1',
backend: MediaBackend.plex,
kind: MediaKind.album,
title: 'Album',
serverId: ServerId('srv'),
);
final provider = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-a',
);
addTearDown(provider.dispose);
await provider.ensureInitialized();
final firstStarted = Completer<void>();
final releaseFirst = Completer<void>();
final firstQueue = provider.queueDownload(
album,
_MusicExpansionClient(const [], gate: releaseFirst.future, started: firstStarted),
);
await firstStarted.future;
provider.setActiveProfileId('profile-b');
final secondStarted = Completer<void>();
final releaseSecond = Completer<void>();
final secondQueue = provider.queueDownload(
album,
_MusicExpansionClient(const [], gate: releaseSecond.future, started: secondStarted),
);
await secondStarted.future;
expect(provider.isQueueing(album.globalKey), isTrue);
releaseFirst.complete();
expect(await firstQueue, 0);
expect(provider.isQueueing(album.globalKey), isTrue);
releaseSecond.complete();
expect(await secondQueue, 0);
expect(provider.isQueueing(album.globalKey), isFalse);
});
test('deleting an album emits one provider notification for all tracks', () async {
MediaItem track(String id) => testMediaItem(
id: id,
backend: MediaBackend.plex,
kind: MediaKind.track,
title: id,
parentId: 'album-1',
serverId: ServerId('srv'),
);
final album = testMediaItem(
id: 'album-1',
backend: MediaBackend.plex,
kind: MediaKind.album,
title: 'Album',
serverId: ServerId('srv'),
);
for (final id in ['t1', 't2']) {
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: id,
globalKey: 'srv:$id',
type: 'track',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'srv:$id');
}
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {
'srv:t1': const DownloadProgress(globalKey: 'srv:t1', status: DownloadStatus.completed),
'srv:t2': const DownloadProgress(globalKey: 'srv:t2', status: DownloadStatus.completed),
},
metadata: {'srv:album-1': album, 'srv:t1': track('t1'), 'srv:t2': track('t2')},
ownedDownloadKeys: {'srv:t1', 'srv:t2'},
);
var notifications = 0;
p.addListener(() => notifications++);
final deletionEvents = <DeletionEvent>[];
final deletionSubscription = DeletionNotifier().stream.listen(deletionEvents.add);
addTearDown(deletionSubscription.cancel);
await p.deleteDownload(album.globalKey);
await pumpEventQueue();
expect(notifications, 1);
expect(p.downloads, isEmpty);
expect(deletionEvents.map((event) => event.itemId), unorderedEquals(['t1', 't2', 'album-1']));
p.dispose();
});
test('album aggregates, downloadedAlbums, and per-album track order come from track downloads', () async {
MediaItem track(String id, {required int disc, required int number}) => testMediaItem(
id: id,
backend: MediaBackend.plex,
kind: MediaKind.track,
title: id,
parentId: 'album-1',
parentTitle: 'Album',
grandparentId: 'artist-1',
grandparentTitle: 'Artist',
parentIndex: disc,
index: number,
serverId: ServerId('srv'),
);
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {
'srv:t1': const DownloadProgress(globalKey: 'srv:t1', status: DownloadStatus.completed),
'srv:t2': const DownloadProgress(globalKey: 'srv:t2', status: DownloadStatus.completed),
},
// Seeded out of disc/track order on purpose.
metadata: {
'srv:t1': track('t1', disc: 2, number: 1),
'srv:t2': track('t2', disc: 1, number: 2),
'srv:album-1': testMediaItem(
id: 'album-1',
backend: MediaBackend.plex,
kind: MediaKind.album,
title: 'Album',
parentId: 'artist-1',
parentTitle: 'Artist',
serverId: ServerId('srv'),
),
},
);
expect(p.getProgress('srv:album-1')?.status, DownloadStatus.completed);
expect(p.isDownloaded('srv:album-1'), isTrue);
expect(p.downloadedAlbums.map((a) => a.id), ['album-1']);
expect(p.getDownloadedTracksForAlbum('album-1').map((item) => item.id), ['t2', 't1']);
p.dispose();
});
test('queueDownload leaves paused downloads paused instead of re-queueing them', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.paused)},
metadata: {'srv:1': movie},
);
final count = await p.queueDownload(movie, _ThrowingClient());
expect(count, 0);
expect(p.getProgress('srv:1')?.status, DownloadStatus.paused);
p.dispose();
});
test('deleteDownload removes only active-profile ownership when another owner remains', () async {
await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'srv:1');
await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv:1');
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed)},
metadata: {'srv:1': movie},
ownedDownloadKeys: const {},
);
await p.deleteDownload('srv:1');
expect(p.downloads, isEmpty);
expect(await db.getDownloadOwnerKeysForProfile('test-profile'), isEmpty);
expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {'srv:1'});
p.dispose();
});
void registerProfileSwitchSharedOwnerTest(
String operationName,
Future<void> Function(DownloadProvider provider, String globalKey) operation,
DownloadStatus status,
) {
test(
'$operationName releases the initiating owner when the active profile switches during owner lookup',
() async {
const globalKey = 'srv:profile-switch';
const itemId = 'profile-switch';
final serverId = ServerId('srv');
final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a');
final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b');
await _insertPlexConnection(db, serverId);
await _insertProfile(db, 'profile-a');
await _insertProfile(db, 'profile-b');
await db.insertDownload(
serverId: serverId,
clientScopeId: scopeA,
ratingKey: itemId,
globalKey: globalKey,
type: 'movie',
status: status.index,
);
await db.addDownloadOwner(
profileId: 'profile-a',
globalKey: globalKey,
backendId: MediaBackend.plex.id,
clientScopeId: scopeA,
);
await db.addDownloadOwner(
profileId: 'profile-b',
globalKey: globalKey,
backendId: MediaBackend.plex.id,
clientScopeId: scopeB,
);
await _putPinnedPlexMetadata(scopeA, id: itemId, title: 'Profile A cache', viewCount: 0, viewOffset: 1000);
await _putPinnedPlexMetadata(scopeB, id: itemId, title: 'Profile B cache', viewCount: 1, viewOffset: 0);
final provider = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-a',
);
addTearDown(provider.dispose);
await provider.ensureInitialized();
provider.debugSeedState(
downloads: {globalKey: DownloadProgress(globalKey: globalKey, status: status)},
metadata: {globalKey: movie.copyWith(id: itemId, title: 'Profile A cache')},
);
downloadOwnerSelectGate.arm(globalKey);
final deletion = operation(provider, globalKey);
await downloadOwnerSelectGate.started;
provider.setActiveProfileId('profile-b');
await provider.debugWaitForProfileScopedReload();
downloadOwnerSelectGate.release();
await deletion;
expect(await db.getDownloadOwnerKeysForProfile('profile-a'), isEmpty);
expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {globalKey});
expect(provider.downloads.keys, [globalKey]);
expect(provider.getMetadata(globalKey)?.title, 'Profile B cache');
expect((await PlexApiCache.instance.getMetadata(scopeB.cacheServerId, itemId))?.title, 'Profile B cache');
expect(await PlexApiCache.instance.isPinnedRatingKey(scopeB.cacheServerId, itemId), isTrue);
},
);
}
registerProfileSwitchSharedOwnerTest(
'deleteDownload',
(provider, globalKey) => provider.deleteDownload(globalKey),
DownloadStatus.completed,
);
registerProfileSwitchSharedOwnerTest(
'cancelDownload',
(provider, globalKey) => provider.cancelDownload(globalKey),
DownloadStatus.queued,
);
void registerProfileSwitchFinalOwnerTest(
String operationName,
Future<void> Function(DownloadProvider provider, String globalKey) operation,
DownloadStatus status,
) {
test('$operationName releases its final owner after a profile switch during physical deletion', () async {
const deletingKey = 'srv:final-owner';
const profileBKey = 'srv:profile-b';
await db.addDownloadOwner(profileId: 'profile-a', globalKey: deletingKey);
await db.addDownloadOwner(profileId: 'profile-b', globalKey: profileBKey);
final gatedManager = _GatedPhysicalDeletionManager(db);
addTearDown(gatedManager.dispose);
final provider = DownloadProvider.forTesting(
downloadManager: gatedManager,
database: db,
activeProfileId: 'profile-a',
);
addTearDown(provider.dispose);
await provider.ensureInitialized();
provider.debugSeedState(
downloads: {
deletingKey: DownloadProgress(globalKey: deletingKey, status: status),
profileBKey: const DownloadProgress(globalKey: profileBKey, status: DownloadStatus.completed),
},
metadata: {
deletingKey: movie.copyWith(id: 'final-owner', title: 'Profile A cache'),
profileBKey: movie.copyWith(id: 'profile-b', title: 'Profile B cache'),
},
ownedDownloadKeys: {deletingKey},
);
final deletion = operation(provider, deletingKey);
await gatedManager.started.future;
provider.setActiveProfileId('profile-b');
await provider.debugWaitForProfileScopedReload();
provider.debugSeedState(
metadata: {profileBKey: movie.copyWith(id: 'profile-b', title: 'Profile B cache')},
);
gatedManager.release.complete();
await deletion;
expect(await db.getDownloadOwnerKeysForProfile('profile-a'), isEmpty);
expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {profileBKey});
expect(provider.downloads.keys, [profileBKey]);
expect(provider.getMetadata(profileBKey)?.title, 'Profile B cache');
});
}
registerProfileSwitchFinalOwnerTest(
'deleteDownload',
(provider, globalKey) => provider.deleteDownload(globalKey),
DownloadStatus.completed,
);
registerProfileSwitchFinalOwnerTest(
'cancelDownload',
(provider, globalKey) => provider.cancelDownload(globalKey),
DownloadStatus.queued,
);
test('deleteDownload is a no-op for unowned physical rows', () async {
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: '1',
globalKey: 'srv:1',
type: 'movie',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv:1');
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed)},
metadata: {'srv:1': movie},
ownedDownloadKeys: const {},
);
await p.deleteDownload('srv:1');
expect(await db.getDownloadedMedia('srv:1'), isNotNull);
expect(p.getProgress('srv:1'), isNull);
p.dispose();
});
test('cancelDownload is a no-op for unowned physical rows', () async {
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: '1',
globalKey: 'srv:1',
type: 'movie',
status: DownloadStatus.queued.index,
);
await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv:1');
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.queued)},
metadata: {'srv:1': movie},
ownedDownloadKeys: const {},
);
await p.cancelDownload('srv:1');
expect(await db.getDownloadedMedia('srv:1'), isNotNull);
expect(p.getProgress('srv:1'), isNull);
p.dispose();
});
test(
'releasing a queued shared owner rebinds the physical row and preserves its queue intent for the survivor',
() async {
const globalKey = 'srv:shared-queued';
const itemId = 'shared-queued';
final serverId = ServerId('srv');
final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a');
final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b');
await _insertPlexConnection(db, serverId);
await _insertProfile(db, 'profile-a');
await _insertProfile(db, 'profile-b');
await db.insertDownload(
serverId: serverId,
clientScopeId: scopeA,
ratingKey: itemId,
globalKey: globalKey,
type: 'movie',
status: DownloadStatus.queued.index,
);
await db.addToQueue(mediaGlobalKey: globalKey, priority: 37);
await db.addDownloadOwner(
profileId: 'profile-a',
globalKey: globalKey,
backendId: MediaBackend.plex.id,
clientScopeId: scopeA,
);
await db.addDownloadOwner(
profileId: 'profile-b',
globalKey: globalKey,
backendId: MediaBackend.plex.id,
clientScopeId: scopeB,
);
await _putPinnedPlexMetadata(scopeB, id: itemId, title: 'Profile B snapshot', viewCount: 0, viewOffset: 0);
final itemB = movie.copyWith(id: itemId, title: 'Profile B snapshot');
final clientB = _ScopedTestClient(
serverId: serverId,
scopedServerId: scopeB,
clientBackend: MediaBackend.plex,
fetchItemHandler: (_) async => itemB,
);
final resolvedScopes = <String?>[];
final queueResumed = Completer<MediaServerClient>();
final scopedManager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (resolvedServerId, {clientScopeId}) {
resolvedScopes.add(clientScopeId);
return resolvedServerId == serverId && clientScopeId == scopeB ? clientB : null;
},
downloadsSupportedOverride: true,
queueProcessorOverride: (client) async {
if (!queueResumed.isCompleted) queueResumed.complete(client);
},
)..recoveryFuture = Future<void>.value();
addTearDown(scopedManager.dispose);
final provider = DownloadProvider.forTesting(
downloadManager: scopedManager,
database: db,
activeProfileId: 'profile-b',
);
addTearDown(provider.dispose);
await provider.ensureInitialized();
provider.debugSeedState(
downloads: {globalKey: const DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued)},
metadata: {globalKey: itemB},
ownedDownloadKeys: {globalKey},
);
await provider.releaseDownloadsForProfileServers('profile-a', {'srv'});
final rebound = await db.getDownloadedMedia(globalKey);
expect(rebound?.clientScopeId, scopeB);
expect(await db.getDownloadOwnerKeysForProfile('profile-a'), isEmpty);
expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {globalKey});
expect(provider.isQueued(globalKey), isTrue);
resolvedScopes.clear();
expect((await scopedManager.lookupMetadata(serverId, itemId))?.title, 'Profile B snapshot');
expect(resolvedScopes, contains(scopeB));
expect(resolvedScopes, isNot(contains(scopeA)));
expect(
await provider.queueDownload(itemB, clientB),
0,
reason: 'the surviving owner must not duplicate the row',
);
var queueRows = await db.select(db.downloadQueue).get();
expect(queueRows, hasLength(1));
expect(queueRows.single.mediaGlobalKey, globalKey);
expect(queueRows.single.priority, 37);
expect((await db.getNextQueueItem())?.mediaGlobalKey, globalKey);
scopedManager.resumeQueuedDownloads(clientB);
expect(await queueResumed.future.timeout(const Duration(seconds: 2)), same(clientB));
queueRows = await db.select(db.downloadQueue).get();
expect(queueRows, hasLength(1));
expect(queueRows.single.priority, 37);
},
);
test('deleting one owner does not migrate a completed shared physical row', () async {
const globalKey = 'srv:shared-completed';
final serverId = ServerId('srv');
final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a');
final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b');
await _insertPlexConnection(db, serverId);
await _insertProfile(db, 'profile-a');
await _insertProfile(db, 'profile-b');
await db.insertDownload(
serverId: serverId,
clientScopeId: scopeA,
ratingKey: 'shared-completed',
globalKey: globalKey,
type: 'movie',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(
profileId: 'profile-a',
globalKey: globalKey,
backendId: MediaBackend.plex.id,
clientScopeId: scopeA,
);
await db.addDownloadOwner(
profileId: 'profile-b',
globalKey: globalKey,
backendId: MediaBackend.plex.id,
clientScopeId: scopeB,
);
final provider = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-b',
);
addTearDown(provider.dispose);
await provider.ensureInitialized();
await provider.deleteDownloadsForProfile('profile-a');
final completed = await db.getDownloadedMedia(globalKey);
expect(completed, isNotNull);
expect(completed?.clientScopeId, scopeA);
expect(await db.getDownloadOwnerKeysForProfile('profile-a'), isEmpty);
expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {globalKey});
});
test('releaseDownloadsForProfileServers removes only downloads from the removed connection', () async {
await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'srv:1');
await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv:1');
await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'other:2');
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {
'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed),
'other:2': const DownloadProgress(globalKey: 'other:2', status: DownloadStatus.completed),
},
metadata: {
'srv:1': movie,
'other:2': movie.copyWith(id: '2', serverId: ServerId('other')),
},
);
await p.releaseDownloadsForProfileServers('test-profile', {'srv'});
expect(await db.getDownloadOwnerKeysForProfile('test-profile'), {'other:2'});
expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {'srv:1'});
expect(p.downloads.keys, ['other:2']);
p.dispose();
});
});
group('DownloadProvider — scoped Jellyfin metadata', () {
Future<void> insertJellyfinConnection(String userId) {
return db
.into(db.connections)
.insert(
ConnectionsCompanion.insert(
id: 'jf-machine/$userId',
kind: 'jellyfin',
displayName: 'Shared JF',
configJson: jsonEncode({
'baseUrl': 'https://jf.example.com',
'serverName': 'Shared JF',
'serverMachineId': 'jf-machine',
'userId': userId,
'userName': userId,
'accessToken': 'token-$userId',
'deviceId': 'device',
}),
createdAt: 0,
),
);
}
Future<void> putPinnedItem(String scopeId, String userId, String itemId, Map<String, Object?> data) async {
await JellyfinApiCache.instance.put(ServerId(scopeId), '/Users/$userId/Items/$itemId', data);
await JellyfinApiCache.instance.pinForOffline(ServerId(scopeId), itemId);
}
test('loads parent metadata from the exact active Jellyfin user scope', () async {
await insertJellyfinConnection('user-a');
await insertJellyfinConnection('user-b');
await putPinnedItem('jf-machine/user-a', 'user-a', 'show-1', {
'Id': 'show-1',
'Type': 'Series',
'Name': 'Scoped Show A',
'RecursiveItemCount': 1,
'UserData': {'UnplayedItemCount': 1},
});
await putPinnedItem('jf-machine/user-a', 'user-a', 'season-1', {
'Id': 'season-1',
'Type': 'Season',
'Name': 'Season A',
'SeriesId': 'show-1',
'SeriesName': 'Scoped Show A',
'UserData': {'UnplayedItemCount': 1},
});
await putPinnedItem('jf-machine/user-a', 'user-a', 'ep-1', {
'Id': 'ep-1',
'Type': 'Episode',
'Name': 'Episode A',
'SeriesId': 'show-1',
'SeriesName': 'Scoped Show A',
'SeasonId': 'season-1',
'SeasonName': 'Season A',
'UserData': {'PlayCount': 0},
});
await putPinnedItem('jf-machine/user-b', 'user-b', 'show-1', {
'Id': 'show-1',
'Type': 'Series',
'Name': 'Wrong User Show',
'RecursiveItemCount': 1,
'UserData': {'UnplayedItemCount': 0},
});
await db.insertDownload(
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'ep-1',
globalKey: 'jf-machine:ep-1',
type: 'episode',
parentRatingKey: 'season-1',
grandparentRatingKey: 'show-1',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'jf-machine:ep-1');
testClientResolver = (serverId, {clientScopeId}) => serverId == 'jf-machine'
? _ScopedTestClient(serverId: ServerId('jf-machine'), scopedServerId: 'jf-machine/user-a')
: null;
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {
'jf-machine:ep-1': const DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed),
},
);
await p.refreshMetadataFromCache();
expect(p.getMetadata('jf-machine:ep-1')?.title, 'Episode A');
expect(p.getMetadata('jf-machine:show-1')?.title, 'Scoped Show A');
expect(p.getMetadata('jf-machine:season-1')?.title, 'Season A');
p.dispose();
});
test('refreshMetadataFromCache prefers the active Jellyfin user scope', () async {
await insertJellyfinConnection('user-a');
await insertJellyfinConnection('user-b');
await putPinnedItem('jf-machine/user-a', 'user-a', 'ep-1', {
'Id': 'ep-1',
'Type': 'Episode',
'Name': 'Wrong User Episode',
'SeriesId': 'show-1',
'SeasonId': 'season-1',
'UserData': {'PlayCount': 0},
});
await putPinnedItem('jf-machine/user-b', 'user-b', 'ep-1', {
'Id': 'ep-1',
'Type': 'Episode',
'Name': 'Active User Episode',
'SeriesId': 'show-1',
'SeasonId': 'season-1',
'UserData': {'PlayCount': 1, 'Played': true},
});
await db.insertDownload(
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'ep-1',
globalKey: 'jf-machine:ep-1',
type: 'episode',
parentRatingKey: 'season-1',
grandparentRatingKey: 'show-1',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'jf-machine:ep-1');
testClientResolver = (serverId, {clientScopeId}) {
if (serverId == 'jf-machine') {
return _ScopedTestClient(serverId: ServerId('jf-machine'), scopedServerId: 'jf-machine/user-b');
}
return null;
};
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {
'jf-machine:ep-1': const DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed),
},
);
await p.refreshMetadataFromCache();
expect(p.getMetadata('jf-machine:ep-1')?.title, 'Active User Episode');
expect(p.getMetadata('jf-machine:ep-1')?.isWatched, isTrue);
p.dispose();
});
test('refreshMetadataFromCache applies scoped Jellyfin offline watch overlay', () async {
await insertJellyfinConnection('user-a');
await insertJellyfinConnection('user-b');
await putPinnedItem('jf-machine/user-b', 'user-b', 'ep-1', {
'Id': 'ep-1',
'Type': 'Episode',
'Name': 'Active User Episode',
'SeriesId': 'show-1',
'SeasonId': 'season-1',
'UserData': {'PlayCount': 0},
});
await db.insertDownload(
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'ep-1',
globalKey: 'jf-machine:ep-1',
type: 'episode',
parentRatingKey: 'season-1',
grandparentRatingKey: 'show-1',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'jf-machine:ep-1');
await db.insertWatchAction(
profileId: 'test-profile',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-b',
ratingKey: 'ep-1',
actionType: 'watched',
);
testClientResolver = (serverId, {clientScopeId}) {
if (serverId == 'jf-machine') {
return _ScopedTestClient(serverId: ServerId('jf-machine'), scopedServerId: 'jf-machine/user-b');
}
return null;
};
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {
'jf-machine:ep-1': const DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed),
},
);
await p.refreshMetadataFromCache();
expect(p.getMetadata('jf-machine:ep-1')?.isWatched, isTrue);
p.dispose();
});
test('cold Jellyfin profile restores its persisted scoped queued watch action', () async {
await insertJellyfinConnection('user-a');
await insertJellyfinConnection('user-b');
await db
.into(db.profileConnections)
.insert(
ProfileConnectionsCompanion.insert(
profileId: 'test-profile',
connectionId: 'jf-machine/user-b',
userIdentifier: 'user-b',
),
);
await putPinnedItem('jf-machine/user-b', 'user-b', 'ep-1', {
'Id': 'ep-1',
'Type': 'Episode',
'Name': 'Offline User B Episode',
'SeriesId': 'show-1',
'SeasonId': 'season-1',
'UserData': {'PlayCount': 0},
});
await db.insertDownload(
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'ep-1',
globalKey: 'jf-machine:ep-1',
type: 'episode',
parentRatingKey: 'season-1',
grandparentRatingKey: 'show-1',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'jf-machine:ep-1');
await db.insertWatchAction(
profileId: 'test-profile',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-b',
ratingKey: 'ep-1',
actionType: 'watched',
);
final provider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await provider.ensureInitialized();
provider.debugSeedState(
downloads: {
'jf-machine:ep-1': const DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed),
},
);
await provider.refreshMetadataFromCache();
expect(provider.getMetadata('jf-machine:ep-1')?.title, 'Offline User B Episode');
expect(provider.getMetadata('jf-machine:ep-1')?.isWatched, isTrue);
provider.dispose();
});
test('offline watch hydration snapshots downloads before database awaits', () async {
final provider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await provider.ensureInitialized();
final item = testMediaItem(
id: '1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie',
serverId: ServerId('srv'),
viewCount: 0,
);
provider.debugSeedState(
downloads: {
'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed),
'srv:2': const DownloadProgress(globalKey: 'srv:2', status: DownloadStatus.completed),
},
metadata: {item.globalKey: item},
);
await db.insertWatchAction(
profileId: 'test-profile',
serverId: ServerId('srv'),
ratingKey: item.id,
actionType: 'watched',
);
scheduleMicrotask(() {
provider.debugSeedState(
downloads: {'srv:3': const DownloadProgress(globalKey: 'srv:3', status: DownloadStatus.completed)},
);
});
await provider.debugHydrateOfflineWatchOverlay();
expect(provider.getMetadata(item.globalKey)?.isWatched, isTrue);
provider.dispose();
});
test('profile switch discards an in-flight metadata refresh from the old scope', () async {
await insertJellyfinConnection('user-a');
await db.insertDownload(
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'movie-1',
globalKey: 'jf-machine:movie-1',
type: 'movie',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'jf-machine:movie-1');
await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'jf-machine:movie-1');
final fetchStarted = Completer<void>();
final releaseFetch = Completer<void>();
var activeClient = _ScopedTestClient(
serverId: ServerId('jf-machine'),
scopedServerId: 'jf-machine/user-a',
fetchItemHandler: (id) async {
fetchStarted.complete();
await releaseFetch.future;
return testMediaItem(
id: id,
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
title: 'Profile A',
serverId: ServerId('jf-machine'),
);
},
);
testClientResolver = (serverId, {clientScopeId}) => serverId == 'jf-machine' ? activeClient : null;
final p = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-a',
);
await p.ensureInitialized();
p.debugSeedState(
downloads: {
'jf-machine:movie-1': const DownloadProgress(
globalKey: 'jf-machine:movie-1',
status: DownloadStatus.completed,
),
},
);
final staleRefresh = p.refreshMetadataFromCache();
await fetchStarted.future;
p.setActiveProfileId('profile-b');
activeClient = _ScopedTestClient(
serverId: ServerId('jf-machine'),
scopedServerId: 'jf-machine/user-b',
fetchItemHandler: (id) async => testMediaItem(
id: id,
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
title: 'Profile B',
serverId: ServerId('jf-machine'),
),
);
releaseFetch.complete();
await staleRefresh;
expect(p.getMetadata('jf-machine:movie-1'), isNull);
if (p.getMetadata('jf-machine:movie-1')?.title != 'Profile B') {
final reloaded = Completer<void>();
void onReload() {
if (p.getMetadata('jf-machine:movie-1')?.title == 'Profile B' && !reloaded.isCompleted) {
reloaded.complete();
}
}
p.addListener(onReload);
await reloaded.future.timeout(const Duration(seconds: 2));
p.removeListener(onReload);
}
expect(p.getMetadata('jf-machine:movie-1')?.title, 'Profile B');
p.dispose();
});
test('shared Jellyfin cache scope survives until its final download owner is released', () async {
const globalKey = 'jf-machine:movie-1';
const scopeId = 'jf-machine/user-a';
await insertJellyfinConnection('user-a');
await _insertProfile(db, 'profile-a');
await _insertProfile(db, 'profile-b');
await db.insertDownload(
serverId: ServerId('jf-machine'),
clientScopeId: scopeId,
ratingKey: 'movie-1',
globalKey: globalKey,
type: 'movie',
status: DownloadStatus.completed.index,
);
for (final profileId in ['profile-a', 'profile-b']) {
await db.addDownloadOwner(
profileId: profileId,
globalKey: globalKey,
backendId: MediaBackend.jellyfin.id,
clientScopeId: scopeId,
);
}
await putPinnedItem(scopeId, 'user-a', 'movie-1', {
'Id': 'movie-1',
'Type': 'Movie',
'Name': 'Shared offline movie',
});
final segmentsEndpoint = JellyfinApiCache.mediaSegmentsEndpoint('movie-1');
await JellyfinApiCache.instance.put(ServerId(scopeId), segmentsEndpoint, {'Items': <Object?>[]});
await JellyfinApiCache.instance.pinForOffline(ServerId(scopeId), 'movie-1');
final provider = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-a',
);
addTearDown(provider.dispose);
await provider.ensureInitialized();
await provider.deleteDownloadsForProfile('profile-a');
expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {globalKey});
expect(
(await JellyfinApiCache.instance.getMetadata(ServerId(scopeId), 'movie-1'))?.title,
'Shared offline movie',
);
expect(await JellyfinApiCache.instance.get(ServerId(scopeId), segmentsEndpoint), isNotNull);
await provider.deleteDownloadsForProfile('profile-b');
expect(await JellyfinApiCache.instance.getMetadata(ServerId(scopeId), 'movie-1'), isNull);
expect(await JellyfinApiCache.instance.get(ServerId(scopeId), segmentsEndpoint), isNull);
});
});
group('DownloadProvider — scoped Plex metadata ownership', () {
const key = 'srv:123';
final serverId = ServerId('srv');
Future<void> seedPhysicalDownload({required Iterable<String> owners}) async {
await _insertPlexConnection(db, serverId);
for (final owner in owners) {
await _insertProfile(db, owner);
}
await db.insertDownload(
serverId: serverId,
clientScopeId: buildPlexProfileScopeId(serverId: serverId, profileId: owners.first),
ratingKey: '123',
globalKey: key,
type: 'movie',
status: DownloadStatus.completed.index,
);
for (final owner in owners) {
await db.addDownloadOwner(profileId: owner, globalKey: key);
}
}
Future<void> waitForProfileReload(DownloadProvider provider, String profileId, bool Function() isSettled) async {
final settled = Completer<void>();
void listener() {
if (isSettled() && !settled.isCompleted) settled.complete();
}
provider.addListener(listener);
provider.setActiveProfileId(profileId);
await settled.future.timeout(const Duration(seconds: 2));
provider.removeListener(listener);
}
test('offline profile switches select only exact Plex owner snapshots and preserve one physical row', () async {
await seedPhysicalDownload(owners: const ['profile-a', 'profile-b']);
final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a');
final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b');
await _putPinnedPlexMetadata(scopeA, id: '123', title: 'Profile A snapshot', viewCount: 0, viewOffset: 12000);
await _putPinnedPlexMetadata(scopeB, id: '123', title: 'Profile B snapshot', viewCount: 1, viewOffset: 0);
final provider = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-a',
);
addTearDown(provider.dispose);
await provider.ensureInitialized();
provider.debugSeedState(
downloads: {key: const DownloadProgress(globalKey: key, status: DownloadStatus.completed)},
ownedDownloadKeys: {key},
);
await provider.refreshMetadataFromCache();
expect(provider.getMetadata(key)?.title, 'Profile A snapshot');
expect(provider.getMetadata(key)?.viewOffsetMs, 12000);
await waitForProfileReload(provider, 'profile-b', () => provider.getMetadata(key)?.title == 'Profile B snapshot');
expect(provider.getMetadata(key)?.isWatched, isTrue);
await waitForProfileReload(provider, 'profile-a', () => provider.getMetadata(key)?.title == 'Profile A snapshot');
expect(provider.getMetadata(key)?.isWatched, isFalse);
expect(await db.getDownloadedMedia(key), isNotNull);
expect(await db.getDownloadOwnerCount(key), 2);
expect(await PlexApiCache.instance.isPinnedRatingKey(scopeA.cacheServerId, '123'), isTrue);
expect(await PlexApiCache.instance.isPinnedRatingKey(scopeB.cacheServerId, '123'), isTrue);
expect((await PlexApiCache.instance.getMetadata(scopeA.cacheServerId, '123'))?.title, 'Profile A snapshot');
expect((await PlexApiCache.instance.getMetadata(scopeB.cacheServerId, '123'))?.title, 'Profile B snapshot');
});
test('full logout transfers Plex metadata to the next profile without carrying private state', () async {
await seedPhysicalDownload(owners: const ['profile-a', 'profile-old-co-owner']);
final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a');
final transferScope = buildPlexTransferScopeId(serverId);
final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b');
final oldCoOwnerScope = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-old-co-owner');
await _putPinnedPlexMetadata(
oldCoOwnerScope,
id: '123',
title: 'Preserved download',
viewCount: 1,
viewOffset: 12000,
);
final provider = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-a',
);
addTearDown(provider.dispose);
await provider.ensureInitialized();
await provider.detachDownloadsForLogout();
expect((await db.getDownloadedMedia(key))?.clientScopeId, transferScope);
expect(await db.hasDownloadOwner(key), isFalse);
expect(await PlexApiCache.instance.getMetadata(scopeA.cacheServerId, '123'), isNull);
expect(await PlexApiCache.instance.getMetadata(oldCoOwnerScope.cacheServerId, '123'), isNull);
final transferred = await PlexApiCache.instance.getMetadata(transferScope.cacheServerId, '123');
expect(transferred?.title, 'Preserved download');
expect(transferred?.viewCount, isNull);
expect(transferred?.viewOffsetMs, isNull);
await db.delete(db.profiles).go();
await db.delete(db.connections).go();
await _insertProfile(db, 'profile-b');
await _insertPlexConnection(db, serverId);
final adoptedProvider = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-b',
);
addTearDown(adoptedProvider.dispose);
await adoptedProvider.ensureInitialized();
final adoptedRow = await db.getDownloadedMedia(key);
final adoptedOwner = await db.getDownloadOwner(profileId: 'profile-b', globalKey: key);
expect(adoptedRow?.clientScopeId, scopeB);
expect(adoptedOwner?.clientScopeId, scopeB);
expect(adoptedOwner?.backend, MediaBackend.plex.id);
expect(await PlexApiCache.instance.getMetadata(transferScope.cacheServerId, '123'), isNull);
expect((await PlexApiCache.instance.getMetadata(scopeB.cacheServerId, '123'))?.title, 'Preserved download');
expect(adoptedProvider.getMetadata(key)?.viewCount, isNull);
expect(adoptedProvider.getMetadata(key)?.viewOffsetMs, isNull);
});
test('logout recovers legacy Plex ownership when row and owner scopes are absent', () async {
await seedPhysicalDownload(owners: const ['profile-a', 'profile-b']);
await db.updateDownloadedMediaClientScope(key, null);
final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b');
final transferScope = buildPlexTransferScopeId(serverId);
await _putPinnedPlexMetadata(scopeB, id: '123', title: 'Legacy owner snapshot', viewCount: 1, viewOffset: 4000);
await downloadManager.preparePlexMetadataForLogoutTransfer();
expect(await db.hasDownloadOwner(key), isTrue);
expect((await db.getDownloadedMedia(key))?.clientScopeId, transferScope);
final transferred = await PlexApiCache.instance.getMetadata(transferScope.cacheServerId, '123');
expect(transferred?.title, 'Legacy owner snapshot');
expect(transferred?.viewCount, isNull);
expect(transferred?.viewOffsetMs, isNull);
});
test('missing active Plex owner scope clears the prior profile snapshot without bare fallback', () async {
await seedPhysicalDownload(owners: const ['profile-a', 'profile-b']);
final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a');
await _putPinnedPlexMetadata(scopeA, id: '123', title: 'Profile A snapshot', viewCount: 0, viewOffset: 12000);
await PlexApiCache.instance.put(
serverId,
'/library/metadata/123',
_plexMetadata(id: '123', title: 'Legacy bare snapshot', viewCount: 1, viewOffset: 0),
);
await PlexApiCache.instance.pinForOffline(serverId, '123');
final provider = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-a',
);
addTearDown(provider.dispose);
await provider.ensureInitialized();
provider.debugSeedState(
downloads: {key: const DownloadProgress(globalKey: key, status: DownloadStatus.completed)},
ownedDownloadKeys: {key},
);
await provider.refreshMetadataFromCache();
expect(provider.getMetadata(key)?.title, 'Profile A snapshot');
await waitForProfileReload(provider, 'profile-b', () => provider.getMetadata(key) == null);
await provider.refreshMetadataFromCache();
expect(provider.getMetadata(key), isNull);
expect(await db.getDownloadedMedia(key), isNotNull);
});
test('a missing episode leaf does not evict parents loaded for a downloaded sibling', () async {
await _insertPlexConnection(db, serverId);
await _insertProfile(db, 'profile-a');
final scope = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a');
for (final id in ['ep-1', 'ep-2']) {
await db.insertDownload(
serverId: serverId,
clientScopeId: scope,
ratingKey: id,
globalKey: 'srv:$id',
type: 'episode',
parentRatingKey: 'season-1',
grandparentRatingKey: 'show-1',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:$id');
}
Future<void> putPinned(String id, Map<String, Object?> metadata) async {
await PlexApiCache.instance.put(scope.cacheServerId, '/library/metadata/$id', {
'MediaContainer': {
'Metadata': [metadata],
},
});
await PlexApiCache.instance.pinForOffline(scope.cacheServerId, id);
}
await putPinned('show-1', {'ratingKey': 'show-1', 'type': 'show', 'title': 'Shared Show'});
await putPinned('season-1', {
'ratingKey': 'season-1',
'type': 'season',
'title': 'Season 1',
'parentRatingKey': 'show-1',
});
await putPinned('ep-1', {
'ratingKey': 'ep-1',
'type': 'episode',
'title': 'Available Episode',
'parentRatingKey': 'season-1',
'grandparentRatingKey': 'show-1',
});
final missingEpisode = testMediaItem(
id: 'ep-2',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Missing Episode',
serverId: serverId,
parentId: 'season-1',
grandparentId: 'show-1',
);
final provider = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-a',
);
addTearDown(provider.dispose);
await provider.ensureInitialized();
provider.debugSeedState(
downloads: {
'srv:ep-1': const DownloadProgress(globalKey: 'srv:ep-1', status: DownloadStatus.completed),
'srv:ep-2': const DownloadProgress(globalKey: 'srv:ep-2', status: DownloadStatus.completed),
},
metadata: {'srv:ep-2': missingEpisode},
ownedDownloadKeys: {'srv:ep-1', 'srv:ep-2'},
);
await provider.refreshMetadataFromCache();
expect(provider.getMetadata('srv:ep-1')?.title, 'Available Episode');
expect(provider.getMetadata('srv:ep-2'), isNull);
expect(provider.getMetadata('srv:show-1')?.title, 'Shared Show');
expect(provider.getMetadata('srv:season-1')?.title, 'Season 1');
});
test('scoped watch writeback mutates only the active Plex owner row despite co-ownership', () async {
await seedPhysicalDownload(owners: const ['profile-a', 'profile-b']);
final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a');
final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b');
await _putPinnedPlexMetadata(scopeA, id: '123', title: 'Profile A snapshot', viewCount: 0, viewOffset: 12000);
await _putPinnedPlexMetadata(scopeB, id: '123', title: 'Profile B snapshot', viewCount: 0, viewOffset: 34000);
final activeClient = _ScopedTestClient(
serverId: serverId,
scopedServerId: scopeA,
clientBackend: MediaBackend.plex,
);
testClientResolver = (resolvedServerId, {clientScopeId}) => resolvedServerId == serverId ? activeClient : null;
final provider = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-a',
);
addTearDown(provider.dispose);
await provider.ensureInitialized();
provider.debugSeedState(
downloads: {key: const DownloadProgress(globalKey: key, status: DownloadStatus.completed)},
ownedDownloadKeys: {key},
);
await provider.refreshMetadataFromCache();
final item = provider.getMetadata(key)!;
WatchStateNotifier().notifyWatched(item: item, cacheServerId: scopeA);
await provider.debugWaitForWatchStateWrites();
expect((await PlexApiCache.instance.getMetadata(scopeA.cacheServerId, '123'))?.isWatched, isTrue);
expect((await PlexApiCache.instance.getMetadata(scopeB.cacheServerId, '123'))?.isWatched, isFalse);
WatchStateNotifier().notifyWatched(item: item, cacheServerId: scopeB);
await provider.debugWaitForWatchStateWrites();
expect((await PlexApiCache.instance.getMetadata(scopeA.cacheServerId, '123'))?.isWatched, isTrue);
expect((await PlexApiCache.instance.getMetadata(scopeB.cacheServerId, '123'))?.isWatched, isFalse);
});
test('claim and owner release pin and delete metadata per Plex owner reference', () async {
await seedPhysicalDownload(owners: const ['profile-a', 'profile-b']);
await db.removeDownloadOwner(profileId: 'profile-b', globalKey: key);
final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a');
final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b');
await _putPinnedPlexMetadata(scopeA, id: '123', title: 'Profile A snapshot', viewCount: 0, viewOffset: 12000);
final profileBItem = testMediaItem(
id: '123',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Profile B snapshot',
serverId: serverId,
viewCount: 1,
);
final clientB = _ScopedTestClient(
serverId: serverId,
scopedServerId: scopeB,
clientBackend: MediaBackend.plex,
fetchItemHandler: (id) async {
await PlexApiCache.instance.put(
scopeB.cacheServerId,
'/library/metadata/$id',
_plexMetadata(id: id, title: 'Profile B snapshot', viewCount: 1, viewOffset: 0),
);
return profileBItem;
},
);
testClientResolver = (resolvedServerId, {clientScopeId}) =>
resolvedServerId == serverId && (clientScopeId == null || clientScopeId == scopeB) ? clientB : null;
final provider = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-b',
);
addTearDown(provider.dispose);
await provider.ensureInitialized();
provider.debugSeedState(
downloads: {key: const DownloadProgress(globalKey: key, status: DownloadStatus.completed)},
ownedDownloadKeys: const {},
);
expect(await provider.queueDownload(profileBItem, clientB), 1);
expect(await db.getDownloadOwnerCount(key), 2);
expect(await db.getAllDownloadedMetadata(), hasLength(1));
expect(await PlexApiCache.instance.isPinnedRatingKey(scopeA.cacheServerId, '123'), isTrue);
expect(await PlexApiCache.instance.isPinnedRatingKey(scopeB.cacheServerId, '123'), isTrue);
await provider.deleteDownloadsForProfile('profile-a');
expect(await db.getDownloadOwnerCount(key), 1);
expect(await db.getDownloadedMedia(key), isNotNull);
expect(await PlexApiCache.instance.getMetadata(scopeA.cacheServerId, '123'), isNull);
expect((await PlexApiCache.instance.getMetadata(scopeB.cacheServerId, '123'))?.title, 'Profile B snapshot');
await provider.deleteDownload(key);
expect(await db.getDownloadOwnerCount(key), 0);
expect(await db.getDownloadedMedia(key), isNull);
expect(await PlexApiCache.instance.getMetadata(scopeB.cacheServerId, '123'), isNull);
});
test('auto-delete protects only the exact active global key', () async {
await _insertProfile(db, 'profile-a');
for (final server in ['A', 'B']) {
await db.insertDownload(
serverId: ServerId(server),
ratingKey: '123',
globalKey: '$server:123',
type: 'movie',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(profileId: 'profile-a', globalKey: '$server:123');
}
final itemA = testMediaItem(
id: '123',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'A watched',
serverId: 'A',
viewCount: 1,
);
final itemB = itemA.copyWith(serverId: 'B', title: 'B watched');
final provider = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-a',
);
addTearDown(provider.dispose);
await provider.ensureInitialized();
provider.debugSeedState(
downloads: {
itemA.globalKey: DownloadProgress(globalKey: itemA.globalKey, status: DownloadStatus.completed),
itemB.globalKey: DownloadProgress(globalKey: itemB.globalKey, status: DownloadStatus.completed),
},
metadata: {itemA.globalKey: itemA, itemB.globalKey: itemB},
ownedDownloadKeys: {itemA.globalKey, itemB.globalKey},
);
expect(await provider.autoDeleteWatchedDownloads(activeGlobalKey: itemA.globalKey), ['B watched']);
expect(provider.downloads.keys, [itemA.globalKey]);
expect(await db.getDownloadedMedia(itemA.globalKey), isNotNull);
expect(await db.getDownloadedMedia(itemB.globalKey), isNull);
});
test('auto-delete removes both same-id downloads when there is no active key', () async {
await _insertProfile(db, 'profile-a');
for (final server in ['A', 'B']) {
await db.insertDownload(
serverId: ServerId(server),
ratingKey: '123',
globalKey: '$server:123',
type: 'movie',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(profileId: 'profile-a', globalKey: '$server:123');
}
final itemA = testMediaItem(
id: '123',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'A watched',
serverId: 'A',
viewCount: 1,
);
final itemB = itemA.copyWith(serverId: 'B', title: 'B watched');
final provider = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-a',
);
addTearDown(provider.dispose);
await provider.ensureInitialized();
provider.debugSeedState(
downloads: {
itemA.globalKey: DownloadProgress(globalKey: itemA.globalKey, status: DownloadStatus.completed),
itemB.globalKey: DownloadProgress(globalKey: itemB.globalKey, status: DownloadStatus.completed),
},
metadata: {itemA.globalKey: itemA, itemB.globalKey: itemB},
ownedDownloadKeys: {itemA.globalKey, itemB.globalKey},
);
expect((await provider.autoDeleteWatchedDownloads()).toSet(), {'A watched', 'B watched'});
expect(provider.downloads, isEmpty);
expect(await db.getDownloadedMedia(itemA.globalKey), isNull);
expect(await db.getDownloadedMedia(itemB.globalKey), isNull);
});
});
group('DownloadProvider — getMetadata', () {
test('getMetadata returns null for keys never observed', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
expect(p.getMetadata('srv:absent'), isNull);
p.dispose();
});
test('watched progress events mark downloaded metadata watched and clear resume', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
final item = testMediaItem(
id: '42',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie',
serverId: ServerId('srv'),
durationMs: 100000,
viewOffsetMs: 12000,
viewCount: 0,
);
p.debugSeedState(metadata: {'srv:42': item});
WatchStateNotifier().notifyProgress(item: item, viewOffset: 95000, duration: 100000, watchedThreshold: 0.9);
await Future<void>.delayed(Duration.zero);
final updated = p.getMetadata('srv:42');
expect(updated?.isWatched, isTrue);
expect(updated?.viewOffsetMs, 0);
p.dispose();
});
test('sub-threshold progress events update downloaded metadata resume', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
final item = testMediaItem(
id: '42',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie',
serverId: ServerId('srv'),
durationMs: 100000,
viewOffsetMs: 0,
viewCount: 1,
);
p.debugSeedState(metadata: {'srv:42': item});
WatchStateNotifier().notifyProgress(item: item, viewOffset: 50000, duration: 100000, watchedThreshold: 0.9);
await Future<void>.delayed(Duration.zero);
final updated = p.getMetadata('srv:42');
expect(updated?.isWatched, isTrue);
expect(updated?.viewOffsetMs, 50000);
p.dispose();
});
test('show, season, and episode events resolve by hierarchical freshness', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
final show = testMediaItem(
id: 'show-1',
backend: MediaBackend.plex,
kind: MediaKind.show,
serverId: ServerId('srv'),
leafCount: 3,
viewedLeafCount: 0,
);
final season1 = testMediaItem(
id: 'season-1',
backend: MediaBackend.plex,
kind: MediaKind.season,
parentId: 'show-1',
serverId: ServerId('srv'),
leafCount: 2,
viewedLeafCount: 0,
);
final episode1 = testMediaItem(
id: 'episode-1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
parentId: 'season-1',
grandparentId: 'show-1',
serverId: ServerId('srv'),
viewCount: 0,
viewOffsetMs: 40000,
);
final episode2 = episode1.copyWith(id: 'episode-2', viewOffsetMs: 50000);
final episode3 = episode1.copyWith(id: 'episode-3', parentId: 'season-2', viewOffsetMs: 60000);
p.debugSeedState(
metadata: {
show.globalKey: show,
season1.globalKey: season1,
episode1.globalKey: episode1,
episode2.globalKey: episode2,
episode3.globalKey: episode3,
},
);
WatchStateNotifier().notifyWatched(item: show);
await Future<void>.delayed(Duration.zero);
expect(p.getMetadata(episode1.globalKey)?.isWatched, isTrue);
expect(p.getMetadata(episode2.globalKey)?.viewOffsetMs, 0);
expect(p.getMetadata(episode3.globalKey)?.isWatched, isTrue);
WatchStateNotifier().notifyWatched(item: season1, isNowWatched: false);
await Future<void>.delayed(Duration.zero);
expect(p.getMetadata(episode1.globalKey)?.isWatched, isFalse);
expect(p.getMetadata(episode2.globalKey)?.isWatched, isFalse);
expect(p.getMetadata(episode3.globalKey)?.isWatched, isTrue);
WatchStateNotifier().notifyWatched(item: episode1);
await Future<void>.delayed(Duration.zero);
expect(p.getMetadata(episode1.globalKey)?.isWatched, isTrue);
expect(p.getMetadata(episode2.globalKey)?.isWatched, isFalse);
WatchStateNotifier().notifyWatched(item: show, isNowWatched: false);
await Future<void>.delayed(Duration.zero);
expect(p.getMetadata(episode1.globalKey)?.isWatched, isFalse);
expect(p.getMetadata(episode2.globalKey)?.isWatched, isFalse);
expect(p.getMetadata(episode3.globalKey)?.isWatched, isFalse);
expect(p.getMetadata(episode3.globalKey)?.viewOffsetMs, 0);
p.dispose();
});
test('queued parent and episode overrides survive provider reload', () async {
await db.insertWatchAction(
profileId: 'profile-a',
serverId: ServerId('srv'),
ratingKey: 'show-1',
actionType: 'watched',
);
await db.insertWatchAction(
profileId: 'profile-a',
serverId: ServerId('srv'),
ratingKey: 'episode-1',
actionType: 'unwatched',
);
final episode1 = testMediaItem(
id: 'episode-1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
parentId: 'season-1',
grandparentId: 'show-1',
serverId: ServerId('srv'),
viewCount: 0,
viewOffsetMs: 45000,
);
final episode2 = episode1.copyWith(id: 'episode-2', viewOffsetMs: 55000);
Future<DownloadProvider> hydrate() async {
final provider = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-a',
);
await provider.ensureInitialized();
provider.debugSeedState(metadata: {episode1.globalKey: episode1, episode2.globalKey: episode2});
await provider.refreshMetadataFromCache();
return provider;
}
var p = await hydrate();
expect(p.getMetadata(episode1.globalKey)?.isWatched, isFalse);
expect(p.getMetadata(episode1.globalKey)?.viewOffsetMs, 0);
expect(p.getMetadata(episode2.globalKey)?.isWatched, isTrue);
expect(p.getMetadata(episode2.globalKey)?.viewOffsetMs, 0);
p.dispose();
p = await hydrate();
expect(p.getMetadata(episode1.globalKey)?.isWatched, isFalse);
expect(p.getMetadata(episode2.globalKey)?.isWatched, isTrue);
p.dispose();
});
test('queued overlays are isolated to the active profile', () async {
await db.insertWatchAction(
profileId: 'profile-a',
serverId: ServerId('srv'),
ratingKey: 'show-1',
actionType: 'watched',
);
await db.insertWatchAction(
profileId: 'profile-b',
serverId: ServerId('srv'),
ratingKey: 'show-1',
actionType: 'unwatched',
);
final episode = testMediaItem(
id: 'episode-1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
parentId: 'season-1',
grandparentId: 'show-1',
serverId: ServerId('srv'),
viewCount: 0,
);
final p = DownloadProvider.forTesting(
downloadManager: downloadManager,
database: db,
activeProfileId: 'profile-a',
);
await p.ensureInitialized();
p.debugSeedState(metadata: {episode.globalKey: episode});
await p.refreshMetadataFromCache();
expect(p.getMetadata(episode.globalKey)?.isWatched, isTrue);
p.setActiveProfileId('profile-b');
await p.refreshMetadataFromCache();
expect(p.getMetadata(episode.globalKey), isNull);
p.dispose();
});
test('queued overlays are isolated to the active Jellyfin client scope', () async {
await db.insertWatchAction(
profileId: 'test-profile',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'show-1',
actionType: 'watched',
);
await db.insertWatchAction(
profileId: 'test-profile',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-b',
ratingKey: 'show-1',
actionType: 'unwatched',
);
final episode = testMediaItem(
id: 'episode-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
parentId: 'season-1',
grandparentId: 'show-1',
serverId: ServerId('jf-machine'),
viewCount: 0,
);
var activeScope = 'jf-machine/user-a';
testClientResolver = (serverId, {clientScopeId}) => serverId == 'jf-machine'
? _ScopedTestClient(serverId: ServerId('jf-machine'), scopedServerId: activeScope)
: null;
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(metadata: {episode.globalKey: episode});
await p.refreshMetadataFromCache();
expect(p.getMetadata(episode.globalKey)?.isWatched, isTrue);
activeScope = 'jf-machine/user-b';
await p.refreshMetadataFromCache();
expect(p.getMetadata(episode.globalKey)?.isWatched, isFalse);
p.dispose();
});
});
group('DownloadProvider — progress stream', () {
test('exposes broadcast progress and deletion-progress streams', () async {
// These streams are broadcast so the provider's subscription can co-
// exist with other listeners (UI widgets, sync rule executor, etc.).
expect(downloadManager.progressStream.isBroadcast, isTrue);
expect(downloadManager.deletionProgressStream.isBroadcast, isTrue);
});
});
group('DownloadProvider — cancelDownload map symmetry', () {
test('cancelDownload removes download, metadata, and artwork', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
const key = 'srv:42';
p.debugSeedState(
downloads: {key: const DownloadProgress(globalKey: key, status: DownloadStatus.queued)},
metadata: {
key: testMediaItem(
id: '42',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Ep 42',
serverId: ServerId('srv'),
),
},
artwork: {key: const DownloadedArtwork(thumbPath: '/art/42.jpg')},
);
await p.cancelDownload(key);
expect(p.getProgress(key), isNull);
expect(p.getMetadata(key), isNull);
expect(p.getArtworkPaths(key), isNull, reason: 'artwork path must not orphan after cancel');
p.dispose();
});
test('cancelDownload is a no-op when download is absent', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
// Seed only artwork; no download → cancelDownload should not touch it.
p.debugSeedState(artwork: {'srv:99': const DownloadedArtwork(thumbPath: '/art/99.jpg')});
await p.cancelDownload('srv:99');
expect(p.getArtworkPaths('srv:99'), isNotNull);
p.dispose();
});
});
group('DownloadProvider — refresh clears transient state', () {
test('refresh evicts stale _queueing and _deletionProgress entries', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
const queueingKey = 'srv:queueing';
const deletingKey = 'srv:deleting';
p.debugSeedState(
queueing: {queueingKey},
deletionProgress: {
deletingKey: const DeletionProgress(
globalKey: deletingKey,
itemTitle: 'Deleting',
currentItem: 1,
totalItems: 5,
),
},
);
expect(p.isQueueing(queueingKey), isTrue);
expect(p.getDeletionProgress(deletingKey), isNotNull);
// refresh() calls _loadPersistedDownloads. Storage may or may not be
// initialized in this test, but the clear-block runs before any storage
// call (right after recoveryFuture resolves), so the assertions below
// hold either way.
await p.refresh();
expect(p.isQueueing(queueingKey), isFalse);
expect(p.getDeletionProgress(deletingKey), isNull);
p.dispose();
});
});
group('DownloadProvider — queueDownload exception safety', () {
test('rolls back season metadata when expansion throws', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
final season = testMediaItem(
id: '7',
backend: MediaBackend.plex,
kind: MediaKind.season,
title: 'Season 7',
serverId: ServerId('srv'),
);
expect(p.getMetadata('srv:7'), isNull);
await expectLater(p.queueDownload(season, _ThrowingClient()), throwsA(isA<StateError>()));
expect(p.getMetadata('srv:7'), isNull, reason: 'metadata stash must be rolled back when queue helper throws');
expect(p.isQueueing('srv:7'), isFalse, reason: '_queueing must be cleared by the finally block');
p.dispose();
});
test('preserves pre-existing metadata if queue throws (no clobber)', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
// Pre-existing metadata under the same key (e.g. from a prior sync rule's
// targetMetadata). The rollback must not delete it on queue failure.
final preexisting = testMediaItem(
id: '7',
backend: MediaBackend.plex,
kind: MediaKind.season,
title: 'Original Title',
serverId: ServerId('srv'),
);
p.debugSeedState(metadata: {'srv:7': preexisting});
final season = testMediaItem(
id: '7',
backend: MediaBackend.plex,
kind: MediaKind.season,
title: 'New Title',
serverId: ServerId('srv'),
);
await expectLater(p.queueDownload(season, _ThrowingClient()), throwsA(isA<StateError>()));
expect(
p.getMetadata('srv:7')?.title,
'Original Title',
reason: 'queue rollback must restore the previous value, not leave the temporary stash',
);
p.dispose();
});
});
group('DownloadProvider — background download prerequisites', () {
final movie = testMediaItem(
id: '1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Queued Movie',
serverId: ServerId('srv'),
);
setUp(NotificationPermission.debugReset);
tearDown(() {
NotificationPermission.debugRequestOverride = null;
NotificationPermission.debugReset();
});
test('queueDownload asks for the notification permission that anchors the transfer', () async {
// Regression guard: music playback used to be the only caller, so a user
// whose first action was a download was never asked, and Android killed
// the transfer the moment the screen went off.
var requests = 0;
NotificationPermission.debugRequestOverride = () async => requests++;
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
// Already downloaded, so the queue short-circuits before touching the
// manager — the permission request is the only thing under test.
p.debugSeedState(
downloads: {'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed)},
metadata: {'srv:1': movie},
ownedDownloadKeys: const {},
);
await p.queueDownload(movie, _ThrowingClient());
expect(requests, 1);
p.dispose();
});
test('the permission is requested once, not on every queued item', () async {
var requests = 0;
NotificationPermission.debugRequestOverride = () async => requests++;
final second = movie.copyWith(id: '2');
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {
'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed),
'srv:2': const DownloadProgress(globalKey: 'srv:2', status: DownloadStatus.completed),
},
metadata: {'srv:1': movie, 'srv:2': second},
ownedDownloadKeys: const {},
);
await p.queueDownload(movie, _ThrowingClient());
await p.queueDownload(second, _ThrowingClient());
expect(requests, 1);
p.dispose();
});
test('holds the duplicate queue claim while permission is pending', () async {
final requestStarted = Completer<void>();
final releaseRequest = Completer<void>();
var requests = 0;
NotificationPermission.debugRequestOverride = () async {
requests++;
requestStarted.complete();
await releaseRequest.future;
};
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed)},
metadata: {'srv:1': movie},
ownedDownloadKeys: const {},
);
final firstQueue = p.queueDownload(movie, _ThrowingClient());
await requestStarted.future;
expect(p.isQueueing(movie.globalKey), isTrue);
expect(await p.queueDownload(movie, _ThrowingClient()), 0);
releaseRequest.complete();
await firstQueue;
expect(requests, 1);
p.dispose();
});
test('the activity snapshot counts in-flight work and every downloaded byte', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {
'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.downloading, downloadedBytes: 100),
'srv:2': const DownloadProgress(globalKey: 'srv:2', status: DownloadStatus.queued, downloadedBytes: 0),
'srv:3': const DownloadProgress(globalKey: 'srv:3', status: DownloadStatus.completed, downloadedBytes: 900),
'srv:4': const DownloadProgress(globalKey: 'srv:4', status: DownloadStatus.failed, downloadedBytes: 5),
},
metadata: const {},
ownedDownloadKeys: const {},
);
final snapshot = p.downloadActivitySnapshot();
expect(snapshot.activeTasks, 1, reason: 'only actively downloading tasks arm the stall detector');
expect(snapshot.completedTasks, 1);
expect(snapshot.failedTasks, 1);
expect(snapshot.downloadedBytes, 1005, reason: 'every download, regardless of profile ownership');
p.dispose();
});
test('terminal status-only events preserve bytes but a retry resets progress', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(ownedDownloadKeys: const {'srv:1'});
downloadManager.debugEmitProgress(
const DownloadProgress(
globalKey: 'srv:1',
status: DownloadStatus.downloading,
progress: 47,
downloadedBytes: 500,
totalBytes: 1000,
),
);
await Future<void>.delayed(Duration.zero);
downloadManager.debugEmitProgress(
const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed, progress: 100),
);
await Future<void>.delayed(Duration.zero);
expect(p.downloads['srv:1']?.progress, 100);
expect(p.downloads['srv:1']?.downloadedBytes, 500);
expect(p.downloads['srv:1']?.totalBytes, 1000);
downloadManager.debugEmitProgress(const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.queued));
await Future<void>.delayed(Duration.zero);
expect(p.downloads['srv:1']?.progress, 0);
expect(p.downloads['srv:1']?.downloadedBytes, 0);
expect(p.downloads['srv:1']?.totalBytes, 0);
p.dispose();
});
test('activity snapshots record offline-source transitions', () async {
final source = _FakeOfflineModeSource(false);
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.setOfflineSource(source);
final online = p.downloadActivitySnapshot();
source.setOffline(true);
final offline = p.downloadActivitySnapshot();
expect(online.networkAvailable, isTrue);
expect(offline.networkAvailable, isFalse);
expect(offline.networkStateGeneration, greaterThan(online.networkStateGeneration));
p.dispose();
source.dispose();
});
test('the activity snapshot tear-off is stable enough to unbind', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
expect(p.downloadActivitySnapshot, equals(p.downloadActivitySnapshot));
p.dispose();
});
});
group('DownloadProvider — dispose hygiene', () {
test('isDisposed flips from false to true on dispose', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
expect(p.isDisposed, isFalse);
p.dispose();
expect(p.isDisposed, isTrue);
});
});
}