Return initialized show and season rules from the backfill query so their coverage is recomputed from download ancestry. Rule execution links only the unwatched episodes it inspected, so the cached flag let a sibling list cleanup delete episodes the show rule covers.
3099 lines
116 KiB
Dart
3099 lines
116 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/multi_server_manager.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/download_fixtures.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 keeps downloads previously associated with the rule', () async {
|
|
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
|
|
addTearDown(p.dispose);
|
|
await p.ensureInitialized();
|
|
await p.createSyncRule(serverId: ServerId('srv'), ratingKey: 'playlist', targetType: 'playlist', episodeCount: 0);
|
|
final ruleKey = p.syncRuleKeyFor(ServerId('srv'), 'playlist');
|
|
final rule = (await db.getSyncRule(ruleKey))!;
|
|
await db.insertDownload(
|
|
serverId: ServerId('srv'),
|
|
ratingKey: 'episode',
|
|
globalKey: 'srv:episode',
|
|
type: 'episode',
|
|
status: DownloadStatus.completed.index,
|
|
);
|
|
await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'srv:episode');
|
|
await db.associateSyncRuleDownload(rule, 'srv:episode');
|
|
|
|
await p.deleteSyncRule(ruleKey);
|
|
|
|
expect(await db.getSyncRule(ruleKey), isNull);
|
|
expect(await db.getDownloadedMedia('srv:episode'), isNotNull);
|
|
expect(await db.getDownloadOwner(profileId: 'test-profile', globalKey: 'srv:episode'), isNotNull);
|
|
});
|
|
|
|
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('deleteSyncRuleAndDownloads removes only downloads exclusive to the rule and active profile', () async {
|
|
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
|
|
addTearDown(p.dispose);
|
|
await p.ensureInitialized();
|
|
final serverManager = MultiServerManager();
|
|
addTearDown(serverManager.dispose);
|
|
|
|
await p.createSyncRule(
|
|
serverId: ServerId('srv'),
|
|
ratingKey: 'playlist-a',
|
|
targetType: 'playlist',
|
|
episodeCount: 0,
|
|
);
|
|
await p.createSyncRule(
|
|
serverId: ServerId('srv'),
|
|
ratingKey: 'playlist-b',
|
|
targetType: 'playlist',
|
|
episodeCount: 0,
|
|
);
|
|
final targetKey = p.syncRuleKeyFor(ServerId('srv'), 'playlist-a');
|
|
final siblingKey = p.syncRuleKeyFor(ServerId('srv'), 'playlist-b');
|
|
final targetRule = (await db.getSyncRule(targetKey))!;
|
|
final siblingRule = (await db.getSyncRule(siblingKey))!;
|
|
await db.markSyncRuleDownloadLinksInitialized(targetKey);
|
|
await db.markSyncRuleDownloadLinksInitialized(siblingKey);
|
|
|
|
final items = <String, MediaItem>{
|
|
'srv:exclusive': testMediaItem(
|
|
id: 'exclusive',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.episode,
|
|
title: 'Exclusive',
|
|
serverId: ServerId('srv'),
|
|
),
|
|
'srv:rule-shared': testMediaItem(
|
|
id: 'rule-shared',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.episode,
|
|
title: 'Rule shared',
|
|
serverId: ServerId('srv'),
|
|
),
|
|
'srv:profile-shared': testMediaItem(
|
|
id: 'profile-shared',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.episode,
|
|
title: 'Profile shared',
|
|
serverId: ServerId('srv'),
|
|
),
|
|
};
|
|
for (final entry in items.entries) {
|
|
await db.insertDownload(
|
|
serverId: ServerId('srv'),
|
|
ratingKey: entry.value.id,
|
|
globalKey: entry.key,
|
|
type: 'episode',
|
|
status: DownloadStatus.completed.index,
|
|
);
|
|
await db.addDownloadOwner(profileId: 'test-profile', globalKey: entry.key);
|
|
await db.associateSyncRuleDownload(targetRule, entry.key);
|
|
}
|
|
await db.associateSyncRuleDownload(siblingRule, 'srv:rule-shared');
|
|
await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv:profile-shared');
|
|
|
|
p.debugSeedState(
|
|
downloads: {
|
|
for (final key in items.keys) key: DownloadProgress(globalKey: key, status: DownloadStatus.completed),
|
|
},
|
|
metadata: items,
|
|
ownedDownloadKeys: items.keys.toSet(),
|
|
);
|
|
|
|
await p.deleteSyncRuleAndDownloads(targetKey, serverManager);
|
|
|
|
expect(await db.getSyncRule(targetKey), isNull);
|
|
expect(await db.getSyncRule(siblingKey), isNotNull);
|
|
expect(await db.getDownloadedMedia('srv:exclusive'), isNull);
|
|
expect(await db.getDownloadedMedia('srv:rule-shared'), isNotNull);
|
|
expect(await db.getDownloadOwner(profileId: 'test-profile', globalKey: 'srv:rule-shared'), isNotNull);
|
|
expect(await db.getDownloadedMedia('srv:profile-shared'), isNotNull);
|
|
expect(await db.getDownloadOwner(profileId: 'test-profile', globalKey: 'srv:profile-shared'), isNull);
|
|
expect(await db.getDownloadOwner(profileId: 'profile-b', globalKey: 'srv:profile-shared'), isNotNull);
|
|
});
|
|
|
|
test('deleteSyncRuleAndDownloads keeps episodes an initialized show rule covers', () async {
|
|
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
|
|
addTearDown(p.dispose);
|
|
await p.ensureInitialized();
|
|
final serverManager = MultiServerManager();
|
|
addTearDown(serverManager.dispose);
|
|
|
|
await p.createSyncRule(serverId: ServerId('srv'), ratingKey: 'playlist', targetType: 'playlist', episodeCount: 0);
|
|
await p.createSyncRule(serverId: ServerId('srv'), ratingKey: 'show-1', targetType: 'show', episodeCount: 1);
|
|
final targetKey = p.syncRuleKeyFor(ServerId('srv'), 'playlist');
|
|
final showKey = p.syncRuleKeyFor(ServerId('srv'), 'show-1');
|
|
final targetRule = (await db.getSyncRule(targetKey))!;
|
|
final showRule = (await db.getSyncRule(showKey))!;
|
|
await db.markSyncRuleDownloadLinksInitialized(targetKey);
|
|
await db.markSyncRuleDownloadLinksInitialized(showKey);
|
|
|
|
const downloadKey = 'srv:ep-watched';
|
|
final episode = testMediaItem(
|
|
id: 'ep-watched',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.episode,
|
|
title: 'Watched episode',
|
|
serverId: ServerId('srv'),
|
|
);
|
|
await db.insertDownload(
|
|
serverId: ServerId('srv'),
|
|
ratingKey: 'ep-watched',
|
|
globalKey: downloadKey,
|
|
type: 'episode',
|
|
grandparentRatingKey: 'show-1',
|
|
status: DownloadStatus.completed.index,
|
|
);
|
|
await db.addDownloadOwner(profileId: 'test-profile', globalKey: downloadKey);
|
|
await db.associateSyncRuleDownload(targetRule, downloadKey);
|
|
|
|
p.debugSeedState(
|
|
downloads: {downloadKey: const DownloadProgress(globalKey: downloadKey, status: DownloadStatus.completed)},
|
|
metadata: {downloadKey: episode},
|
|
ownedDownloadKeys: {downloadKey},
|
|
);
|
|
|
|
await p.deleteSyncRuleAndDownloads(targetKey, serverManager);
|
|
|
|
expect(await db.getDownloadedMedia(downloadKey), isNotNull);
|
|
expect(await db.getSyncRuleDownloadLinks(showRule.id), hasLength(1));
|
|
});
|
|
|
|
test('deleteSyncRuleAndDownloads keeps an unresolvable legacy list rule intact', () async {
|
|
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
|
|
addTearDown(p.dispose);
|
|
await p.ensureInitialized();
|
|
final serverManager = MultiServerManager();
|
|
addTearDown(serverManager.dispose);
|
|
|
|
await p.createSyncRule(
|
|
serverId: ServerId('missing-server'),
|
|
ratingKey: 'deleted-playlist',
|
|
targetType: 'playlist',
|
|
episodeCount: 0,
|
|
);
|
|
final ruleKey = p.syncRuleKeyFor(ServerId('missing-server'), 'deleted-playlist');
|
|
|
|
await expectLater(
|
|
p.deleteSyncRuleAndDownloads(ruleKey, serverManager),
|
|
throwsA(
|
|
isA<SyncRuleCleanupUnavailableException>().having((error) => error.ruleGlobalKey, 'ruleGlobalKey', ruleKey),
|
|
),
|
|
);
|
|
|
|
expect(await db.getSyncRule(ruleKey), isNotNull);
|
|
expect((await db.getSyncRule(ruleKey))!.enabled, isTrue);
|
|
expect(p.hasSyncRule(ruleKey), isTrue);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|