Drops dead code across services, models, utils and widgets, including the connection auth service, which had no implementer, and the Live TV DVR provisioning models, which had no caller. Tests that only covered deleted behaviour are removed or trimmed. No behaviour change.
764 lines
29 KiB
Dart
764 lines
29 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:drift/drift.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import '../media/ids.dart';
|
|
|
|
import 'app_database.dart';
|
|
import '../models/download_models.dart';
|
|
import '../profiles/profile.dart';
|
|
import '../utils/active_client_scope.dart';
|
|
|
|
enum QueueDownloadOutcome {
|
|
/// A missing or retryable row was durably admitted to the queue.
|
|
admitted,
|
|
|
|
/// The row was already queued; only its queue policy was refreshed.
|
|
alreadyQueued,
|
|
|
|
/// The existing row is active, paused, or complete and was left unchanged.
|
|
unchanged,
|
|
}
|
|
|
|
extension DownloadDatabaseOperations on AppDatabase {
|
|
Future<void> addDownloadOwner({
|
|
required String profileId,
|
|
required String globalKey,
|
|
String? backendId,
|
|
String? clientScopeId,
|
|
}) async {
|
|
if (profileId.isEmpty) return;
|
|
await customUpdate(
|
|
'''
|
|
INSERT INTO download_owners (
|
|
profile_id,
|
|
global_key,
|
|
backend,
|
|
client_scope_id,
|
|
created_at
|
|
) VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(profile_id, global_key) DO UPDATE SET
|
|
backend = COALESCE(excluded.backend, download_owners.backend),
|
|
client_scope_id = COALESCE(excluded.client_scope_id, download_owners.client_scope_id)
|
|
''',
|
|
variables: [
|
|
Variable<String>(profileId),
|
|
Variable<String>(globalKey),
|
|
Variable<String>(backendId),
|
|
Variable<String>(clientScopeId),
|
|
Variable<int>(DateTime.now().millisecondsSinceEpoch),
|
|
],
|
|
updates: {downloadOwners},
|
|
);
|
|
}
|
|
|
|
Future<void> removeDownloadOwner({required String profileId, required String globalKey}) async {
|
|
await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).go();
|
|
}
|
|
|
|
/// Removes one owner from a shared download while keeping an incomplete
|
|
/// physical row usable by a remaining owner.
|
|
///
|
|
/// When there is no remaining valid owner, nothing is removed so callers
|
|
/// can delete the physical download before releasing its final durable
|
|
/// owner. Selection, scope rebinding, and owner removal share a transaction.
|
|
Future<({DownloadOwnerItem? removedOwner, bool hasRemainingOwner})>
|
|
removeSharedDownloadOwnerAndRebindIncompleteMedia({required String profileId, required String globalKey}) {
|
|
return transaction(() async {
|
|
final departingOwner = await getDownloadOwner(profileId: profileId, globalKey: globalKey);
|
|
final remainingOwners = (await _validDownloadOwnerRows(globalKey, excludingProfileId: profileId)).toList()
|
|
..sort((a, b) {
|
|
final aHasScope = a.clientScopeId?.isNotEmpty ?? false;
|
|
final bHasScope = b.clientScopeId?.isNotEmpty ?? false;
|
|
if (aHasScope != bHasScope) return aHasScope ? -1 : 1;
|
|
final createdAtComparison = a.createdAt.compareTo(b.createdAt);
|
|
return createdAtComparison != 0 ? createdAtComparison : a.profileId.compareTo(b.profileId);
|
|
});
|
|
if (remainingOwners.isEmpty) {
|
|
return (removedOwner: null, hasRemainingOwner: false);
|
|
}
|
|
|
|
if (departingOwner != null) {
|
|
final media = await getDownloadedMedia(globalKey);
|
|
final departingScope = departingOwner.clientScopeId;
|
|
if (media != null &&
|
|
media.status != DownloadStatus.completed.index &&
|
|
departingScope != null &&
|
|
departingScope.isNotEmpty &&
|
|
media.clientScopeId == departingScope) {
|
|
final replacementScope = remainingOwners.first.clientScopeId;
|
|
if (replacementScope != media.clientScopeId) {
|
|
await updateDownloadedMediaClientScope(globalKey, replacementScope);
|
|
}
|
|
}
|
|
await removeDownloadOwner(profileId: profileId, globalKey: globalKey);
|
|
}
|
|
return (removedOwner: departingOwner, hasRemainingOwner: true);
|
|
});
|
|
}
|
|
|
|
Future<DownloadOwnerItem?> getDownloadOwner({required String profileId, required String globalKey}) {
|
|
return (select(
|
|
downloadOwners,
|
|
)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).getSingleOrNull();
|
|
}
|
|
|
|
Future<void> clearAllDownloadOwners() async {
|
|
await delete(downloadOwners).go();
|
|
}
|
|
|
|
Future<Set<String>> getDownloadOwnerKeysForProfile(String profileId) async {
|
|
if (profileId.isEmpty) return const {};
|
|
final rows = await (select(downloadOwners)..where((t) => t.profileId.equals(profileId))).get();
|
|
return rows.map((row) => row.globalKey).toSet();
|
|
}
|
|
|
|
Future<List<DownloadOwnerItem>> getDownloadOwnersForProfile(String profileId) {
|
|
if (profileId.isEmpty) return Future.value(const []);
|
|
return (select(downloadOwners)..where((t) => t.profileId.equals(profileId))).get();
|
|
}
|
|
|
|
Future<void> updateDownloadOwnerScope({
|
|
required String profileId,
|
|
required String globalKey,
|
|
required String backendId,
|
|
required String clientScopeId,
|
|
}) {
|
|
return (update(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).write(
|
|
DownloadOwnersCompanion(backend: Value(backendId), clientScopeId: Value(clientScopeId)),
|
|
);
|
|
}
|
|
|
|
Future<void> updateDownloadedMediaClientScope(String globalKey, String? clientScopeId) {
|
|
return (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
|
DownloadedMediaCompanion(clientScopeId: Value(clientScopeId)),
|
|
);
|
|
}
|
|
|
|
Future<int> getDownloadOwnerCount(String globalKey) async {
|
|
return (await _validDownloadOwnerRows(globalKey)).length;
|
|
}
|
|
|
|
@visibleForTesting
|
|
Future<bool> hasDownloadOwner(String globalKey, {String? excludingProfileId}) async {
|
|
final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId);
|
|
return rows.isNotEmpty;
|
|
}
|
|
|
|
Future<List<DownloadOwnerItem>> getValidDownloadOwnersForKey(String globalKey) {
|
|
return _validDownloadOwnerRows(globalKey);
|
|
}
|
|
|
|
Future<bool> hasDownloadOwnerForCacheScope(
|
|
String globalKey, {
|
|
required String backendId,
|
|
required String clientScopeId,
|
|
}) async {
|
|
final owners = await _validDownloadOwnerRows(globalKey);
|
|
return owners.any((owner) => owner.backend == backendId && owner.clientScopeId == clientScopeId);
|
|
}
|
|
|
|
Future<List<DownloadOwnerItem>> _validDownloadOwnerRows(String globalKey, {String? excludingProfileId}) async {
|
|
final rows = await (select(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).get();
|
|
if (rows.isEmpty) return const [];
|
|
final candidates = rows
|
|
.where((row) => excludingProfileId == null || excludingProfileId.isEmpty || row.profileId != excludingProfileId)
|
|
.toList(growable: false);
|
|
if (candidates.isEmpty) return const [];
|
|
|
|
final localProfileRows = await select(profiles).get();
|
|
final localProfileIds = localProfileRows.map((row) => row.id).toSet();
|
|
final connectionRows = await select(connections).get();
|
|
final connectionIds = connectionRows.map((row) => row.id).toSet();
|
|
return candidates
|
|
.where((row) => _isValidDownloadOwner(row, localProfileIds: localProfileIds, connectionIds: connectionIds))
|
|
.toList(growable: false);
|
|
}
|
|
|
|
/// Claim pre-v17 shared download rows for [profileId]. Rows that already
|
|
/// have any valid owner are left untouched so later profiles do not
|
|
/// inherit them.
|
|
///
|
|
/// Runs on every profile switch — validity context is computed once and
|
|
/// applied in memory instead of the per-download full-table rescan
|
|
/// `getDownloadOwnerCount` would do.
|
|
Future<void> adoptLegacyDownloadsForProfile(String profileId, {bool Function()? isStillActive}) async {
|
|
if (profileId.isEmpty) return;
|
|
if (isStillActive != null && !isStillActive()) return;
|
|
final rows = await select(downloadedMedia).get();
|
|
if (rows.isEmpty) return;
|
|
|
|
final owners = await select(downloadOwners).get();
|
|
final localProfileIds = (await select(profiles).get()).map((row) => row.id).toSet();
|
|
final connectionRows = await select(connections).get();
|
|
final connectionIds = connectionRows.map((row) => row.id).toSet();
|
|
final connectionKindsById = {for (final row in connectionRows) row.id: row.kind};
|
|
final jellyfinIdentities = <String, ({String machineId, String? userId})>{};
|
|
final jellyfinMachineIds = <String>{};
|
|
for (final connection in connectionRows.where((row) => row.kind == 'jellyfin')) {
|
|
final identity = _jellyfinConnectionIdentity(connection);
|
|
jellyfinIdentities[connection.id] = identity;
|
|
jellyfinMachineIds.add(identity.machineId);
|
|
}
|
|
final jellyfinScopesByProfileAndMachine = <String, Map<String, Set<String>>>{};
|
|
for (final binding in await select(profileConnections).get()) {
|
|
if (binding.userIdentifier.isEmpty) continue;
|
|
final identity = jellyfinIdentities[binding.connectionId];
|
|
if (identity == null || identity.userId != null && identity.userId != binding.userIdentifier) continue;
|
|
jellyfinScopesByProfileAndMachine
|
|
.putIfAbsent(binding.profileId, () => <String, Set<String>>{})
|
|
.putIfAbsent(identity.machineId, () => <String>{})
|
|
.add('${identity.machineId}/${binding.userIdentifier}');
|
|
}
|
|
final ownedKeys = <String>{
|
|
for (final owner in owners)
|
|
if (_isValidDownloadOwner(owner, localProfileIds: localProfileIds, connectionIds: connectionIds))
|
|
owner.globalKey,
|
|
};
|
|
for (final row in rows) {
|
|
if (!ownedKeys.contains(row.globalKey)) {
|
|
if (isStillActive != null && !isStillActive()) return;
|
|
final scopeId = row.clientScopeId;
|
|
final plexScope = PlexProfileScopeId.tryParse(scopeId ?? '');
|
|
final transferScope = PlexTransferScopeId.tryParse(scopeId ?? '');
|
|
// A scoped Plex row already identifies the Plezy profile whose token
|
|
// and cache namespace produced it. Logout first moves preserved rows
|
|
// through a sanitized transfer namespace so a new profile can adopt
|
|
// the physical file without inheriting the old profile's watch state.
|
|
if (plexScope != null && plexScope.profileId != profileId) continue;
|
|
if (plexScope != null || transferScope != null) {
|
|
await addDownloadOwner(
|
|
profileId: profileId,
|
|
globalKey: row.globalKey,
|
|
backendId: 'plex',
|
|
clientScopeId: scopeId,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
final jellyfinScopes = jellyfinScopesByProfileAndMachine[profileId]?[row.serverId] ?? const <String>{};
|
|
if (jellyfinScopes.length == 1) {
|
|
final adoptingScope = jellyfinScopes.single;
|
|
await transaction(() async {
|
|
if (isStillActive != null && !isStillActive()) return;
|
|
await updateDownloadedMediaClientScope(row.globalKey, adoptingScope);
|
|
await addDownloadOwner(
|
|
profileId: profileId,
|
|
globalKey: row.globalKey,
|
|
backendId: 'jellyfin',
|
|
clientScopeId: adoptingScope,
|
|
);
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// A compound non-Plex scope is a legacy Jellyfin user namespace.
|
|
// Never attach it to another profile unless that profile has exactly
|
|
// one matching Jellyfin binding. The same applies when persisted
|
|
// Jellyfin connections identify the machine but the profile has zero
|
|
// or multiple possible users.
|
|
final hasLegacyJellyfinScope = scopeId?.startsWith('${row.serverId}/') ?? false;
|
|
if (hasLegacyJellyfinScope || jellyfinMachineIds.contains(row.serverId)) continue;
|
|
|
|
final backendId = connectionKindsById[scopeId];
|
|
await addDownloadOwner(
|
|
profileId: profileId,
|
|
globalKey: row.globalKey,
|
|
backendId: backendId,
|
|
clientScopeId: scopeId,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> insertDownload({
|
|
required ServerId serverId,
|
|
String? clientScopeId,
|
|
required String ratingKey,
|
|
required String globalKey,
|
|
required String type,
|
|
String? parentRatingKey,
|
|
String? grandparentRatingKey,
|
|
required int status,
|
|
int mediaIndex = 0,
|
|
String? mediaSourceId,
|
|
}) async {
|
|
await customUpdate(
|
|
'''
|
|
INSERT INTO downloaded_media (
|
|
server_id,
|
|
client_scope_id,
|
|
rating_key,
|
|
global_key,
|
|
type,
|
|
parent_rating_key,
|
|
grandparent_rating_key,
|
|
status,
|
|
media_index,
|
|
media_source_id
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(global_key) DO UPDATE SET
|
|
server_id = excluded.server_id,
|
|
client_scope_id = excluded.client_scope_id,
|
|
rating_key = excluded.rating_key,
|
|
type = excluded.type,
|
|
parent_rating_key = excluded.parent_rating_key,
|
|
grandparent_rating_key = excluded.grandparent_rating_key,
|
|
status = excluded.status,
|
|
progress = 0,
|
|
total_bytes = NULL,
|
|
downloaded_bytes = 0,
|
|
error_message = NULL,
|
|
retry_count = 0,
|
|
media_index = excluded.media_index,
|
|
media_source_id = excluded.media_source_id
|
|
''',
|
|
variables: [
|
|
Variable<String>(serverId),
|
|
Variable<String>(clientScopeId),
|
|
Variable<String>(ratingKey),
|
|
Variable<String>(globalKey),
|
|
Variable<String>(type),
|
|
Variable<String>(parentRatingKey),
|
|
Variable<String>(grandparentRatingKey),
|
|
Variable<int>(status),
|
|
Variable<int>(mediaIndex),
|
|
Variable<String>(mediaSourceId),
|
|
],
|
|
updates: {downloadedMedia},
|
|
);
|
|
}
|
|
|
|
Future<void> addToQueue({
|
|
required String mediaGlobalKey,
|
|
int priority = 0,
|
|
bool downloadSubtitles = true,
|
|
bool downloadArtwork = true,
|
|
}) async {
|
|
await into(downloadQueue).insert(
|
|
DownloadQueueCompanion.insert(
|
|
mediaGlobalKey: mediaGlobalKey,
|
|
priority: Value(priority),
|
|
addedAt: DateTime.now().millisecondsSinceEpoch,
|
|
downloadSubtitles: Value(downloadSubtitles),
|
|
downloadArtwork: Value(downloadArtwork),
|
|
),
|
|
mode: InsertMode.insertOrReplace,
|
|
);
|
|
}
|
|
|
|
Future<void> updateSupplementaryQueueIntent(
|
|
String mediaGlobalKey, {
|
|
required bool downloadSubtitles,
|
|
required bool downloadArtwork,
|
|
}) async {
|
|
await (update(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).write(
|
|
DownloadQueueCompanion(downloadSubtitles: Value(downloadSubtitles), downloadArtwork: Value(downloadArtwork)),
|
|
);
|
|
}
|
|
|
|
/// Atomically admits a durable media row and its executable queue item.
|
|
///
|
|
/// Existing active, paused, and completed media rows are never rewritten.
|
|
/// Failed, cancelled, and partial attempts keep their stable row identity
|
|
/// and physical-file fields while their request and attempt state is refreshed.
|
|
Future<QueueDownloadOutcome> insertQueuedDownload({
|
|
required ServerId serverId,
|
|
String? clientScopeId,
|
|
required String ratingKey,
|
|
required String globalKey,
|
|
required String type,
|
|
String? parentRatingKey,
|
|
String? grandparentRatingKey,
|
|
int mediaIndex = 0,
|
|
String? mediaSourceId,
|
|
int priority = 0,
|
|
bool downloadSubtitles = true,
|
|
bool downloadArtwork = true,
|
|
}) {
|
|
return transaction(() async {
|
|
final admitted = await customUpdate(
|
|
'''
|
|
INSERT INTO downloaded_media (
|
|
server_id,
|
|
client_scope_id,
|
|
rating_key,
|
|
global_key,
|
|
type,
|
|
parent_rating_key,
|
|
grandparent_rating_key,
|
|
status,
|
|
media_index,
|
|
media_source_id
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(global_key) DO UPDATE SET
|
|
server_id = excluded.server_id,
|
|
client_scope_id = excluded.client_scope_id,
|
|
rating_key = excluded.rating_key,
|
|
type = excluded.type,
|
|
parent_rating_key = excluded.parent_rating_key,
|
|
grandparent_rating_key = excluded.grandparent_rating_key,
|
|
status = excluded.status,
|
|
progress = 0,
|
|
total_bytes = NULL,
|
|
downloaded_bytes = 0,
|
|
error_message = NULL,
|
|
retry_count = 0,
|
|
bg_task_id = NULL,
|
|
media_index = excluded.media_index,
|
|
media_source_id = excluded.media_source_id
|
|
WHERE downloaded_media.status IN (?, ?, ?)
|
|
''',
|
|
variables: [
|
|
Variable<String>(serverId),
|
|
Variable<String>(clientScopeId),
|
|
Variable<String>(ratingKey),
|
|
Variable<String>(globalKey),
|
|
Variable<String>(type),
|
|
Variable<String>(parentRatingKey),
|
|
Variable<String>(grandparentRatingKey),
|
|
Variable<int>(DownloadStatus.queued.index),
|
|
Variable<int>(mediaIndex),
|
|
Variable<String>(mediaSourceId),
|
|
Variable<int>(DownloadStatus.failed.index),
|
|
Variable<int>(DownloadStatus.cancelled.index),
|
|
Variable<int>(DownloadStatus.partial.index),
|
|
],
|
|
updates: {downloadedMedia},
|
|
);
|
|
|
|
if (admitted > 0) {
|
|
await addToQueue(
|
|
mediaGlobalKey: globalKey,
|
|
priority: priority,
|
|
downloadSubtitles: downloadSubtitles,
|
|
downloadArtwork: downloadArtwork,
|
|
);
|
|
return QueueDownloadOutcome.admitted;
|
|
}
|
|
|
|
final current = await getDownloadedMedia(globalKey);
|
|
if (current?.status == DownloadStatus.queued.index) {
|
|
await addToQueue(
|
|
mediaGlobalKey: globalKey,
|
|
priority: priority,
|
|
downloadSubtitles: downloadSubtitles,
|
|
downloadArtwork: downloadArtwork,
|
|
);
|
|
return QueueDownloadOutcome.alreadyQueued;
|
|
}
|
|
return QueueDownloadOutcome.unchanged;
|
|
});
|
|
}
|
|
|
|
/// Restores queue items omitted by legacy non-atomic queue creation.
|
|
///
|
|
/// Existing queue rows are never rewritten because they retain the original
|
|
/// priority and supplementary-download policy.
|
|
Future<int> repairMissingQueuedDownloadEntries() async {
|
|
return transaction(() async {
|
|
final queuedMedia = await (select(
|
|
downloadedMedia,
|
|
)..where((t) => t.status.equals(DownloadStatus.queued.index))).get();
|
|
if (queuedMedia.isEmpty) return 0;
|
|
|
|
final existingKeys = (await select(downloadQueue).get()).map((row) => row.mediaGlobalKey).toSet();
|
|
var repaired = 0;
|
|
for (final media in queuedMedia) {
|
|
if (existingKeys.contains(media.globalKey)) continue;
|
|
await addToQueue(mediaGlobalKey: media.globalKey);
|
|
existingKeys.add(media.globalKey);
|
|
repaired++;
|
|
}
|
|
return repaired;
|
|
});
|
|
}
|
|
|
|
/// Get next item from queue (highest priority, oldest first)
|
|
/// Only returns items that are not paused
|
|
Future<DownloadQueueItem?> getNextQueueItem() async {
|
|
final query = select(
|
|
downloadQueue,
|
|
).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]);
|
|
|
|
query
|
|
..where(downloadedMedia.status.equals(DownloadStatus.queued.index))
|
|
..orderBy([
|
|
OrderingTerm(expression: downloadQueue.priority, mode: OrderingMode.desc),
|
|
OrderingTerm(expression: downloadQueue.addedAt),
|
|
])
|
|
..limit(1);
|
|
|
|
final result = await query.getSingleOrNull();
|
|
return result?.readTable(downloadQueue);
|
|
}
|
|
|
|
/// Completed videos whose retained queue row records unsettled
|
|
/// supplementary download intent.
|
|
Future<List<DownloadQueueItem>> getPendingSupplementaryQueueItems() async {
|
|
final query = select(
|
|
downloadQueue,
|
|
).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]);
|
|
query
|
|
..where(downloadedMedia.status.equals(DownloadStatus.completed.index) & downloadedMedia.videoFilePath.isNotNull())
|
|
..orderBy([OrderingTerm(expression: downloadQueue.addedAt)]);
|
|
final rows = await query.get();
|
|
return rows.map((row) => row.readTable(downloadQueue)).toList(growable: false);
|
|
}
|
|
|
|
/// Fails every queued or active download after the target filesystem fills.
|
|
///
|
|
/// Clearing native task ids and queue rows prevents startup recovery from
|
|
/// immediately re-enqueueing work after partial files have been discarded.
|
|
Future<int> failActiveDownloadsForStorageFull(String errorMessage) {
|
|
return transaction(() async {
|
|
final active = await (select(
|
|
downloadedMedia,
|
|
)..where((row) => row.status.isIn([DownloadStatus.queued.index, DownloadStatus.downloading.index]))).get();
|
|
if (active.isEmpty) return 0;
|
|
|
|
final globalKeys = active.map((item) => item.globalKey).toList(growable: false);
|
|
await (update(downloadedMedia)..where((row) => row.globalKey.isIn(globalKeys))).write(
|
|
DownloadedMediaCompanion(
|
|
status: Value(DownloadStatus.failed.index),
|
|
bgTaskId: const Value(null),
|
|
errorMessage: Value(errorMessage),
|
|
),
|
|
);
|
|
await (delete(downloadQueue)..where((row) => row.mediaGlobalKey.isIn(globalKeys))).go();
|
|
return globalKeys.length;
|
|
});
|
|
}
|
|
|
|
Future<void> updateDownloadStatus(String globalKey, int status) async {
|
|
await (update(
|
|
downloadedMedia,
|
|
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(status: Value(status)));
|
|
}
|
|
|
|
Future<void> updateDownloadMediaSource(String globalKey, String? mediaSourceId) async {
|
|
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
|
DownloadedMediaCompanion(mediaSourceId: Value(mediaSourceId)),
|
|
);
|
|
}
|
|
|
|
Future<void> updateDownloadProgress(String globalKey, int progress, int downloadedBytes, int totalBytes) async {
|
|
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
|
DownloadedMediaCompanion(
|
|
progress: Value(progress),
|
|
downloadedBytes: Value(downloadedBytes),
|
|
totalBytes: Value(totalBytes),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> updateVideoFilePath(String globalKey, String filePath) async {
|
|
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
|
DownloadedMediaCompanion(
|
|
videoFilePath: Value(filePath),
|
|
downloadedAt: Value(DateTime.now().millisecondsSinceEpoch),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> updateDownloadSafRoot(String globalKey, String? safRootUri) async {
|
|
await (update(
|
|
downloadedMedia,
|
|
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(safRootUri: Value(safRootUri)));
|
|
}
|
|
|
|
Future<int> countDownloadsReferencingSafRoot(String safRootUri) async {
|
|
final count = downloadedMedia.id.count();
|
|
final query = selectOnly(downloadedMedia)
|
|
..addColumns([count])
|
|
..where(downloadedMedia.safRootUri.equals(safRootUri));
|
|
return (await query.map((row) => row.read(count) ?? 0).getSingle());
|
|
}
|
|
|
|
@visibleForTesting
|
|
Future<Set<String>> getReferencedDownloadSafRoots() async {
|
|
final rows =
|
|
await (selectOnly(downloadedMedia)
|
|
..addColumns([downloadedMedia.safRootUri])
|
|
..where(downloadedMedia.safRootUri.isNotNull()))
|
|
.map((row) => row.read(downloadedMedia.safRootUri))
|
|
.get();
|
|
return rows.whereType<String>().toSet();
|
|
}
|
|
|
|
Future<void> updateArtworkPaths({required String globalKey, String? thumbPath}) async {
|
|
await (update(
|
|
downloadedMedia,
|
|
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(thumbPath: Value(thumbPath)));
|
|
}
|
|
|
|
Future<void> updateDownloadError(String globalKey, String errorMessage) async {
|
|
final existing = await getDownloadedMedia(globalKey);
|
|
final currentCount = existing?.retryCount ?? 0;
|
|
|
|
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
|
DownloadedMediaCompanion(errorMessage: Value(errorMessage), retryCount: Value(currentCount + 1)),
|
|
);
|
|
}
|
|
|
|
Future<void> clearDownloadError(String globalKey) async {
|
|
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
|
const DownloadedMediaCompanion(errorMessage: Value(null), retryCount: Value(0)),
|
|
);
|
|
}
|
|
|
|
Future<void> removeFromQueue(String mediaGlobalKey) async {
|
|
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).go();
|
|
}
|
|
|
|
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) {
|
|
return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
|
|
}
|
|
|
|
/// Removes the physical row and all dependent queue/owner state atomically,
|
|
/// returning the row's SAF root only after the transaction commits.
|
|
///
|
|
/// The caller owns persisted-grant reconciliation after this returns.
|
|
Future<String?> deleteDownload(String globalKey) async {
|
|
late String? safRootUri;
|
|
await transaction(() async {
|
|
safRootUri = (await getDownloadedMedia(globalKey))?.safRootUri;
|
|
await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go();
|
|
await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
|
|
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
|
|
});
|
|
return safRootUri;
|
|
}
|
|
|
|
Future<List<DownloadedMediaItem>> getEpisodesBySeason(
|
|
String seasonKey, {
|
|
ServerId? serverId,
|
|
String? clientScopeId,
|
|
bool filterClientScope = false,
|
|
}) {
|
|
return (select(downloadedMedia)..where(
|
|
(t) =>
|
|
t.parentRatingKey.equals(seasonKey) &
|
|
_optionalServerPredicate(t.serverId, serverIdOrNull(serverId)) &
|
|
_optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
|
|
))
|
|
.get();
|
|
}
|
|
|
|
Future<List<DownloadedMediaItem>> getEpisodesByShow(
|
|
String showKey, {
|
|
ServerId? serverId,
|
|
String? clientScopeId,
|
|
bool filterClientScope = false,
|
|
}) {
|
|
return (select(downloadedMedia)..where(
|
|
(t) =>
|
|
t.grandparentRatingKey.equals(showKey) &
|
|
_optionalServerPredicate(t.serverId, serverIdOrNull(serverId)) &
|
|
_optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
|
|
))
|
|
.get();
|
|
}
|
|
|
|
/// Downloaded tracks belonging to an album (parentRatingKey). Mirrors
|
|
/// [getEpisodesBySeason] but filters on type so an id collision with a
|
|
/// season key can never mix media kinds.
|
|
Future<List<DownloadedMediaItem>> getTracksByAlbum(
|
|
String albumKey, {
|
|
ServerId? serverId,
|
|
String? clientScopeId,
|
|
bool filterClientScope = false,
|
|
}) {
|
|
return (select(downloadedMedia)..where(
|
|
(t) =>
|
|
t.type.equals('track') &
|
|
t.parentRatingKey.equals(albumKey) &
|
|
_optionalServerPredicate(t.serverId, serverIdOrNull(serverId)) &
|
|
_optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
|
|
))
|
|
.get();
|
|
}
|
|
|
|
/// Downloaded tracks belonging to an artist (grandparentRatingKey). Mirrors
|
|
/// [getEpisodesByShow] with the same type filter as [getTracksByAlbum].
|
|
Future<List<DownloadedMediaItem>> getTracksByArtist(
|
|
String artistKey, {
|
|
ServerId? serverId,
|
|
String? clientScopeId,
|
|
bool filterClientScope = false,
|
|
}) {
|
|
return (select(downloadedMedia)..where(
|
|
(t) =>
|
|
t.type.equals('track') &
|
|
t.grandparentRatingKey.equals(artistKey) &
|
|
_optionalServerPredicate(t.serverId, serverIdOrNull(serverId)) &
|
|
_optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
|
|
))
|
|
.get();
|
|
}
|
|
|
|
Future<List<DownloadedMediaItem>> getDownloadsByServerId(ServerId serverId) {
|
|
return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get();
|
|
}
|
|
|
|
Expression<bool> _optionalServerPredicate(GeneratedColumn<String> column, ServerId? serverId) {
|
|
return serverId == null ? const Constant(true) : column.equals(serverId);
|
|
}
|
|
|
|
Expression<bool> _optionalClientScopePredicate(
|
|
GeneratedColumn<String> column,
|
|
String? clientScopeId, {
|
|
required bool filterClientScope,
|
|
}) {
|
|
if (!filterClientScope && (clientScopeId == null || clientScopeId.isEmpty)) {
|
|
return const Constant(true);
|
|
}
|
|
if (clientScopeId == null || clientScopeId.isEmpty) {
|
|
return column.isNull() | column.equals('');
|
|
}
|
|
return column.equals(clientScopeId);
|
|
}
|
|
|
|
Future<void> updateBgTaskId(String globalKey, String? taskId) async {
|
|
await (update(
|
|
downloadedMedia,
|
|
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(bgTaskId: Value(taskId)));
|
|
}
|
|
|
|
Future<String?> getBgTaskId(String globalKey) async {
|
|
final item = await getDownloadedMedia(globalKey);
|
|
return item?.bgTaskId;
|
|
}
|
|
}
|
|
|
|
bool _isValidDownloadOwner(
|
|
DownloadOwnerItem owner, {
|
|
required Set<String> localProfileIds,
|
|
required Set<String> connectionIds,
|
|
}) {
|
|
if (localProfileIds.contains(owner.profileId)) return true;
|
|
final plexHome = parsePlexHomeProfileId(owner.profileId);
|
|
if (plexHome != null) return connectionIds.contains(plexHome.accountConnectionId);
|
|
return localProfileIds.isEmpty;
|
|
}
|
|
|
|
({String machineId, String? userId}) _jellyfinConnectionIdentity(ConnectionRow connection) {
|
|
final separator = connection.id.indexOf('/');
|
|
var machineId = separator < 0 ? connection.id : connection.id.substring(0, separator);
|
|
String? userId = separator < 0 || separator == connection.id.length - 1
|
|
? null
|
|
: connection.id.substring(separator + 1);
|
|
try {
|
|
final config = jsonDecode(connection.configJson);
|
|
if (config is Map<String, dynamic>) {
|
|
final configuredMachineId = config['serverMachineId'];
|
|
final configuredUserId = config['userId'];
|
|
if (configuredMachineId is String && configuredMachineId.isNotEmpty) machineId = configuredMachineId;
|
|
if (configuredUserId is String && configuredUserId.isNotEmpty) userId = configuredUserId;
|
|
}
|
|
} on FormatException {
|
|
// Legacy rows still carry enough identity in their canonical id.
|
|
}
|
|
return (machineId: machineId, userId: userId);
|
|
}
|