fix(runtime): harden application service boundaries
This commit is contained in:
+902
-123
File diff suppressed because it is too large
Load Diff
@@ -153,6 +153,17 @@ class $DownloadedMediaTable extends DownloadedMedia
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _safRootUriMeta = const VerificationMeta(
|
||||
'safRootUri',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> safRootUri = GeneratedColumn<String>(
|
||||
'saf_root_uri',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _thumbPathMeta = const VerificationMeta(
|
||||
'thumbPath',
|
||||
);
|
||||
@@ -247,6 +258,7 @@ class $DownloadedMediaTable extends DownloadedMedia
|
||||
totalBytes,
|
||||
downloadedBytes,
|
||||
videoFilePath,
|
||||
safRootUri,
|
||||
thumbPath,
|
||||
downloadedAt,
|
||||
errorMessage,
|
||||
@@ -367,6 +379,15 @@ class $DownloadedMediaTable extends DownloadedMedia
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('saf_root_uri')) {
|
||||
context.handle(
|
||||
_safRootUriMeta,
|
||||
safRootUri.isAcceptableOrUnknown(
|
||||
data['saf_root_uri']!,
|
||||
_safRootUriMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('thumb_path')) {
|
||||
context.handle(
|
||||
_thumbPathMeta,
|
||||
@@ -479,6 +500,10 @@ class $DownloadedMediaTable extends DownloadedMedia
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}video_file_path'],
|
||||
),
|
||||
safRootUri: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}saf_root_uri'],
|
||||
),
|
||||
thumbPath: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}thumb_path'],
|
||||
@@ -531,6 +556,7 @@ class DownloadedMediaItem extends DataClass
|
||||
final int? totalBytes;
|
||||
final int downloadedBytes;
|
||||
final String? videoFilePath;
|
||||
final String? safRootUri;
|
||||
final String? thumbPath;
|
||||
final int? downloadedAt;
|
||||
final String? errorMessage;
|
||||
@@ -552,6 +578,7 @@ class DownloadedMediaItem extends DataClass
|
||||
this.totalBytes,
|
||||
required this.downloadedBytes,
|
||||
this.videoFilePath,
|
||||
this.safRootUri,
|
||||
this.thumbPath,
|
||||
this.downloadedAt,
|
||||
this.errorMessage,
|
||||
@@ -586,6 +613,9 @@ class DownloadedMediaItem extends DataClass
|
||||
if (!nullToAbsent || videoFilePath != null) {
|
||||
map['video_file_path'] = Variable<String>(videoFilePath);
|
||||
}
|
||||
if (!nullToAbsent || safRootUri != null) {
|
||||
map['saf_root_uri'] = Variable<String>(safRootUri);
|
||||
}
|
||||
if (!nullToAbsent || thumbPath != null) {
|
||||
map['thumb_path'] = Variable<String>(thumbPath);
|
||||
}
|
||||
@@ -631,6 +661,9 @@ class DownloadedMediaItem extends DataClass
|
||||
videoFilePath: videoFilePath == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(videoFilePath),
|
||||
safRootUri: safRootUri == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(safRootUri),
|
||||
thumbPath: thumbPath == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(thumbPath),
|
||||
@@ -672,6 +705,7 @@ class DownloadedMediaItem extends DataClass
|
||||
totalBytes: serializer.fromJson<int?>(json['totalBytes']),
|
||||
downloadedBytes: serializer.fromJson<int>(json['downloadedBytes']),
|
||||
videoFilePath: serializer.fromJson<String?>(json['videoFilePath']),
|
||||
safRootUri: serializer.fromJson<String?>(json['safRootUri']),
|
||||
thumbPath: serializer.fromJson<String?>(json['thumbPath']),
|
||||
downloadedAt: serializer.fromJson<int?>(json['downloadedAt']),
|
||||
errorMessage: serializer.fromJson<String?>(json['errorMessage']),
|
||||
@@ -698,6 +732,7 @@ class DownloadedMediaItem extends DataClass
|
||||
'totalBytes': serializer.toJson<int?>(totalBytes),
|
||||
'downloadedBytes': serializer.toJson<int>(downloadedBytes),
|
||||
'videoFilePath': serializer.toJson<String?>(videoFilePath),
|
||||
'safRootUri': serializer.toJson<String?>(safRootUri),
|
||||
'thumbPath': serializer.toJson<String?>(thumbPath),
|
||||
'downloadedAt': serializer.toJson<int?>(downloadedAt),
|
||||
'errorMessage': serializer.toJson<String?>(errorMessage),
|
||||
@@ -722,6 +757,7 @@ class DownloadedMediaItem extends DataClass
|
||||
Value<int?> totalBytes = const Value.absent(),
|
||||
int? downloadedBytes,
|
||||
Value<String?> videoFilePath = const Value.absent(),
|
||||
Value<String?> safRootUri = const Value.absent(),
|
||||
Value<String?> thumbPath = const Value.absent(),
|
||||
Value<int?> downloadedAt = const Value.absent(),
|
||||
Value<String?> errorMessage = const Value.absent(),
|
||||
@@ -751,6 +787,7 @@ class DownloadedMediaItem extends DataClass
|
||||
videoFilePath: videoFilePath.present
|
||||
? videoFilePath.value
|
||||
: this.videoFilePath,
|
||||
safRootUri: safRootUri.present ? safRootUri.value : this.safRootUri,
|
||||
thumbPath: thumbPath.present ? thumbPath.value : this.thumbPath,
|
||||
downloadedAt: downloadedAt.present ? downloadedAt.value : this.downloadedAt,
|
||||
errorMessage: errorMessage.present ? errorMessage.value : this.errorMessage,
|
||||
@@ -788,6 +825,9 @@ class DownloadedMediaItem extends DataClass
|
||||
videoFilePath: data.videoFilePath.present
|
||||
? data.videoFilePath.value
|
||||
: this.videoFilePath,
|
||||
safRootUri: data.safRootUri.present
|
||||
? data.safRootUri.value
|
||||
: this.safRootUri,
|
||||
thumbPath: data.thumbPath.present ? data.thumbPath.value : this.thumbPath,
|
||||
downloadedAt: data.downloadedAt.present
|
||||
? data.downloadedAt.value
|
||||
@@ -824,6 +864,7 @@ class DownloadedMediaItem extends DataClass
|
||||
..write('totalBytes: $totalBytes, ')
|
||||
..write('downloadedBytes: $downloadedBytes, ')
|
||||
..write('videoFilePath: $videoFilePath, ')
|
||||
..write('safRootUri: $safRootUri, ')
|
||||
..write('thumbPath: $thumbPath, ')
|
||||
..write('downloadedAt: $downloadedAt, ')
|
||||
..write('errorMessage: $errorMessage, ')
|
||||
@@ -836,7 +877,7 @@ class DownloadedMediaItem extends DataClass
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
int get hashCode => Object.hashAll([
|
||||
id,
|
||||
serverId,
|
||||
clientScopeId,
|
||||
@@ -850,6 +891,7 @@ class DownloadedMediaItem extends DataClass
|
||||
totalBytes,
|
||||
downloadedBytes,
|
||||
videoFilePath,
|
||||
safRootUri,
|
||||
thumbPath,
|
||||
downloadedAt,
|
||||
errorMessage,
|
||||
@@ -857,7 +899,7 @@ class DownloadedMediaItem extends DataClass
|
||||
bgTaskId,
|
||||
mediaIndex,
|
||||
mediaSourceId,
|
||||
);
|
||||
]);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
@@ -875,6 +917,7 @@ class DownloadedMediaItem extends DataClass
|
||||
other.totalBytes == this.totalBytes &&
|
||||
other.downloadedBytes == this.downloadedBytes &&
|
||||
other.videoFilePath == this.videoFilePath &&
|
||||
other.safRootUri == this.safRootUri &&
|
||||
other.thumbPath == this.thumbPath &&
|
||||
other.downloadedAt == this.downloadedAt &&
|
||||
other.errorMessage == this.errorMessage &&
|
||||
@@ -898,6 +941,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
final Value<int?> totalBytes;
|
||||
final Value<int> downloadedBytes;
|
||||
final Value<String?> videoFilePath;
|
||||
final Value<String?> safRootUri;
|
||||
final Value<String?> thumbPath;
|
||||
final Value<int?> downloadedAt;
|
||||
final Value<String?> errorMessage;
|
||||
@@ -919,6 +963,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
this.totalBytes = const Value.absent(),
|
||||
this.downloadedBytes = const Value.absent(),
|
||||
this.videoFilePath = const Value.absent(),
|
||||
this.safRootUri = const Value.absent(),
|
||||
this.thumbPath = const Value.absent(),
|
||||
this.downloadedAt = const Value.absent(),
|
||||
this.errorMessage = const Value.absent(),
|
||||
@@ -941,6 +986,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
this.totalBytes = const Value.absent(),
|
||||
this.downloadedBytes = const Value.absent(),
|
||||
this.videoFilePath = const Value.absent(),
|
||||
this.safRootUri = const Value.absent(),
|
||||
this.thumbPath = const Value.absent(),
|
||||
this.downloadedAt = const Value.absent(),
|
||||
this.errorMessage = const Value.absent(),
|
||||
@@ -967,6 +1013,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
Expression<int>? totalBytes,
|
||||
Expression<int>? downloadedBytes,
|
||||
Expression<String>? videoFilePath,
|
||||
Expression<String>? safRootUri,
|
||||
Expression<String>? thumbPath,
|
||||
Expression<int>? downloadedAt,
|
||||
Expression<String>? errorMessage,
|
||||
@@ -990,6 +1037,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
if (totalBytes != null) 'total_bytes': totalBytes,
|
||||
if (downloadedBytes != null) 'downloaded_bytes': downloadedBytes,
|
||||
if (videoFilePath != null) 'video_file_path': videoFilePath,
|
||||
if (safRootUri != null) 'saf_root_uri': safRootUri,
|
||||
if (thumbPath != null) 'thumb_path': thumbPath,
|
||||
if (downloadedAt != null) 'downloaded_at': downloadedAt,
|
||||
if (errorMessage != null) 'error_message': errorMessage,
|
||||
@@ -1014,6 +1062,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
Value<int?>? totalBytes,
|
||||
Value<int>? downloadedBytes,
|
||||
Value<String?>? videoFilePath,
|
||||
Value<String?>? safRootUri,
|
||||
Value<String?>? thumbPath,
|
||||
Value<int?>? downloadedAt,
|
||||
Value<String?>? errorMessage,
|
||||
@@ -1036,6 +1085,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
totalBytes: totalBytes ?? this.totalBytes,
|
||||
downloadedBytes: downloadedBytes ?? this.downloadedBytes,
|
||||
videoFilePath: videoFilePath ?? this.videoFilePath,
|
||||
safRootUri: safRootUri ?? this.safRootUri,
|
||||
thumbPath: thumbPath ?? this.thumbPath,
|
||||
downloadedAt: downloadedAt ?? this.downloadedAt,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
@@ -1090,6 +1140,9 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
if (videoFilePath.present) {
|
||||
map['video_file_path'] = Variable<String>(videoFilePath.value);
|
||||
}
|
||||
if (safRootUri.present) {
|
||||
map['saf_root_uri'] = Variable<String>(safRootUri.value);
|
||||
}
|
||||
if (thumbPath.present) {
|
||||
map['thumb_path'] = Variable<String>(thumbPath.value);
|
||||
}
|
||||
@@ -1130,6 +1183,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
..write('totalBytes: $totalBytes, ')
|
||||
..write('downloadedBytes: $downloadedBytes, ')
|
||||
..write('videoFilePath: $videoFilePath, ')
|
||||
..write('safRootUri: $safRootUri, ')
|
||||
..write('thumbPath: $thumbPath, ')
|
||||
..write('downloadedAt: $downloadedAt, ')
|
||||
..write('errorMessage: $errorMessage, ')
|
||||
@@ -1170,6 +1224,28 @@ class $DownloadOwnersTable extends DownloadOwners
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _backendMeta = const VerificationMeta(
|
||||
'backend',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> backend = GeneratedColumn<String>(
|
||||
'backend',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _clientScopeIdMeta = const VerificationMeta(
|
||||
'clientScopeId',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> clientScopeId = GeneratedColumn<String>(
|
||||
'client_scope_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _createdAtMeta = const VerificationMeta(
|
||||
'createdAt',
|
||||
);
|
||||
@@ -1182,7 +1258,13 @@ class $DownloadOwnersTable extends DownloadOwners
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> get $columns => [profileId, globalKey, createdAt];
|
||||
List<GeneratedColumn> get $columns => [
|
||||
profileId,
|
||||
globalKey,
|
||||
backend,
|
||||
clientScopeId,
|
||||
createdAt,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
@@ -1211,6 +1293,21 @@ class $DownloadOwnersTable extends DownloadOwners
|
||||
} else if (isInserting) {
|
||||
context.missing(_globalKeyMeta);
|
||||
}
|
||||
if (data.containsKey('backend')) {
|
||||
context.handle(
|
||||
_backendMeta,
|
||||
backend.isAcceptableOrUnknown(data['backend']!, _backendMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('client_scope_id')) {
|
||||
context.handle(
|
||||
_clientScopeIdMeta,
|
||||
clientScopeId.isAcceptableOrUnknown(
|
||||
data['client_scope_id']!,
|
||||
_clientScopeIdMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('created_at')) {
|
||||
context.handle(
|
||||
_createdAtMeta,
|
||||
@@ -1236,6 +1333,14 @@ class $DownloadOwnersTable extends DownloadOwners
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}global_key'],
|
||||
)!,
|
||||
backend: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}backend'],
|
||||
),
|
||||
clientScopeId: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}client_scope_id'],
|
||||
),
|
||||
createdAt: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}created_at'],
|
||||
@@ -1253,10 +1358,14 @@ class DownloadOwnerItem extends DataClass
|
||||
implements Insertable<DownloadOwnerItem> {
|
||||
final String profileId;
|
||||
final String globalKey;
|
||||
final String? backend;
|
||||
final String? clientScopeId;
|
||||
final int createdAt;
|
||||
const DownloadOwnerItem({
|
||||
required this.profileId,
|
||||
required this.globalKey,
|
||||
this.backend,
|
||||
this.clientScopeId,
|
||||
required this.createdAt,
|
||||
});
|
||||
@override
|
||||
@@ -1264,6 +1373,12 @@ class DownloadOwnerItem extends DataClass
|
||||
final map = <String, Expression>{};
|
||||
map['profile_id'] = Variable<String>(profileId);
|
||||
map['global_key'] = Variable<String>(globalKey);
|
||||
if (!nullToAbsent || backend != null) {
|
||||
map['backend'] = Variable<String>(backend);
|
||||
}
|
||||
if (!nullToAbsent || clientScopeId != null) {
|
||||
map['client_scope_id'] = Variable<String>(clientScopeId);
|
||||
}
|
||||
map['created_at'] = Variable<int>(createdAt);
|
||||
return map;
|
||||
}
|
||||
@@ -1272,6 +1387,12 @@ class DownloadOwnerItem extends DataClass
|
||||
return DownloadOwnersCompanion(
|
||||
profileId: Value(profileId),
|
||||
globalKey: Value(globalKey),
|
||||
backend: backend == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(backend),
|
||||
clientScopeId: clientScopeId == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(clientScopeId),
|
||||
createdAt: Value(createdAt),
|
||||
);
|
||||
}
|
||||
@@ -1284,6 +1405,8 @@ class DownloadOwnerItem extends DataClass
|
||||
return DownloadOwnerItem(
|
||||
profileId: serializer.fromJson<String>(json['profileId']),
|
||||
globalKey: serializer.fromJson<String>(json['globalKey']),
|
||||
backend: serializer.fromJson<String?>(json['backend']),
|
||||
clientScopeId: serializer.fromJson<String?>(json['clientScopeId']),
|
||||
createdAt: serializer.fromJson<int>(json['createdAt']),
|
||||
);
|
||||
}
|
||||
@@ -1293,6 +1416,8 @@ class DownloadOwnerItem extends DataClass
|
||||
return <String, dynamic>{
|
||||
'profileId': serializer.toJson<String>(profileId),
|
||||
'globalKey': serializer.toJson<String>(globalKey),
|
||||
'backend': serializer.toJson<String?>(backend),
|
||||
'clientScopeId': serializer.toJson<String?>(clientScopeId),
|
||||
'createdAt': serializer.toJson<int>(createdAt),
|
||||
};
|
||||
}
|
||||
@@ -1300,16 +1425,26 @@ class DownloadOwnerItem extends DataClass
|
||||
DownloadOwnerItem copyWith({
|
||||
String? profileId,
|
||||
String? globalKey,
|
||||
Value<String?> backend = const Value.absent(),
|
||||
Value<String?> clientScopeId = const Value.absent(),
|
||||
int? createdAt,
|
||||
}) => DownloadOwnerItem(
|
||||
profileId: profileId ?? this.profileId,
|
||||
globalKey: globalKey ?? this.globalKey,
|
||||
backend: backend.present ? backend.value : this.backend,
|
||||
clientScopeId: clientScopeId.present
|
||||
? clientScopeId.value
|
||||
: this.clientScopeId,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
DownloadOwnerItem copyWithCompanion(DownloadOwnersCompanion data) {
|
||||
return DownloadOwnerItem(
|
||||
profileId: data.profileId.present ? data.profileId.value : this.profileId,
|
||||
globalKey: data.globalKey.present ? data.globalKey.value : this.globalKey,
|
||||
backend: data.backend.present ? data.backend.value : this.backend,
|
||||
clientScopeId: data.clientScopeId.present
|
||||
? data.clientScopeId.value
|
||||
: this.clientScopeId,
|
||||
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
|
||||
);
|
||||
}
|
||||
@@ -1319,36 +1454,47 @@ class DownloadOwnerItem extends DataClass
|
||||
return (StringBuffer('DownloadOwnerItem(')
|
||||
..write('profileId: $profileId, ')
|
||||
..write('globalKey: $globalKey, ')
|
||||
..write('backend: $backend, ')
|
||||
..write('clientScopeId: $clientScopeId, ')
|
||||
..write('createdAt: $createdAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(profileId, globalKey, createdAt);
|
||||
int get hashCode =>
|
||||
Object.hash(profileId, globalKey, backend, clientScopeId, createdAt);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is DownloadOwnerItem &&
|
||||
other.profileId == this.profileId &&
|
||||
other.globalKey == this.globalKey &&
|
||||
other.backend == this.backend &&
|
||||
other.clientScopeId == this.clientScopeId &&
|
||||
other.createdAt == this.createdAt);
|
||||
}
|
||||
|
||||
class DownloadOwnersCompanion extends UpdateCompanion<DownloadOwnerItem> {
|
||||
final Value<String> profileId;
|
||||
final Value<String> globalKey;
|
||||
final Value<String?> backend;
|
||||
final Value<String?> clientScopeId;
|
||||
final Value<int> createdAt;
|
||||
final Value<int> rowid;
|
||||
const DownloadOwnersCompanion({
|
||||
this.profileId = const Value.absent(),
|
||||
this.globalKey = const Value.absent(),
|
||||
this.backend = const Value.absent(),
|
||||
this.clientScopeId = const Value.absent(),
|
||||
this.createdAt = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
});
|
||||
DownloadOwnersCompanion.insert({
|
||||
required String profileId,
|
||||
required String globalKey,
|
||||
this.backend = const Value.absent(),
|
||||
this.clientScopeId = const Value.absent(),
|
||||
required int createdAt,
|
||||
this.rowid = const Value.absent(),
|
||||
}) : profileId = Value(profileId),
|
||||
@@ -1357,12 +1503,16 @@ class DownloadOwnersCompanion extends UpdateCompanion<DownloadOwnerItem> {
|
||||
static Insertable<DownloadOwnerItem> custom({
|
||||
Expression<String>? profileId,
|
||||
Expression<String>? globalKey,
|
||||
Expression<String>? backend,
|
||||
Expression<String>? clientScopeId,
|
||||
Expression<int>? createdAt,
|
||||
Expression<int>? rowid,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
if (profileId != null) 'profile_id': profileId,
|
||||
if (globalKey != null) 'global_key': globalKey,
|
||||
if (backend != null) 'backend': backend,
|
||||
if (clientScopeId != null) 'client_scope_id': clientScopeId,
|
||||
if (createdAt != null) 'created_at': createdAt,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
});
|
||||
@@ -1371,12 +1521,16 @@ class DownloadOwnersCompanion extends UpdateCompanion<DownloadOwnerItem> {
|
||||
DownloadOwnersCompanion copyWith({
|
||||
Value<String>? profileId,
|
||||
Value<String>? globalKey,
|
||||
Value<String?>? backend,
|
||||
Value<String?>? clientScopeId,
|
||||
Value<int>? createdAt,
|
||||
Value<int>? rowid,
|
||||
}) {
|
||||
return DownloadOwnersCompanion(
|
||||
profileId: profileId ?? this.profileId,
|
||||
globalKey: globalKey ?? this.globalKey,
|
||||
backend: backend ?? this.backend,
|
||||
clientScopeId: clientScopeId ?? this.clientScopeId,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
rowid: rowid ?? this.rowid,
|
||||
);
|
||||
@@ -1391,6 +1545,12 @@ class DownloadOwnersCompanion extends UpdateCompanion<DownloadOwnerItem> {
|
||||
if (globalKey.present) {
|
||||
map['global_key'] = Variable<String>(globalKey.value);
|
||||
}
|
||||
if (backend.present) {
|
||||
map['backend'] = Variable<String>(backend.value);
|
||||
}
|
||||
if (clientScopeId.present) {
|
||||
map['client_scope_id'] = Variable<String>(clientScopeId.value);
|
||||
}
|
||||
if (createdAt.present) {
|
||||
map['created_at'] = Variable<int>(createdAt.value);
|
||||
}
|
||||
@@ -1405,6 +1565,8 @@ class DownloadOwnersCompanion extends UpdateCompanion<DownloadOwnerItem> {
|
||||
return (StringBuffer('DownloadOwnersCompanion(')
|
||||
..write('profileId: $profileId, ')
|
||||
..write('globalKey: $globalKey, ')
|
||||
..write('backend: $backend, ')
|
||||
..write('clientScopeId: $clientScopeId, ')
|
||||
..write('createdAt: $createdAt, ')
|
||||
..write('rowid: $rowid')
|
||||
..write(')'))
|
||||
@@ -5423,6 +5585,7 @@ typedef $$DownloadedMediaTableCreateCompanionBuilder =
|
||||
Value<int?> totalBytes,
|
||||
Value<int> downloadedBytes,
|
||||
Value<String?> videoFilePath,
|
||||
Value<String?> safRootUri,
|
||||
Value<String?> thumbPath,
|
||||
Value<int?> downloadedAt,
|
||||
Value<String?> errorMessage,
|
||||
@@ -5446,6 +5609,7 @@ typedef $$DownloadedMediaTableUpdateCompanionBuilder =
|
||||
Value<int?> totalBytes,
|
||||
Value<int> downloadedBytes,
|
||||
Value<String?> videoFilePath,
|
||||
Value<String?> safRootUri,
|
||||
Value<String?> thumbPath,
|
||||
Value<int?> downloadedAt,
|
||||
Value<String?> errorMessage,
|
||||
@@ -5529,6 +5693,11 @@ class $$DownloadedMediaTableFilterComposer
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get safRootUri => $composableBuilder(
|
||||
column: $table.safRootUri,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get thumbPath => $composableBuilder(
|
||||
column: $table.thumbPath,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
@@ -5639,6 +5808,11 @@ class $$DownloadedMediaTableOrderingComposer
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get safRootUri => $composableBuilder(
|
||||
column: $table.safRootUri,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get thumbPath => $composableBuilder(
|
||||
column: $table.thumbPath,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
@@ -5735,6 +5909,11 @@ class $$DownloadedMediaTableAnnotationComposer
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<String> get safRootUri => $composableBuilder(
|
||||
column: $table.safRootUri,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<String> get thumbPath =>
|
||||
$composableBuilder(column: $table.thumbPath, builder: (column) => column);
|
||||
|
||||
@@ -5817,6 +5996,7 @@ class $$DownloadedMediaTableTableManager
|
||||
Value<int?> totalBytes = const Value.absent(),
|
||||
Value<int> downloadedBytes = const Value.absent(),
|
||||
Value<String?> videoFilePath = const Value.absent(),
|
||||
Value<String?> safRootUri = const Value.absent(),
|
||||
Value<String?> thumbPath = const Value.absent(),
|
||||
Value<int?> downloadedAt = const Value.absent(),
|
||||
Value<String?> errorMessage = const Value.absent(),
|
||||
@@ -5838,6 +6018,7 @@ class $$DownloadedMediaTableTableManager
|
||||
totalBytes: totalBytes,
|
||||
downloadedBytes: downloadedBytes,
|
||||
videoFilePath: videoFilePath,
|
||||
safRootUri: safRootUri,
|
||||
thumbPath: thumbPath,
|
||||
downloadedAt: downloadedAt,
|
||||
errorMessage: errorMessage,
|
||||
@@ -5861,6 +6042,7 @@ class $$DownloadedMediaTableTableManager
|
||||
Value<int?> totalBytes = const Value.absent(),
|
||||
Value<int> downloadedBytes = const Value.absent(),
|
||||
Value<String?> videoFilePath = const Value.absent(),
|
||||
Value<String?> safRootUri = const Value.absent(),
|
||||
Value<String?> thumbPath = const Value.absent(),
|
||||
Value<int?> downloadedAt = const Value.absent(),
|
||||
Value<String?> errorMessage = const Value.absent(),
|
||||
@@ -5882,6 +6064,7 @@ class $$DownloadedMediaTableTableManager
|
||||
totalBytes: totalBytes,
|
||||
downloadedBytes: downloadedBytes,
|
||||
videoFilePath: videoFilePath,
|
||||
safRootUri: safRootUri,
|
||||
thumbPath: thumbPath,
|
||||
downloadedAt: downloadedAt,
|
||||
errorMessage: errorMessage,
|
||||
@@ -5923,6 +6106,8 @@ typedef $$DownloadOwnersTableCreateCompanionBuilder =
|
||||
DownloadOwnersCompanion Function({
|
||||
required String profileId,
|
||||
required String globalKey,
|
||||
Value<String?> backend,
|
||||
Value<String?> clientScopeId,
|
||||
required int createdAt,
|
||||
Value<int> rowid,
|
||||
});
|
||||
@@ -5930,6 +6115,8 @@ typedef $$DownloadOwnersTableUpdateCompanionBuilder =
|
||||
DownloadOwnersCompanion Function({
|
||||
Value<String> profileId,
|
||||
Value<String> globalKey,
|
||||
Value<String?> backend,
|
||||
Value<String?> clientScopeId,
|
||||
Value<int> createdAt,
|
||||
Value<int> rowid,
|
||||
});
|
||||
@@ -5953,6 +6140,16 @@ class $$DownloadOwnersTableFilterComposer
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get backend => $composableBuilder(
|
||||
column: $table.backend,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get clientScopeId => $composableBuilder(
|
||||
column: $table.clientScopeId,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get createdAt => $composableBuilder(
|
||||
column: $table.createdAt,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
@@ -5978,6 +6175,16 @@ class $$DownloadOwnersTableOrderingComposer
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get backend => $composableBuilder(
|
||||
column: $table.backend,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get clientScopeId => $composableBuilder(
|
||||
column: $table.clientScopeId,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get createdAt => $composableBuilder(
|
||||
column: $table.createdAt,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
@@ -5999,6 +6206,14 @@ class $$DownloadOwnersTableAnnotationComposer
|
||||
GeneratedColumn<String> get globalKey =>
|
||||
$composableBuilder(column: $table.globalKey, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get backend =>
|
||||
$composableBuilder(column: $table.backend, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get clientScopeId => $composableBuilder(
|
||||
column: $table.clientScopeId,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<int> get createdAt =>
|
||||
$composableBuilder(column: $table.createdAt, builder: (column) => column);
|
||||
}
|
||||
@@ -6042,11 +6257,15 @@ class $$DownloadOwnersTableTableManager
|
||||
({
|
||||
Value<String> profileId = const Value.absent(),
|
||||
Value<String> globalKey = const Value.absent(),
|
||||
Value<String?> backend = const Value.absent(),
|
||||
Value<String?> clientScopeId = const Value.absent(),
|
||||
Value<int> createdAt = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => DownloadOwnersCompanion(
|
||||
profileId: profileId,
|
||||
globalKey: globalKey,
|
||||
backend: backend,
|
||||
clientScopeId: clientScopeId,
|
||||
createdAt: createdAt,
|
||||
rowid: rowid,
|
||||
),
|
||||
@@ -6054,11 +6273,15 @@ class $$DownloadOwnersTableTableManager
|
||||
({
|
||||
required String profileId,
|
||||
required String globalKey,
|
||||
Value<String?> backend = const Value.absent(),
|
||||
Value<String?> clientScopeId = const Value.absent(),
|
||||
required int createdAt,
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => DownloadOwnersCompanion.insert(
|
||||
profileId: profileId,
|
||||
globalKey: globalKey,
|
||||
backend: backend,
|
||||
clientScopeId: clientScopeId,
|
||||
createdAt: createdAt,
|
||||
rowid: rowid,
|
||||
),
|
||||
|
||||
@@ -1,20 +1,53 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.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}) async {
|
||||
Future<void> addDownloadOwner({
|
||||
required String profileId,
|
||||
required String globalKey,
|
||||
String? backendId,
|
||||
String? clientScopeId,
|
||||
}) async {
|
||||
if (profileId.isEmpty) return;
|
||||
await into(downloadOwners).insert(
|
||||
DownloadOwnersCompanion.insert(
|
||||
profileId: profileId,
|
||||
globalKey: globalKey,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
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},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +55,53 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
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();
|
||||
}
|
||||
@@ -32,6 +112,28 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
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;
|
||||
}
|
||||
@@ -41,6 +143,19 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
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 [];
|
||||
@@ -65,14 +180,34 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
/// 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) async {
|
||||
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 connectionIds = (await select(connections).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))
|
||||
@@ -80,7 +215,56 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
};
|
||||
for (final row in rows) {
|
||||
if (!ownedKeys.contains(row.globalKey)) {
|
||||
await addDownloadOwner(profileId: profileId, globalKey: 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,20 +281,49 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
int mediaIndex = 0,
|
||||
String? mediaSourceId,
|
||||
}) async {
|
||||
await into(downloadedMedia).insert(
|
||||
DownloadedMediaCompanion.insert(
|
||||
serverId: serverId,
|
||||
clientScopeId: Value(clientScopeId),
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
type: type,
|
||||
parentRatingKey: Value(parentRatingKey),
|
||||
grandparentRatingKey: Value(grandparentRatingKey),
|
||||
status: status,
|
||||
mediaIndex: Value(mediaIndex),
|
||||
mediaSourceId: Value(mediaSourceId),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
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},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,6 +345,133 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -151,6 +491,19 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
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);
|
||||
}
|
||||
|
||||
Future<void> updateDownloadStatus(String globalKey, int status) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
@@ -182,6 +535,30 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -211,10 +588,19 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> deleteDownload(String globalKey) async {
|
||||
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();
|
||||
/// 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(
|
||||
@@ -328,3 +714,23 @@ bool _isValidDownloadOwner(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ class DownloadedMedia extends Table {
|
||||
IntColumn get totalBytes => integer().nullable()();
|
||||
IntColumn get downloadedBytes => integer().withDefault(const Constant(0))();
|
||||
TextColumn get videoFilePath => text().nullable()();
|
||||
TextColumn get safRootUri => text().nullable()();
|
||||
TextColumn get thumbPath => text().nullable()();
|
||||
IntColumn get downloadedAt => integer().nullable()();
|
||||
TextColumn get errorMessage => text().nullable()();
|
||||
@@ -73,6 +74,8 @@ class DownloadedMedia extends Table {
|
||||
class DownloadOwners extends Table {
|
||||
TextColumn get profileId => text()();
|
||||
TextColumn get globalKey => text()();
|
||||
TextColumn get backend => text().nullable()();
|
||||
TextColumn get clientScopeId => text().nullable()();
|
||||
IntColumn get createdAt => integer()();
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Startup result after reconciling the purgeable tvOS database with its
|
||||
/// bounded standard-domain recovery image.
|
||||
enum TvosDatabaseRecoveryOutcome { notApplicable, fresh, adoptedExistingDatabase, restored, recoveryRequired }
|
||||
|
||||
/// The critical row group changed by a database mutation.
|
||||
enum TvosDatabaseRecoveryGroup { identity, pending }
|
||||
|
||||
/// Deterministic fault-injection points for the recovery commit protocol.
|
||||
@visibleForTesting
|
||||
enum TvosDatabaseRecoveryCrashPoint { afterInvalidation, afterDatabaseMutation, afterPayloadWrite, afterFinalManifest }
|
||||
|
||||
/// A durability failure that is deliberately free of protected row payloads.
|
||||
final class TvosDatabaseDurabilityException implements Exception {
|
||||
const TvosDatabaseDurabilityException();
|
||||
|
||||
@override
|
||||
String toString() => 'TvosDatabaseDurabilityException: critical local data was not durably committed';
|
||||
}
|
||||
|
||||
final class _TvosDatabaseRecoveryBudgetException implements Exception {
|
||||
const _TvosDatabaseRecoveryBudgetException();
|
||||
}
|
||||
|
||||
final class _TvosDatabaseRecoveryInvalidationException implements Exception {
|
||||
const _TvosDatabaseRecoveryInvalidationException();
|
||||
}
|
||||
|
||||
/// Raw critical rows from a validated, committed recovery image.
|
||||
///
|
||||
/// Values are kept raw so already-protected connection configuration and user
|
||||
/// token bytes are restored exactly, without crossing a reveal boundary.
|
||||
final class TvosDatabaseRecoverySnapshot {
|
||||
const TvosDatabaseRecoverySnapshot({required this.identity, required this.pending});
|
||||
|
||||
final Map<String, Object?> identity;
|
||||
final Map<String, Object?> pending;
|
||||
}
|
||||
|
||||
typedef TvosDatabaseRecoveryRowsReader = Future<Map<String, Object?>> Function();
|
||||
typedef TvosDatabaseRecoveryRestore = Future<void> Function(TvosDatabaseRecoverySnapshot snapshot);
|
||||
typedef TvosDatabaseRecoveryPriorInstallEvidence = Future<bool> Function();
|
||||
typedef TvosDatabaseRecoveryDebugCrash = Future<void> Function(TvosDatabaseRecoveryCrashPoint point);
|
||||
typedef TvosDatabaseRecoveryDebugBeforePreferenceWrite = Future<void> Function(String key);
|
||||
|
||||
/// Maintains the bounded two-group tvOS recovery image in UserDefaults.standard.
|
||||
///
|
||||
/// The manifest is invalidated before a critical Drift mutation. The changed
|
||||
/// payload and its digest are written only after Drift commits, followed by the
|
||||
/// committed manifest as the final write. Therefore a missing database is
|
||||
/// restorable only from a complete committed image; an interrupted update
|
||||
/// always requires recovery instead of silently resurrecting stale state.
|
||||
final class TvosDatabaseRecoveryStore {
|
||||
TvosDatabaseRecoveryStore(
|
||||
this._preferences, {
|
||||
this.isTvos = false,
|
||||
this.preferenceImageByteCeiling = defaultPreferenceImageByteCeiling,
|
||||
this.debugCrash,
|
||||
this.debugBeforePreferenceWrite,
|
||||
});
|
||||
|
||||
static const int recoveryFormatVersion = 1;
|
||||
static const int defaultPreferenceImageByteCeiling = 400000;
|
||||
|
||||
static const String manifestKey = 'tvos_db_recovery_manifest_v1';
|
||||
static const String identityKey = 'tvos_db_recovery_identity_v1';
|
||||
static const String pendingKey = 'tvos_db_recovery_pending_v1';
|
||||
static const String recoveryRequiredKey = 'tvos_db_recovery_required_v1';
|
||||
static const String keyPrefix = 'tvos_db_recovery_';
|
||||
|
||||
static const String _stateInvalidated = 'invalidated';
|
||||
static const String _stateCommitted = 'committed';
|
||||
|
||||
final SharedPreferencesWithCache _preferences;
|
||||
final bool isTvos;
|
||||
final int preferenceImageByteCeiling;
|
||||
final TvosDatabaseRecoveryDebugCrash? debugCrash;
|
||||
final TvosDatabaseRecoveryDebugBeforePreferenceWrite? debugBeforePreferenceWrite;
|
||||
|
||||
bool _recoveryDisabled = false;
|
||||
bool _manifestCacheNeedsReload = false;
|
||||
bool _pendingPayloadTruncated = false;
|
||||
|
||||
/// Reconciles startup before any registry, legacy bootstrap, or UI consumer.
|
||||
Future<TvosDatabaseRecoveryOutcome> reconcile({
|
||||
required bool databaseExisted,
|
||||
required TvosDatabaseRecoveryRowsReader readIdentity,
|
||||
required TvosDatabaseRecoveryRowsReader readPending,
|
||||
required TvosDatabaseRecoveryRestore restore,
|
||||
required TvosDatabaseRecoveryPriorInstallEvidence hasPriorInstallEvidence,
|
||||
}) async {
|
||||
if (!isTvos) return TvosDatabaseRecoveryOutcome.notApplicable;
|
||||
|
||||
final recoveryRequired = _preferences.getBool(recoveryRequiredKey) ?? false;
|
||||
if (recoveryRequired) {
|
||||
await _reloadManifestCacheIfNeeded();
|
||||
final snapshot = _readCommittedSnapshot();
|
||||
if (snapshot == null) return TvosDatabaseRecoveryOutcome.recoveryRequired;
|
||||
try {
|
||||
return await _restoreCommittedSnapshot(
|
||||
snapshot: snapshot,
|
||||
restore: restore,
|
||||
readIdentity: readIdentity,
|
||||
readPending: readPending,
|
||||
);
|
||||
} catch (_) {
|
||||
return TvosDatabaseRecoveryOutcome.recoveryRequired;
|
||||
}
|
||||
}
|
||||
|
||||
if (databaseExisted) {
|
||||
// The database is authoritative. Read it outside the recovery publishing
|
||||
// failure boundary so database failures are never mistaken for damaged
|
||||
// recovery evidence.
|
||||
final identityRows = await readIdentity();
|
||||
final pendingRows = await readPending();
|
||||
try {
|
||||
await _publishAuthoritativeRows(identityRows: identityRows, pendingRows: pendingRows);
|
||||
return TvosDatabaseRecoveryOutcome.adoptedExistingDatabase;
|
||||
} on _TvosDatabaseRecoveryInvalidationException {
|
||||
// The old committed image may still be restorable. Keep recovery
|
||||
// enabled so every later critical mutation must retry invalidation
|
||||
// before it is allowed to touch the authoritative database.
|
||||
_recoveryDisabled = false;
|
||||
return TvosDatabaseRecoveryOutcome.adoptedExistingDatabase;
|
||||
} catch (error, stackTrace) {
|
||||
_disableRecovery(error, stackTrace);
|
||||
return TvosDatabaseRecoveryOutcome.adoptedExistingDatabase;
|
||||
}
|
||||
}
|
||||
|
||||
final hasAnyRecoveryKey = _preferences.keys.any((key) => key.startsWith(keyPrefix));
|
||||
if (!hasAnyRecoveryKey) {
|
||||
if (await hasPriorInstallEvidence()) {
|
||||
return _markRecoveryRequired();
|
||||
}
|
||||
try {
|
||||
await _commitAuthoritativeDatabase(readIdentity: readIdentity, readPending: readPending);
|
||||
return TvosDatabaseRecoveryOutcome.fresh;
|
||||
} on _TvosDatabaseRecoveryBudgetException catch (error, stackTrace) {
|
||||
_disableRecovery(error, stackTrace);
|
||||
return TvosDatabaseRecoveryOutcome.fresh;
|
||||
} catch (_) {
|
||||
return _markRecoveryRequired();
|
||||
}
|
||||
}
|
||||
|
||||
final snapshot = _readCommittedSnapshot();
|
||||
if (snapshot == null) return _markRecoveryRequired();
|
||||
|
||||
try {
|
||||
return await _restoreCommittedSnapshot(
|
||||
snapshot: snapshot,
|
||||
restore: restore,
|
||||
readIdentity: readIdentity,
|
||||
readPending: readPending,
|
||||
);
|
||||
} catch (_) {
|
||||
return _markRecoveryRequired();
|
||||
}
|
||||
}
|
||||
|
||||
Future<TvosDatabaseRecoveryOutcome> _restoreCommittedSnapshot({
|
||||
required TvosDatabaseRecoverySnapshot snapshot,
|
||||
required TvosDatabaseRecoveryRestore restore,
|
||||
required TvosDatabaseRecoveryRowsReader readIdentity,
|
||||
required TvosDatabaseRecoveryRowsReader readPending,
|
||||
}) async {
|
||||
await restore(snapshot);
|
||||
// The restored database may have migrated legacy plaintext credentials.
|
||||
// Publish a replacement image before clearing the replay marker so the
|
||||
// committed preference copy is protected as well.
|
||||
await _commitAuthoritativeDatabase(readIdentity: readIdentity, readPending: readPending);
|
||||
await _clearRecoveryRequired();
|
||||
return TvosDatabaseRecoveryOutcome.restored;
|
||||
}
|
||||
|
||||
Future<TvosDatabaseRecoveryOutcome> _markRecoveryRequired() async {
|
||||
try {
|
||||
await debugBeforePreferenceWrite?.call(recoveryRequiredKey);
|
||||
await _preferences.setBool(recoveryRequiredKey, true);
|
||||
} catch (_) {
|
||||
throw const TvosDatabaseDurabilityException();
|
||||
}
|
||||
return TvosDatabaseRecoveryOutcome.recoveryRequired;
|
||||
}
|
||||
|
||||
Future<void> _clearRecoveryRequired() async {
|
||||
// Always issue the removal. SharedPreferencesWithCache can update its
|
||||
// cache before the platform write finishes, so a failed removal may make
|
||||
// the key look absent locally while it remains durable.
|
||||
try {
|
||||
await debugBeforePreferenceWrite?.call(recoveryRequiredKey);
|
||||
await _preferences.remove(recoveryRequiredKey);
|
||||
} catch (_) {
|
||||
throw const TvosDatabaseDurabilityException();
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one complete critical mutation and resolves only after its recovery
|
||||
/// image is committed. Off tvOS this is a zero-storage wrapper.
|
||||
Future<T> runDurableMutation<T>({
|
||||
required TvosDatabaseRecoveryGroup group,
|
||||
required Future<T> Function() mutation,
|
||||
required TvosDatabaseRecoveryRowsReader readIdentity,
|
||||
required TvosDatabaseRecoveryRowsReader readPending,
|
||||
}) async {
|
||||
if (!isTvos) return mutation();
|
||||
if (_recoveryDisabled) return mutation();
|
||||
|
||||
await _reloadManifestCacheIfNeeded();
|
||||
final previous = _readCommittedManifestForMutation();
|
||||
// Invalidating the old image is mandatory even when the preference
|
||||
// domain is already over budget: stale identity must never become
|
||||
// restorable after the database mutation commits.
|
||||
try {
|
||||
await _invalidateRecoveryImage(previous);
|
||||
} on _TvosDatabaseRecoveryInvalidationException {
|
||||
throw const TvosDatabaseDurabilityException();
|
||||
}
|
||||
await debugCrash?.call(TvosDatabaseRecoveryCrashPoint.afterInvalidation);
|
||||
|
||||
late final T result;
|
||||
late final Map<String, Object?> rows;
|
||||
try {
|
||||
result = await mutation();
|
||||
await debugCrash?.call(TvosDatabaseRecoveryCrashPoint.afterDatabaseMutation);
|
||||
rows = await (group == TvosDatabaseRecoveryGroup.identity ? readIdentity() : readPending());
|
||||
} catch (error, stackTrace) {
|
||||
// The database may already have committed, so the previous recovery
|
||||
// image is no longer safe to restore. Keep it invalidated and let later
|
||||
// mutations use the authoritative database for the rest of this process.
|
||||
_disableRecovery(error, stackTrace);
|
||||
rethrow;
|
||||
}
|
||||
|
||||
try {
|
||||
await _commitChangedGroup(previous: previous, group: group, rows: rows);
|
||||
} on _TvosDatabaseRecoveryBudgetException catch (error, stackTrace) {
|
||||
_disableRecovery(error, stackTrace);
|
||||
} on TvosDatabaseDurabilityException catch (error, stackTrace) {
|
||||
if (debugCrash != null || debugBeforePreferenceWrite != null) rethrow;
|
||||
_disableRecovery(error, stackTrace);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Replaces an irrecoverable image with the current authoritative database
|
||||
/// only after the user explicitly starts a new sign-in.
|
||||
Future<void> acknowledgeRecoveryRequired({
|
||||
required TvosDatabaseRecoveryRowsReader readIdentity,
|
||||
required TvosDatabaseRecoveryRowsReader readPending,
|
||||
}) async {
|
||||
if (!isTvos) return;
|
||||
try {
|
||||
await _commitAuthoritativeDatabase(readIdentity: readIdentity, readPending: readPending);
|
||||
await _clearRecoveryRequired();
|
||||
_recoveryDisabled = false;
|
||||
} on _TvosDatabaseRecoveryInvalidationException {
|
||||
throw const TvosDatabaseDurabilityException();
|
||||
} on _TvosDatabaseRecoveryBudgetException catch (error, stackTrace) {
|
||||
_disableRecovery(error, stackTrace);
|
||||
throw const TvosDatabaseDurabilityException();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _commitAuthoritativeDatabase({
|
||||
required TvosDatabaseRecoveryRowsReader readIdentity,
|
||||
required TvosDatabaseRecoveryRowsReader readPending,
|
||||
}) async {
|
||||
final identityRows = await readIdentity();
|
||||
final pendingRows = await readPending();
|
||||
await _publishAuthoritativeRows(identityRows: identityRows, pendingRows: pendingRows);
|
||||
}
|
||||
|
||||
Future<void> _publishAuthoritativeRows({
|
||||
required Map<String, Object?> identityRows,
|
||||
required Map<String, Object?> pendingRows,
|
||||
}) async {
|
||||
await _reloadManifestCacheIfNeeded();
|
||||
final previous = _readManifestLenient();
|
||||
await _invalidateRecoveryImage(previous);
|
||||
|
||||
final identityPayload = _encodePayload(identityRows);
|
||||
final pendingPayload = _encodePayload(pendingRows);
|
||||
final manifest = _Manifest(
|
||||
state: _stateCommitted,
|
||||
identityDigest: _digest(identityPayload),
|
||||
pendingDigest: _digest(pendingPayload),
|
||||
);
|
||||
await _commitGeneration(
|
||||
payloads: {identityKey: identityPayload, pendingKey: pendingPayload},
|
||||
manifest: manifest,
|
||||
reportsPendingPayloadState: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _commitChangedGroup({
|
||||
required _Manifest previous,
|
||||
required TvosDatabaseRecoveryGroup group,
|
||||
required Map<String, Object?> rows,
|
||||
}) async {
|
||||
final payloadKey = group == TvosDatabaseRecoveryGroup.identity ? identityKey : pendingKey;
|
||||
final payload = _encodePayload(rows);
|
||||
final digest = _digest(payload);
|
||||
final manifest = switch (group) {
|
||||
TvosDatabaseRecoveryGroup.identity => previous.copyWith(state: _stateCommitted, identityDigest: digest),
|
||||
TvosDatabaseRecoveryGroup.pending => previous.copyWith(state: _stateCommitted, pendingDigest: digest),
|
||||
};
|
||||
await _commitGeneration(
|
||||
payloads: {payloadKey: payload},
|
||||
manifest: manifest,
|
||||
reportsPendingPayloadState: group == TvosDatabaseRecoveryGroup.pending,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _commitGeneration({
|
||||
required Map<String, String> payloads,
|
||||
required _Manifest manifest,
|
||||
required bool reportsPendingPayloadState,
|
||||
}) async {
|
||||
final committedPayloads = Map<String, String>.of(payloads);
|
||||
var committedManifest = manifest;
|
||||
var replacements = <String, String>{...committedPayloads, manifestKey: _encodeManifest(committedManifest)};
|
||||
var pendingTruncated = false;
|
||||
|
||||
if (!_candidateFits(replacements)) {
|
||||
final emptyPendingPayload = _encodePayload(_emptyPendingRows);
|
||||
committedPayloads[pendingKey] = emptyPendingPayload;
|
||||
committedManifest = committedManifest.copyWith(pendingDigest: _digest(emptyPendingPayload));
|
||||
replacements = <String, String>{...committedPayloads, manifestKey: _encodeManifest(committedManifest)};
|
||||
pendingTruncated = true;
|
||||
}
|
||||
|
||||
_requireCandidateFits(replacements);
|
||||
if (reportsPendingPayloadState || pendingTruncated) {
|
||||
_markPendingPayloadTruncated(pendingTruncated);
|
||||
}
|
||||
try {
|
||||
for (final entry in committedPayloads.entries) {
|
||||
await debugBeforePreferenceWrite?.call(entry.key);
|
||||
await _preferences.setString(entry.key, entry.value);
|
||||
}
|
||||
await debugCrash?.call(TvosDatabaseRecoveryCrashPoint.afterPayloadWrite);
|
||||
await debugBeforePreferenceWrite?.call(manifestKey);
|
||||
await _preferences.setString(manifestKey, _encodeManifest(committedManifest));
|
||||
await debugCrash?.call(TvosDatabaseRecoveryCrashPoint.afterFinalManifest);
|
||||
} catch (_) {
|
||||
throw const TvosDatabaseDurabilityException();
|
||||
}
|
||||
}
|
||||
|
||||
TvosDatabaseRecoverySnapshot? _readCommittedSnapshot() {
|
||||
try {
|
||||
final manifest = _decodeManifest(_preferences.getString(manifestKey));
|
||||
if (manifest == null || manifest.state != _stateCommitted) return null;
|
||||
if (_currentPreferenceImageSize() > preferenceImageByteCeiling) return null;
|
||||
|
||||
final identityRaw = _preferences.getString(identityKey);
|
||||
final pendingRaw = _preferences.getString(pendingKey);
|
||||
if (identityRaw == null || pendingRaw == null) return null;
|
||||
if (_digest(identityRaw) != manifest.identityDigest || _digest(pendingRaw) != manifest.pendingDigest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final identity = _decodePayload(identityRaw, _identityRowKeys);
|
||||
final pending = _decodePayload(pendingRaw, _pendingRowKeys);
|
||||
if (identity == null || pending == null) return null;
|
||||
return TvosDatabaseRecoverySnapshot(identity: identity, pending: pending);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_Manifest _readCommittedManifestForMutation() {
|
||||
try {
|
||||
final manifest = _decodeManifest(_preferences.getString(manifestKey));
|
||||
final identityRaw = _preferences.getString(identityKey);
|
||||
final pendingRaw = _preferences.getString(pendingKey);
|
||||
if (manifest == null ||
|
||||
manifest.state != _stateCommitted ||
|
||||
identityRaw == null ||
|
||||
pendingRaw == null ||
|
||||
_digest(identityRaw) != manifest.identityDigest ||
|
||||
_digest(pendingRaw) != manifest.pendingDigest) {
|
||||
throw const TvosDatabaseDurabilityException();
|
||||
}
|
||||
return manifest;
|
||||
} catch (_) {
|
||||
throw const TvosDatabaseDurabilityException();
|
||||
}
|
||||
}
|
||||
|
||||
_Manifest? _readManifestLenient() {
|
||||
try {
|
||||
return _decodeManifest(_preferences.getString(manifestKey));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _invalidateRecoveryImage(_Manifest? previous) async {
|
||||
try {
|
||||
await _writeManifest(_invalidatedManifest(previous), enforceBudget: false);
|
||||
} on TvosDatabaseDurabilityException {
|
||||
if (previous?.state == _stateCommitted) {
|
||||
throw const _TvosDatabaseRecoveryInvalidationException();
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeManifest(_Manifest manifest, {bool enforceBudget = true}) async {
|
||||
final encoded = _encodeManifest(manifest);
|
||||
if (enforceBudget) _requireCandidateFits({manifestKey: encoded});
|
||||
try {
|
||||
await debugBeforePreferenceWrite?.call(manifestKey);
|
||||
await _preferences.setString(manifestKey, encoded);
|
||||
} catch (_) {
|
||||
// SharedPreferencesWithCache updates its local value before awaiting the
|
||||
// platform write. Reload the durable domain so a failed invalidation
|
||||
// cannot leave an optimistic "invalidated" manifest blocking retries.
|
||||
_manifestCacheNeedsReload = true;
|
||||
try {
|
||||
await _reloadManifestCacheIfNeeded();
|
||||
} catch (_) {
|
||||
// The next mutation retries the durable reload before reading state.
|
||||
}
|
||||
throw const TvosDatabaseDurabilityException();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reloadManifestCacheIfNeeded() async {
|
||||
if (!_manifestCacheNeedsReload) return;
|
||||
try {
|
||||
await _preferences.reloadCache();
|
||||
_manifestCacheNeedsReload = false;
|
||||
} catch (_) {
|
||||
throw const TvosDatabaseDurabilityException();
|
||||
}
|
||||
}
|
||||
|
||||
_Manifest _invalidatedManifest(_Manifest? previous) => _Manifest(
|
||||
state: _stateInvalidated,
|
||||
identityDigest: previous?.identityDigest ?? '',
|
||||
pendingDigest: previous?.pendingDigest ?? '',
|
||||
);
|
||||
|
||||
bool _candidateFits(Map<String, Object?> replacements) =>
|
||||
_preferenceImageSize({recoveryRequiredKey: true, ...replacements}) <= preferenceImageByteCeiling;
|
||||
|
||||
void _requireCandidateFits(Map<String, Object?> replacements) {
|
||||
if (!_candidateFits(replacements)) {
|
||||
throw const _TvosDatabaseRecoveryBudgetException();
|
||||
}
|
||||
}
|
||||
|
||||
int _currentPreferenceImageSize() => _preferenceImageSize(const {});
|
||||
|
||||
int _preferenceImageSize(Map<String, Object?> replacements) {
|
||||
final keys = <String>{..._preferences.keys.where((key) => key.startsWith(keyPrefix)), ...replacements.keys}.toList()
|
||||
..sort();
|
||||
final image = <String, Object?>{};
|
||||
for (final key in keys) {
|
||||
image[key] = replacements.containsKey(key) ? replacements[key] : _preferences.get(key);
|
||||
}
|
||||
return utf8.encode(jsonEncode(image)).length;
|
||||
}
|
||||
|
||||
void _markPendingPayloadTruncated(bool truncated) {
|
||||
if (truncated && !_pendingPayloadTruncated) {
|
||||
appLogger.w('tvOS database recovery omitted pending watch progress to stay within its preference budget');
|
||||
}
|
||||
_pendingPayloadTruncated = truncated;
|
||||
}
|
||||
|
||||
void _disableRecovery(Object error, StackTrace stackTrace) {
|
||||
if (_recoveryDisabled) return;
|
||||
_recoveryDisabled = true;
|
||||
appLogger.w(
|
||||
'tvOS database recovery disabled for this process; the authoritative database remains available',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
static const Set<String> _identityRowKeys = {'connections', 'profiles', 'profileConnections'};
|
||||
static const Set<String> _pendingRowKeys = {'offlineWatchProgress'};
|
||||
static const Map<String, Object?> _emptyPendingRows = {'offlineWatchProgress': <Object?>[]};
|
||||
|
||||
static String _encodePayload(Map<String, Object?> rows) =>
|
||||
jsonEncode({'version': recoveryFormatVersion, 'rows': rows});
|
||||
|
||||
static Map<String, Object?>? _decodePayload(String raw, Set<String> expectedKeys) {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, dynamic> || decoded.length != 2 || decoded['version'] != recoveryFormatVersion) {
|
||||
return null;
|
||||
}
|
||||
final rows = decoded['rows'];
|
||||
if (rows is! Map<String, dynamic> ||
|
||||
rows.keys.toSet().difference(expectedKeys).isNotEmpty ||
|
||||
rows.length != expectedKeys.length) {
|
||||
return null;
|
||||
}
|
||||
for (final key in expectedKeys) {
|
||||
final value = rows[key];
|
||||
if (value is! List || value.any((row) => row is! Map<String, dynamic>)) return null;
|
||||
}
|
||||
return Map<String, Object?>.unmodifiable(rows);
|
||||
}
|
||||
|
||||
static String _digest(String value) => sha256.convert(utf8.encode(value)).toString();
|
||||
|
||||
static String _encodeManifest(_Manifest manifest) => jsonEncode({
|
||||
'version': recoveryFormatVersion,
|
||||
'state': manifest.state,
|
||||
'identityDigest': manifest.identityDigest,
|
||||
'pendingDigest': manifest.pendingDigest,
|
||||
});
|
||||
|
||||
static _Manifest? _decodeManifest(String? raw) {
|
||||
if (raw == null) return null;
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, dynamic> ||
|
||||
decoded.length != 4 ||
|
||||
decoded['version'] != recoveryFormatVersion ||
|
||||
decoded['state'] is! String ||
|
||||
decoded['identityDigest'] is! String ||
|
||||
decoded['pendingDigest'] is! String) {
|
||||
return null;
|
||||
}
|
||||
final state = decoded['state'] as String;
|
||||
final identityDigest = decoded['identityDigest'] as String;
|
||||
final pendingDigest = decoded['pendingDigest'] as String;
|
||||
if ((state != _stateInvalidated && state != _stateCommitted) ||
|
||||
(state == _stateCommitted && (identityDigest.isEmpty || pendingDigest.isEmpty))) {
|
||||
return null;
|
||||
}
|
||||
return _Manifest(state: state, identityDigest: identityDigest, pendingDigest: pendingDigest);
|
||||
}
|
||||
}
|
||||
|
||||
final class _Manifest {
|
||||
const _Manifest({required this.state, required this.identityDigest, required this.pendingDigest});
|
||||
|
||||
final String state;
|
||||
final String identityDigest;
|
||||
final String pendingDigest;
|
||||
|
||||
_Manifest copyWith({String? state, String? identityDigest, String? pendingDigest}) => _Manifest(
|
||||
state: state ?? this.state,
|
||||
identityDigest: identityDigest ?? this.identityDigest,
|
||||
pendingDigest: pendingDigest ?? this.pendingDigest,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user