@@ -17,7 +17,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase() : super(_openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 8; // Added bgTaskId column to DownloadedMedia
|
||||
int get schemaVersion => 9; // Added mediaIndex column to DownloadedMedia
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -38,6 +38,14 @@ class AppDatabase extends _$AppDatabase {
|
||||
appLogger.w('bgTaskId column may already exist: $e');
|
||||
}
|
||||
}
|
||||
if (from < 9) {
|
||||
appLogger.i('Adding mediaIndex column to DownloadedMedia (v9 migration)');
|
||||
try {
|
||||
await m.addColumn(downloadedMedia, downloadedMedia.mediaIndex);
|
||||
} catch (e) {
|
||||
appLogger.w('mediaIndex column may already exist: $e');
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,6 +198,18 @@ class $DownloadedMediaTable extends DownloadedMedia
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _mediaIndexMeta = const VerificationMeta(
|
||||
'mediaIndex',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<int> mediaIndex = GeneratedColumn<int>(
|
||||
'media_index',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: const Constant(0),
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> get $columns => [
|
||||
id,
|
||||
@@ -217,6 +229,7 @@ class $DownloadedMediaTable extends DownloadedMedia
|
||||
errorMessage,
|
||||
retryCount,
|
||||
bgTaskId,
|
||||
mediaIndex,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -357,6 +370,12 @@ class $DownloadedMediaTable extends DownloadedMedia
|
||||
bgTaskId.isAcceptableOrUnknown(data['bg_task_id']!, _bgTaskIdMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('media_index')) {
|
||||
context.handle(
|
||||
_mediaIndexMeta,
|
||||
mediaIndex.isAcceptableOrUnknown(data['media_index']!, _mediaIndexMeta),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -434,6 +453,10 @@ class $DownloadedMediaTable extends DownloadedMedia
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}bg_task_id'],
|
||||
),
|
||||
mediaIndex: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}media_index'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -462,6 +485,7 @@ class DownloadedMediaItem extends DataClass
|
||||
final String? errorMessage;
|
||||
final int retryCount;
|
||||
final String? bgTaskId;
|
||||
final int mediaIndex;
|
||||
const DownloadedMediaItem({
|
||||
required this.id,
|
||||
required this.serverId,
|
||||
@@ -480,6 +504,7 @@ class DownloadedMediaItem extends DataClass
|
||||
this.errorMessage,
|
||||
required this.retryCount,
|
||||
this.bgTaskId,
|
||||
required this.mediaIndex,
|
||||
});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -517,6 +542,7 @@ class DownloadedMediaItem extends DataClass
|
||||
if (!nullToAbsent || bgTaskId != null) {
|
||||
map['bg_task_id'] = Variable<String>(bgTaskId);
|
||||
}
|
||||
map['media_index'] = Variable<int>(mediaIndex);
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -555,6 +581,7 @@ class DownloadedMediaItem extends DataClass
|
||||
bgTaskId: bgTaskId == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(bgTaskId),
|
||||
mediaIndex: Value(mediaIndex),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -583,6 +610,7 @@ class DownloadedMediaItem extends DataClass
|
||||
errorMessage: serializer.fromJson<String?>(json['errorMessage']),
|
||||
retryCount: serializer.fromJson<int>(json['retryCount']),
|
||||
bgTaskId: serializer.fromJson<String?>(json['bgTaskId']),
|
||||
mediaIndex: serializer.fromJson<int>(json['mediaIndex']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -606,6 +634,7 @@ class DownloadedMediaItem extends DataClass
|
||||
'errorMessage': serializer.toJson<String?>(errorMessage),
|
||||
'retryCount': serializer.toJson<int>(retryCount),
|
||||
'bgTaskId': serializer.toJson<String?>(bgTaskId),
|
||||
'mediaIndex': serializer.toJson<int>(mediaIndex),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -627,6 +656,7 @@ class DownloadedMediaItem extends DataClass
|
||||
Value<String?> errorMessage = const Value.absent(),
|
||||
int? retryCount,
|
||||
Value<String?> bgTaskId = const Value.absent(),
|
||||
int? mediaIndex,
|
||||
}) => DownloadedMediaItem(
|
||||
id: id ?? this.id,
|
||||
serverId: serverId ?? this.serverId,
|
||||
@@ -651,6 +681,7 @@ class DownloadedMediaItem extends DataClass
|
||||
errorMessage: errorMessage.present ? errorMessage.value : this.errorMessage,
|
||||
retryCount: retryCount ?? this.retryCount,
|
||||
bgTaskId: bgTaskId.present ? bgTaskId.value : this.bgTaskId,
|
||||
mediaIndex: mediaIndex ?? this.mediaIndex,
|
||||
);
|
||||
DownloadedMediaItem copyWithCompanion(DownloadedMediaCompanion data) {
|
||||
return DownloadedMediaItem(
|
||||
@@ -687,6 +718,9 @@ class DownloadedMediaItem extends DataClass
|
||||
? data.retryCount.value
|
||||
: this.retryCount,
|
||||
bgTaskId: data.bgTaskId.present ? data.bgTaskId.value : this.bgTaskId,
|
||||
mediaIndex: data.mediaIndex.present
|
||||
? data.mediaIndex.value
|
||||
: this.mediaIndex,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -709,7 +743,8 @@ class DownloadedMediaItem extends DataClass
|
||||
..write('downloadedAt: $downloadedAt, ')
|
||||
..write('errorMessage: $errorMessage, ')
|
||||
..write('retryCount: $retryCount, ')
|
||||
..write('bgTaskId: $bgTaskId')
|
||||
..write('bgTaskId: $bgTaskId, ')
|
||||
..write('mediaIndex: $mediaIndex')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -733,6 +768,7 @@ class DownloadedMediaItem extends DataClass
|
||||
errorMessage,
|
||||
retryCount,
|
||||
bgTaskId,
|
||||
mediaIndex,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -754,7 +790,8 @@ class DownloadedMediaItem extends DataClass
|
||||
other.downloadedAt == this.downloadedAt &&
|
||||
other.errorMessage == this.errorMessage &&
|
||||
other.retryCount == this.retryCount &&
|
||||
other.bgTaskId == this.bgTaskId);
|
||||
other.bgTaskId == this.bgTaskId &&
|
||||
other.mediaIndex == this.mediaIndex);
|
||||
}
|
||||
|
||||
class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
@@ -775,6 +812,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
final Value<String?> errorMessage;
|
||||
final Value<int> retryCount;
|
||||
final Value<String?> bgTaskId;
|
||||
final Value<int> mediaIndex;
|
||||
const DownloadedMediaCompanion({
|
||||
this.id = const Value.absent(),
|
||||
this.serverId = const Value.absent(),
|
||||
@@ -793,6 +831,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
this.errorMessage = const Value.absent(),
|
||||
this.retryCount = const Value.absent(),
|
||||
this.bgTaskId = const Value.absent(),
|
||||
this.mediaIndex = const Value.absent(),
|
||||
});
|
||||
DownloadedMediaCompanion.insert({
|
||||
this.id = const Value.absent(),
|
||||
@@ -812,6 +851,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
this.errorMessage = const Value.absent(),
|
||||
this.retryCount = const Value.absent(),
|
||||
this.bgTaskId = const Value.absent(),
|
||||
this.mediaIndex = const Value.absent(),
|
||||
}) : serverId = Value(serverId),
|
||||
ratingKey = Value(ratingKey),
|
||||
globalKey = Value(globalKey),
|
||||
@@ -835,6 +875,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
Expression<String>? errorMessage,
|
||||
Expression<int>? retryCount,
|
||||
Expression<String>? bgTaskId,
|
||||
Expression<int>? mediaIndex,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
if (id != null) 'id': id,
|
||||
@@ -855,6 +896,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
if (errorMessage != null) 'error_message': errorMessage,
|
||||
if (retryCount != null) 'retry_count': retryCount,
|
||||
if (bgTaskId != null) 'bg_task_id': bgTaskId,
|
||||
if (mediaIndex != null) 'media_index': mediaIndex,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -876,6 +918,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
Value<String?>? errorMessage,
|
||||
Value<int>? retryCount,
|
||||
Value<String?>? bgTaskId,
|
||||
Value<int>? mediaIndex,
|
||||
}) {
|
||||
return DownloadedMediaCompanion(
|
||||
id: id ?? this.id,
|
||||
@@ -895,6 +938,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
retryCount: retryCount ?? this.retryCount,
|
||||
bgTaskId: bgTaskId ?? this.bgTaskId,
|
||||
mediaIndex: mediaIndex ?? this.mediaIndex,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -954,6 +998,9 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
if (bgTaskId.present) {
|
||||
map['bg_task_id'] = Variable<String>(bgTaskId.value);
|
||||
}
|
||||
if (mediaIndex.present) {
|
||||
map['media_index'] = Variable<int>(mediaIndex.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -976,7 +1023,8 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
|
||||
..write('downloadedAt: $downloadedAt, ')
|
||||
..write('errorMessage: $errorMessage, ')
|
||||
..write('retryCount: $retryCount, ')
|
||||
..write('bgTaskId: $bgTaskId')
|
||||
..write('bgTaskId: $bgTaskId, ')
|
||||
..write('mediaIndex: $mediaIndex')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -2504,6 +2552,7 @@ typedef $$DownloadedMediaTableCreateCompanionBuilder =
|
||||
Value<String?> errorMessage,
|
||||
Value<int> retryCount,
|
||||
Value<String?> bgTaskId,
|
||||
Value<int> mediaIndex,
|
||||
});
|
||||
typedef $$DownloadedMediaTableUpdateCompanionBuilder =
|
||||
DownloadedMediaCompanion Function({
|
||||
@@ -2524,6 +2573,7 @@ typedef $$DownloadedMediaTableUpdateCompanionBuilder =
|
||||
Value<String?> errorMessage,
|
||||
Value<int> retryCount,
|
||||
Value<String?> bgTaskId,
|
||||
Value<int> mediaIndex,
|
||||
});
|
||||
|
||||
class $$DownloadedMediaTableFilterComposer
|
||||
@@ -2619,6 +2669,11 @@ class $$DownloadedMediaTableFilterComposer
|
||||
column: $table.bgTaskId,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get mediaIndex => $composableBuilder(
|
||||
column: $table.mediaIndex,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$DownloadedMediaTableOrderingComposer
|
||||
@@ -2714,6 +2769,11 @@ class $$DownloadedMediaTableOrderingComposer
|
||||
column: $table.bgTaskId,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get mediaIndex => $composableBuilder(
|
||||
column: $table.mediaIndex,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$DownloadedMediaTableAnnotationComposer
|
||||
@@ -2791,6 +2851,11 @@ class $$DownloadedMediaTableAnnotationComposer
|
||||
|
||||
GeneratedColumn<String> get bgTaskId =>
|
||||
$composableBuilder(column: $table.bgTaskId, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<int> get mediaIndex => $composableBuilder(
|
||||
column: $table.mediaIndex,
|
||||
builder: (column) => column,
|
||||
);
|
||||
}
|
||||
|
||||
class $$DownloadedMediaTableTableManager
|
||||
@@ -2847,6 +2912,7 @@ class $$DownloadedMediaTableTableManager
|
||||
Value<String?> errorMessage = const Value.absent(),
|
||||
Value<int> retryCount = const Value.absent(),
|
||||
Value<String?> bgTaskId = const Value.absent(),
|
||||
Value<int> mediaIndex = const Value.absent(),
|
||||
}) => DownloadedMediaCompanion(
|
||||
id: id,
|
||||
serverId: serverId,
|
||||
@@ -2865,6 +2931,7 @@ class $$DownloadedMediaTableTableManager
|
||||
errorMessage: errorMessage,
|
||||
retryCount: retryCount,
|
||||
bgTaskId: bgTaskId,
|
||||
mediaIndex: mediaIndex,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -2885,6 +2952,7 @@ class $$DownloadedMediaTableTableManager
|
||||
Value<String?> errorMessage = const Value.absent(),
|
||||
Value<int> retryCount = const Value.absent(),
|
||||
Value<String?> bgTaskId = const Value.absent(),
|
||||
Value<int> mediaIndex = const Value.absent(),
|
||||
}) => DownloadedMediaCompanion.insert(
|
||||
id: id,
|
||||
serverId: serverId,
|
||||
@@ -2903,6 +2971,7 @@ class $$DownloadedMediaTableTableManager
|
||||
errorMessage: errorMessage,
|
||||
retryCount: retryCount,
|
||||
bgTaskId: bgTaskId,
|
||||
mediaIndex: mediaIndex,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||
|
||||
@@ -14,6 +14,7 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
String? parentRatingKey,
|
||||
String? grandparentRatingKey,
|
||||
required int status,
|
||||
int mediaIndex = 0,
|
||||
}) async {
|
||||
await into(downloadedMedia).insert(
|
||||
DownloadedMediaCompanion.insert(
|
||||
@@ -24,6 +25,7 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
parentRatingKey: Value(parentRatingKey),
|
||||
grandparentRatingKey: Value(grandparentRatingKey),
|
||||
status: status,
|
||||
mediaIndex: Value(mediaIndex),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
|
||||
@@ -48,6 +48,7 @@ class DownloadedMedia extends Table {
|
||||
TextColumn get errorMessage => text().nullable()();
|
||||
IntColumn get retryCount => integer().withDefault(const Constant(0))();
|
||||
TextColumn get bgTaskId => text().nullable()();
|
||||
IntColumn get mediaIndex => integer().withDefault(const Constant(0))();
|
||||
}
|
||||
|
||||
/// Queue for offline watch progress and manual watch actions.
|
||||
|
||||
@@ -703,7 +703,8 @@
|
||||
"noDownloadsTree": "Ingen downloads",
|
||||
"pauseAll": "Pause alle",
|
||||
"resumeAll": "Genoptag alle",
|
||||
"deleteAll": "Slet alle"
|
||||
"deleteAll": "Slet alle",
|
||||
"selectVersion": "Vælg version"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Shadere",
|
||||
|
||||
@@ -601,7 +601,8 @@
|
||||
"noDownloadsTree": "Keine Downloads",
|
||||
"pauseAll": "Alle pausieren",
|
||||
"resumeAll": "Alle fortsetzen",
|
||||
"deleteAll": "Alle löschen"
|
||||
"deleteAll": "Alle löschen",
|
||||
"selectVersion": "Version auswählen"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Wiedergabelisten",
|
||||
|
||||
@@ -703,7 +703,8 @@
|
||||
"noDownloadsTree": "No downloads",
|
||||
"pauseAll": "Pause all",
|
||||
"resumeAll": "Resume all",
|
||||
"deleteAll": "Delete all"
|
||||
"deleteAll": "Delete all",
|
||||
"selectVersion": "Select Version"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Shaders",
|
||||
|
||||
@@ -703,7 +703,8 @@
|
||||
"noDownloadsTree": "Sin descargas",
|
||||
"pauseAll": "Pausar todo",
|
||||
"resumeAll": "Reanudar todo",
|
||||
"deleteAll": "Eliminar todo"
|
||||
"deleteAll": "Eliminar todo",
|
||||
"selectVersion": "Seleccionar versión"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Shaders",
|
||||
|
||||
@@ -703,7 +703,8 @@
|
||||
"noDownloadsTree": "Aucun téléchargement",
|
||||
"pauseAll": "Tout mettre en pause",
|
||||
"resumeAll": "Tout reprendre",
|
||||
"deleteAll": "Tout supprimer"
|
||||
"deleteAll": "Tout supprimer",
|
||||
"selectVersion": "Sélectionner la version"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Shaders",
|
||||
|
||||
@@ -601,7 +601,8 @@
|
||||
"noDownloadsTree": "Nessun download",
|
||||
"pauseAll": "Metti tutto in pausa",
|
||||
"resumeAll": "Riprendi tutto",
|
||||
"deleteAll": "Elimina tutto"
|
||||
"deleteAll": "Elimina tutto",
|
||||
"selectVersion": "Seleziona versione"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Playlist",
|
||||
|
||||
@@ -703,7 +703,8 @@
|
||||
"noDownloadsTree": "ダウンロードなし",
|
||||
"pauseAll": "すべて一時停止",
|
||||
"resumeAll": "すべて再開",
|
||||
"deleteAll": "すべて削除"
|
||||
"deleteAll": "すべて削除",
|
||||
"selectVersion": "バージョンを選択"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "シェーダー",
|
||||
|
||||
@@ -703,7 +703,8 @@
|
||||
"noDownloadsTree": "다운로드 없음",
|
||||
"pauseAll": "모두 일시정지",
|
||||
"resumeAll": "모두 재개",
|
||||
"deleteAll": "모두 삭제"
|
||||
"deleteAll": "모두 삭제",
|
||||
"selectVersion": "버전 선택"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "셰이더",
|
||||
|
||||
@@ -703,7 +703,8 @@
|
||||
"noDownloadsTree": "Ingen nedlastinger",
|
||||
"pauseAll": "Pause alle",
|
||||
"resumeAll": "Gjenoppta alle",
|
||||
"deleteAll": "Slett alle"
|
||||
"deleteAll": "Slett alle",
|
||||
"selectVersion": "Velg versjon"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Shadere",
|
||||
|
||||
@@ -601,7 +601,8 @@
|
||||
"noDownloadsTree": "Geen downloads",
|
||||
"pauseAll": "Alles pauzeren",
|
||||
"resumeAll": "Alles hervatten",
|
||||
"deleteAll": "Alles verwijderen"
|
||||
"deleteAll": "Alles verwijderen",
|
||||
"selectVersion": "Versie selecteren"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Afspeellijsten",
|
||||
|
||||
@@ -703,7 +703,8 @@
|
||||
"noDownloadsTree": "Brak pobrań",
|
||||
"pauseAll": "Wstrzymaj wszystko",
|
||||
"resumeAll": "Wznów wszystko",
|
||||
"deleteAll": "Usuń wszystko"
|
||||
"deleteAll": "Usuń wszystko",
|
||||
"selectVersion": "Wybierz wersję"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Shadery",
|
||||
|
||||
@@ -703,7 +703,8 @@
|
||||
"noDownloadsTree": "Nenhum download",
|
||||
"pauseAll": "Pausar todos",
|
||||
"resumeAll": "Retomar todos",
|
||||
"deleteAll": "Excluir todos"
|
||||
"deleteAll": "Excluir todos",
|
||||
"selectVersion": "Selecionar versão"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Shaders",
|
||||
|
||||
@@ -703,7 +703,8 @@
|
||||
"noDownloadsTree": "Нет загрузок",
|
||||
"pauseAll": "Приостановить все",
|
||||
"resumeAll": "Возобновить все",
|
||||
"deleteAll": "Удалить все"
|
||||
"deleteAll": "Удалить все",
|
||||
"selectVersion": "Выбрать версию"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Шейдеры",
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 15
|
||||
/// Strings: 12120 (808 per locale)
|
||||
/// Strings: 12135 (809 per locale)
|
||||
///
|
||||
/// Built on 2026-03-29 at 18:23 UTC
|
||||
/// Built on 2026-03-31 at 02:43 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -964,6 +964,7 @@ class _TranslationsDownloadsDa implements TranslationsDownloadsEn {
|
||||
@override String get pauseAll => 'Pause alle';
|
||||
@override String get resumeAll => 'Genoptag alle';
|
||||
@override String get deleteAll => 'Slet alle';
|
||||
@override String get selectVersion => 'Vælg version';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -1941,6 +1942,7 @@ extension on TranslationsDa {
|
||||
'downloads.pauseAll' => 'Pause alle',
|
||||
'downloads.resumeAll' => 'Genoptag alle',
|
||||
'downloads.deleteAll' => 'Slet alle',
|
||||
'downloads.selectVersion' => 'Vælg version',
|
||||
'shaders.title' => 'Shadere',
|
||||
'shaders.noShaderDescription' => 'Ingen videoforbedring',
|
||||
'shaders.nvscalerDescription' => 'NVIDIA-billedskalering for skarpere video',
|
||||
|
||||
@@ -841,6 +841,7 @@ class _TranslationsDownloadsDe implements TranslationsDownloadsEn {
|
||||
@override String get pauseAll => 'Alle pausieren';
|
||||
@override String get resumeAll => 'Alle fortsetzen';
|
||||
@override String get deleteAll => 'Alle löschen';
|
||||
@override String get selectVersion => 'Version auswählen';
|
||||
}
|
||||
|
||||
// Path: playlists
|
||||
@@ -1845,6 +1846,7 @@ extension on TranslationsDe {
|
||||
'downloads.pauseAll' => 'Alle pausieren',
|
||||
'downloads.resumeAll' => 'Alle fortsetzen',
|
||||
'downloads.deleteAll' => 'Alle löschen',
|
||||
'downloads.selectVersion' => 'Version auswählen',
|
||||
'playlists.title' => 'Wiedergabelisten',
|
||||
'playlists.noPlaylists' => 'Keine Wiedergabelisten gefunden',
|
||||
'playlists.create' => 'Wiedergabeliste erstellen',
|
||||
|
||||
@@ -2164,6 +2164,9 @@ class TranslationsDownloadsEn {
|
||||
|
||||
/// en: 'Delete all'
|
||||
String get deleteAll => 'Delete all';
|
||||
|
||||
/// en: 'Select Version'
|
||||
String get selectVersion => 'Select Version';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -3564,6 +3567,7 @@ extension on Translations {
|
||||
'downloads.pauseAll' => 'Pause all',
|
||||
'downloads.resumeAll' => 'Resume all',
|
||||
'downloads.deleteAll' => 'Delete all',
|
||||
'downloads.selectVersion' => 'Select Version',
|
||||
'shaders.title' => 'Shaders',
|
||||
'shaders.noShaderDescription' => 'No video enhancement',
|
||||
'shaders.nvscalerDescription' => 'NVIDIA image scaling for sharper video',
|
||||
|
||||
@@ -964,6 +964,7 @@ class _TranslationsDownloadsEs implements TranslationsDownloadsEn {
|
||||
@override String get pauseAll => 'Pausar todo';
|
||||
@override String get resumeAll => 'Reanudar todo';
|
||||
@override String get deleteAll => 'Eliminar todo';
|
||||
@override String get selectVersion => 'Seleccionar versión';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -1941,6 +1942,7 @@ extension on TranslationsEs {
|
||||
'downloads.pauseAll' => 'Pausar todo',
|
||||
'downloads.resumeAll' => 'Reanudar todo',
|
||||
'downloads.deleteAll' => 'Eliminar todo',
|
||||
'downloads.selectVersion' => 'Seleccionar versión',
|
||||
'shaders.title' => 'Shaders',
|
||||
'shaders.noShaderDescription' => 'Sin mejora de video',
|
||||
'shaders.nvscalerDescription' => 'Escalado de imagen NVIDIA para un video más nítido',
|
||||
|
||||
@@ -964,6 +964,7 @@ class _TranslationsDownloadsFr implements TranslationsDownloadsEn {
|
||||
@override String get pauseAll => 'Tout mettre en pause';
|
||||
@override String get resumeAll => 'Tout reprendre';
|
||||
@override String get deleteAll => 'Tout supprimer';
|
||||
@override String get selectVersion => 'Sélectionner la version';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -1941,6 +1942,7 @@ extension on TranslationsFr {
|
||||
'downloads.pauseAll' => 'Tout mettre en pause',
|
||||
'downloads.resumeAll' => 'Tout reprendre',
|
||||
'downloads.deleteAll' => 'Tout supprimer',
|
||||
'downloads.selectVersion' => 'Sélectionner la version',
|
||||
'shaders.title' => 'Shaders',
|
||||
'shaders.noShaderDescription' => 'Aucune amélioration vidéo',
|
||||
'shaders.nvscalerDescription' => 'Mise à l\'échelle NVIDIA pour une vidéo plus nette',
|
||||
|
||||
@@ -841,6 +841,7 @@ class _TranslationsDownloadsIt implements TranslationsDownloadsEn {
|
||||
@override String get pauseAll => 'Metti tutto in pausa';
|
||||
@override String get resumeAll => 'Riprendi tutto';
|
||||
@override String get deleteAll => 'Elimina tutto';
|
||||
@override String get selectVersion => 'Seleziona versione';
|
||||
}
|
||||
|
||||
// Path: playlists
|
||||
@@ -1845,6 +1846,7 @@ extension on TranslationsIt {
|
||||
'downloads.pauseAll' => 'Metti tutto in pausa',
|
||||
'downloads.resumeAll' => 'Riprendi tutto',
|
||||
'downloads.deleteAll' => 'Elimina tutto',
|
||||
'downloads.selectVersion' => 'Seleziona versione',
|
||||
'playlists.title' => 'Playlist',
|
||||
'playlists.noPlaylists' => 'Nessuna playlist trovata',
|
||||
'playlists.create' => 'Crea playlist',
|
||||
|
||||
@@ -964,6 +964,7 @@ class _TranslationsDownloadsJa implements TranslationsDownloadsEn {
|
||||
@override String get pauseAll => 'すべて一時停止';
|
||||
@override String get resumeAll => 'すべて再開';
|
||||
@override String get deleteAll => 'すべて削除';
|
||||
@override String get selectVersion => 'バージョンを選択';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -1941,6 +1942,7 @@ extension on TranslationsJa {
|
||||
'downloads.pauseAll' => 'すべて一時停止',
|
||||
'downloads.resumeAll' => 'すべて再開',
|
||||
'downloads.deleteAll' => 'すべて削除',
|
||||
'downloads.selectVersion' => 'バージョンを選択',
|
||||
'shaders.title' => 'シェーダー',
|
||||
'shaders.noShaderDescription' => '映像補正なし',
|
||||
'shaders.nvscalerDescription' => 'よりシャープな映像のためのNVIDIA画像スケーリング',
|
||||
|
||||
@@ -964,6 +964,7 @@ class _TranslationsDownloadsKo implements TranslationsDownloadsEn {
|
||||
@override String get pauseAll => '모두 일시정지';
|
||||
@override String get resumeAll => '모두 재개';
|
||||
@override String get deleteAll => '모두 삭제';
|
||||
@override String get selectVersion => '버전 선택';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -1941,6 +1942,7 @@ extension on TranslationsKo {
|
||||
'downloads.pauseAll' => '모두 일시정지',
|
||||
'downloads.resumeAll' => '모두 재개',
|
||||
'downloads.deleteAll' => '모두 삭제',
|
||||
'downloads.selectVersion' => '버전 선택',
|
||||
'shaders.title' => '셰이더',
|
||||
'shaders.noShaderDescription' => '비디오 향상 없음',
|
||||
'shaders.nvscalerDescription' => '더 선명한 비디오를 위한 NVIDIA 이미지 스케일링',
|
||||
|
||||
@@ -964,6 +964,7 @@ class _TranslationsDownloadsNb implements TranslationsDownloadsEn {
|
||||
@override String get pauseAll => 'Pause alle';
|
||||
@override String get resumeAll => 'Gjenoppta alle';
|
||||
@override String get deleteAll => 'Slett alle';
|
||||
@override String get selectVersion => 'Velg versjon';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -1941,6 +1942,7 @@ extension on TranslationsNb {
|
||||
'downloads.pauseAll' => 'Pause alle',
|
||||
'downloads.resumeAll' => 'Gjenoppta alle',
|
||||
'downloads.deleteAll' => 'Slett alle',
|
||||
'downloads.selectVersion' => 'Velg versjon',
|
||||
'shaders.title' => 'Shadere',
|
||||
'shaders.noShaderDescription' => 'Ingen videoforbedring',
|
||||
'shaders.nvscalerDescription' => 'NVIDIA bildeskalering for skarpere video',
|
||||
|
||||
@@ -841,6 +841,7 @@ class _TranslationsDownloadsNl implements TranslationsDownloadsEn {
|
||||
@override String get pauseAll => 'Alles pauzeren';
|
||||
@override String get resumeAll => 'Alles hervatten';
|
||||
@override String get deleteAll => 'Alles verwijderen';
|
||||
@override String get selectVersion => 'Versie selecteren';
|
||||
}
|
||||
|
||||
// Path: playlists
|
||||
@@ -1845,6 +1846,7 @@ extension on TranslationsNl {
|
||||
'downloads.pauseAll' => 'Alles pauzeren',
|
||||
'downloads.resumeAll' => 'Alles hervatten',
|
||||
'downloads.deleteAll' => 'Alles verwijderen',
|
||||
'downloads.selectVersion' => 'Versie selecteren',
|
||||
'playlists.title' => 'Afspeellijsten',
|
||||
'playlists.noPlaylists' => 'Geen afspeellijsten gevonden',
|
||||
'playlists.create' => 'Afspeellijst maken',
|
||||
|
||||
@@ -964,6 +964,7 @@ class _TranslationsDownloadsPl implements TranslationsDownloadsEn {
|
||||
@override String get pauseAll => 'Wstrzymaj wszystko';
|
||||
@override String get resumeAll => 'Wznów wszystko';
|
||||
@override String get deleteAll => 'Usuń wszystko';
|
||||
@override String get selectVersion => 'Wybierz wersję';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -1941,6 +1942,7 @@ extension on TranslationsPl {
|
||||
'downloads.pauseAll' => 'Wstrzymaj wszystko',
|
||||
'downloads.resumeAll' => 'Wznów wszystko',
|
||||
'downloads.deleteAll' => 'Usuń wszystko',
|
||||
'downloads.selectVersion' => 'Wybierz wersję',
|
||||
'shaders.title' => 'Shadery',
|
||||
'shaders.noShaderDescription' => 'Bez ulepszenia wideo',
|
||||
'shaders.nvscalerDescription' => 'Skalowanie obrazu NVIDIA dla ostrzejszego wideo',
|
||||
|
||||
@@ -964,6 +964,7 @@ class _TranslationsDownloadsPt implements TranslationsDownloadsEn {
|
||||
@override String get pauseAll => 'Pausar todos';
|
||||
@override String get resumeAll => 'Retomar todos';
|
||||
@override String get deleteAll => 'Excluir todos';
|
||||
@override String get selectVersion => 'Selecionar versão';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -1941,6 +1942,7 @@ extension on TranslationsPt {
|
||||
'downloads.pauseAll' => 'Pausar todos',
|
||||
'downloads.resumeAll' => 'Retomar todos',
|
||||
'downloads.deleteAll' => 'Excluir todos',
|
||||
'downloads.selectVersion' => 'Selecionar versão',
|
||||
'shaders.title' => 'Shaders',
|
||||
'shaders.noShaderDescription' => 'Sem aprimoramento de vídeo',
|
||||
'shaders.nvscalerDescription' => 'Escalonamento de imagem NVIDIA para vídeo mais nítido',
|
||||
|
||||
@@ -964,6 +964,7 @@ class _TranslationsDownloadsRu implements TranslationsDownloadsEn {
|
||||
@override String get pauseAll => 'Приостановить все';
|
||||
@override String get resumeAll => 'Возобновить все';
|
||||
@override String get deleteAll => 'Удалить все';
|
||||
@override String get selectVersion => 'Выбрать версию';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -1941,6 +1942,7 @@ extension on TranslationsRu {
|
||||
'downloads.pauseAll' => 'Приостановить все',
|
||||
'downloads.resumeAll' => 'Возобновить все',
|
||||
'downloads.deleteAll' => 'Удалить все',
|
||||
'downloads.selectVersion' => 'Выбрать версию',
|
||||
'shaders.title' => 'Шейдеры',
|
||||
'shaders.noShaderDescription' => 'Без улучшения видео',
|
||||
'shaders.nvscalerDescription' => 'Масштабирование NVIDIA для более чёткого видео',
|
||||
|
||||
@@ -841,6 +841,7 @@ class _TranslationsDownloadsSv implements TranslationsDownloadsEn {
|
||||
@override String get pauseAll => 'Pausa alla';
|
||||
@override String get resumeAll => 'Återuppta alla';
|
||||
@override String get deleteAll => 'Ta bort alla';
|
||||
@override String get selectVersion => 'Välj version';
|
||||
}
|
||||
|
||||
// Path: playlists
|
||||
@@ -1845,6 +1846,7 @@ extension on TranslationsSv {
|
||||
'downloads.pauseAll' => 'Pausa alla',
|
||||
'downloads.resumeAll' => 'Återuppta alla',
|
||||
'downloads.deleteAll' => 'Ta bort alla',
|
||||
'downloads.selectVersion' => 'Välj version',
|
||||
'playlists.title' => 'Spellistor',
|
||||
'playlists.noPlaylists' => 'Inga spellistor hittades',
|
||||
'playlists.create' => 'Skapa spellista',
|
||||
|
||||
@@ -841,6 +841,7 @@ class _TranslationsDownloadsZh implements TranslationsDownloadsEn {
|
||||
@override String get pauseAll => '全部暂停';
|
||||
@override String get resumeAll => '全部继续';
|
||||
@override String get deleteAll => '全部删除';
|
||||
@override String get selectVersion => '选择版本';
|
||||
}
|
||||
|
||||
// Path: playlists
|
||||
@@ -1845,6 +1846,7 @@ extension on TranslationsZh {
|
||||
'downloads.pauseAll' => '全部暂停',
|
||||
'downloads.resumeAll' => '全部继续',
|
||||
'downloads.deleteAll' => '全部删除',
|
||||
'downloads.selectVersion' => '选择版本',
|
||||
'playlists.title' => '播放列表',
|
||||
'playlists.noPlaylists' => '未找到播放列表',
|
||||
'playlists.create' => '创建播放列表',
|
||||
|
||||
@@ -601,7 +601,8 @@
|
||||
"noDownloadsTree": "Inga nedladdningar",
|
||||
"pauseAll": "Pausa alla",
|
||||
"resumeAll": "Återuppta alla",
|
||||
"deleteAll": "Ta bort alla"
|
||||
"deleteAll": "Ta bort alla",
|
||||
"selectVersion": "Välj version"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Spellistor",
|
||||
|
||||
@@ -601,7 +601,8 @@
|
||||
"noDownloadsTree": "暂无下载",
|
||||
"pauseAll": "全部暂停",
|
||||
"resumeAll": "全部继续",
|
||||
"deleteAll": "全部删除"
|
||||
"deleteAll": "全部删除",
|
||||
"selectVersion": "选择版本"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "播放列表",
|
||||
|
||||
@@ -72,6 +72,49 @@ class PlexMediaVersion {
|
||||
return label;
|
||||
}
|
||||
|
||||
/// Version signature for matching across episodes.
|
||||
/// Format: "resolution:codec:container" (e.g., "1080:h264:mkv")
|
||||
String get signature {
|
||||
final res = videoResolution ?? '';
|
||||
final codec = videoCodec ?? '';
|
||||
final cont = container ?? '';
|
||||
return '$res:$codec:$cont'.toLowerCase();
|
||||
}
|
||||
|
||||
String get _resolutionPart => (videoResolution ?? '').toLowerCase();
|
||||
String get _codecPart => (videoCodec ?? '').toLowerCase();
|
||||
|
||||
/// Find the best matching version index from a set of accepted signatures.
|
||||
/// Uses tiered matching: exact → resolution+codec → resolution only.
|
||||
/// Returns null if no accepted signature matches at all.
|
||||
static int? findMatchingIndex(List<PlexMediaVersion> versions, Set<String> acceptedSignatures) {
|
||||
if (versions.isEmpty || acceptedSignatures.isEmpty) return null;
|
||||
|
||||
for (final sig in acceptedSignatures) {
|
||||
final parts = sig.split(':');
|
||||
if (parts.length != 3) continue;
|
||||
final targetRes = parts[0];
|
||||
final targetCodec = parts[1];
|
||||
|
||||
// Tier 1: exact match
|
||||
for (int i = 0; i < versions.length; i++) {
|
||||
if (versions[i].signature == sig) return i;
|
||||
}
|
||||
|
||||
// Tier 2: resolution + codec
|
||||
for (int i = 0; i < versions.length; i++) {
|
||||
if (versions[i]._resolutionPart == targetRes && versions[i]._codecPart == targetCodec) return i;
|
||||
}
|
||||
|
||||
// Tier 3: resolution only
|
||||
for (int i = 0; i < versions.length; i++) {
|
||||
if (versions[i]._resolutionPart == targetRes) return i;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => displayLabel;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import 'dart:collection';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:plezy/utils/content_utils.dart';
|
||||
import '../models/download_models.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../utils/download_version_utils.dart';
|
||||
import '../services/download_manager_service.dart';
|
||||
import '../services/download_storage_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
@@ -593,8 +595,13 @@ class DownloadProvider extends ChangeNotifier {
|
||||
/// For movies and episodes, queues directly.
|
||||
/// For shows and seasons, fetches all child episodes and queues them.
|
||||
/// Returns the number of items queued.
|
||||
Future<int> queueDownload(PlexMetadata metadata, PlexClient client) async {
|
||||
Future<int> queueDownload(
|
||||
PlexMetadata metadata,
|
||||
PlexClient client, {
|
||||
DownloadVersionConfig? versionConfig,
|
||||
}) async {
|
||||
final globalKey = metadata.globalKey;
|
||||
final config = versionConfig ?? DownloadVersionConfig();
|
||||
|
||||
// Check if downloads are blocked on cellular
|
||||
if (await DownloadManagerService.shouldBlockDownloadOnCellular()) {
|
||||
@@ -609,40 +616,38 @@ class DownloadProvider extends ChangeNotifier {
|
||||
final mt = metadata.mediaType;
|
||||
|
||||
if (mt == PlexMediaType.movie || mt == PlexMediaType.episode) {
|
||||
// Direct download of a single item
|
||||
await _queueSingleDownload(metadata, client);
|
||||
return 1;
|
||||
final queued = await _queueSingleDownload(metadata, client, mediaIndex: config.mediaIndex);
|
||||
return queued ? 1 : 0;
|
||||
} else if (mt == PlexMediaType.show) {
|
||||
// Store show metadata so getProgress() can identify it as a show
|
||||
_metadata[globalKey] = metadata;
|
||||
|
||||
// Download all episodes from all seasons
|
||||
return await _queueShowDownload(metadata, client);
|
||||
return await _queueShowDownload(metadata, client, versionConfig: config);
|
||||
} else if (mt == PlexMediaType.season) {
|
||||
// Store season metadata so getProgress() can identify it as a season
|
||||
_metadata[globalKey] = metadata;
|
||||
|
||||
// Download all episodes in season
|
||||
return await _queueSeasonDownload(metadata, client);
|
||||
return await _queueSeasonDownload(metadata, client, versionConfig: config);
|
||||
} else {
|
||||
throw Exception('Cannot download ${metadata.type}');
|
||||
}
|
||||
} finally {
|
||||
// Always remove from queueing set, even on error
|
||||
_queueing.remove(globalKey);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a single movie or episode for download
|
||||
Future<void> _queueSingleDownload(PlexMetadata metadata, PlexClient client) async {
|
||||
/// Queue a single movie or episode for download.
|
||||
/// Returns true if the item was actually queued, false if skipped.
|
||||
Future<bool> _queueSingleDownload(
|
||||
PlexMetadata metadata,
|
||||
PlexClient client, {
|
||||
int mediaIndex = 0,
|
||||
DownloadVersionConfig? versionConfig,
|
||||
}) async {
|
||||
final globalKey = metadata.globalKey;
|
||||
|
||||
// Don't re-queue if already downloading or completed
|
||||
if (_downloads.containsKey(globalKey)) {
|
||||
final existing = _downloads[globalKey]!;
|
||||
if (existing.status == DownloadStatus.downloading || existing.status == DownloadStatus.completed) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,6 +665,23 @@ class DownloadProvider extends ChangeNotifier {
|
||||
appLogger.w('Failed to fetch full metadata for ${metadata.ratingKey}, using partial', error: e);
|
||||
}
|
||||
|
||||
// Smart version matching for series/season downloads
|
||||
var resolvedIndex = mediaIndex;
|
||||
if (versionConfig != null && versionConfig.acceptedSignatures.isNotEmpty) {
|
||||
final versions = metadataToStore.mediaVersions;
|
||||
if (versions != null && versions.isNotEmpty) {
|
||||
final matchedIndex = PlexMediaVersion.findMatchingIndex(versions, versionConfig.acceptedSignatures);
|
||||
if (matchedIndex != null) {
|
||||
resolvedIndex = matchedIndex;
|
||||
} else if (versionConfig.onVersionMismatch != null) {
|
||||
final pickedIndex = await versionConfig.onVersionMismatch!(metadataToStore, versions);
|
||||
if (pickedIndex == null) return false;
|
||||
resolvedIndex = pickedIndex;
|
||||
versionConfig.acceptedSignatures.add(versions[pickedIndex].signature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For episodes, also fetch and store show and season metadata for offline display
|
||||
if (metadataToStore.type == 'episode') {
|
||||
await _fetchAndStoreParentMetadata(metadataToStore, client);
|
||||
@@ -673,7 +695,8 @@ class DownloadProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
// Actually trigger download via DownloadManagerService
|
||||
await _downloadManager.queueDownload(metadata: metadataToStore, client: client);
|
||||
await _downloadManager.queueDownload(metadata: metadataToStore, client: client, mediaIndex: resolvedIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Fetch and store show and season metadata for an episode
|
||||
@@ -727,7 +750,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Queue all episodes from a TV show for download
|
||||
Future<int> _queueShowDownload(PlexMetadata show, PlexClient client) async {
|
||||
Future<int> _queueShowDownload(PlexMetadata show, PlexClient client, {DownloadVersionConfig? versionConfig}) async {
|
||||
int count = 0;
|
||||
final seasons = await client.getChildren(show.ratingKey);
|
||||
|
||||
@@ -736,7 +759,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
for (final season in seasons) {
|
||||
if (season.type == 'season') {
|
||||
final seasonWithServer = _ensureServerId(season, show.serverId);
|
||||
count += await _queueSeasonDownload(seasonWithServer, client);
|
||||
count += await _queueSeasonDownload(seasonWithServer, client, versionConfig: versionConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -744,7 +767,8 @@ class DownloadProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Queue all episodes from a season for download
|
||||
Future<int> _queueSeasonDownload(PlexMetadata season, PlexClient client) async {
|
||||
Future<int> _queueSeasonDownload(PlexMetadata season, PlexClient client,
|
||||
{DownloadVersionConfig? versionConfig}) async {
|
||||
int count = 0;
|
||||
final episodes = await client.getChildren(season.ratingKey);
|
||||
|
||||
@@ -753,8 +777,8 @@ class DownloadProvider extends ChangeNotifier {
|
||||
for (final episode in episodes) {
|
||||
if (episode.type == 'episode') {
|
||||
final episodeWithServer = _ensureServerId(episode, season.serverId);
|
||||
await _queueSingleDownload(episodeWithServer, client);
|
||||
count++;
|
||||
final queued = await _queueSingleDownload(episodeWithServer, client, versionConfig: versionConfig);
|
||||
if (queued) count++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,29 +788,33 @@ class DownloadProvider extends ChangeNotifier {
|
||||
/// Queue only the missing (not downloaded) episodes for a show/season
|
||||
/// Used for resuming partial downloads
|
||||
/// Returns the number of episodes queued
|
||||
Future<int> queueMissingEpisodes(PlexMetadata metadata, PlexClient client) async {
|
||||
Future<int> queueMissingEpisodes(
|
||||
PlexMetadata metadata,
|
||||
PlexClient client, {
|
||||
DownloadVersionConfig? versionConfig,
|
||||
}) async {
|
||||
final mt = metadata.mediaType;
|
||||
|
||||
if (mt == PlexMediaType.show) {
|
||||
return await _queueMissingShowEpisodes(metadata, client);
|
||||
return await _queueMissingShowEpisodes(metadata, client, versionConfig: versionConfig);
|
||||
} else if (mt == PlexMediaType.season) {
|
||||
return await _queueMissingSeasonEpisodes(metadata, client);
|
||||
return await _queueMissingSeasonEpisodes(metadata, client, versionConfig: versionConfig);
|
||||
} else {
|
||||
throw Exception('queueMissingEpisodes only supports shows/seasons');
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue missing episodes for a show
|
||||
Future<int> _queueMissingShowEpisodes(PlexMetadata show, PlexClient client) async {
|
||||
Future<int> _queueMissingShowEpisodes(PlexMetadata show, PlexClient client,
|
||||
{DownloadVersionConfig? versionConfig}) async {
|
||||
int queuedCount = 0;
|
||||
|
||||
// Fetch all seasons
|
||||
final seasons = await client.getChildren(show.ratingKey);
|
||||
|
||||
for (final season in seasons) {
|
||||
if (season.type == 'season') {
|
||||
final seasonWithServer = _ensureServerId(season, show.serverId);
|
||||
queuedCount += await _queueMissingSeasonEpisodes(seasonWithServer, client);
|
||||
queuedCount += await _queueMissingSeasonEpisodes(seasonWithServer, client, versionConfig: versionConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,10 +823,10 @@ class DownloadProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Queue missing episodes for a season
|
||||
Future<int> _queueMissingSeasonEpisodes(PlexMetadata season, PlexClient client) async {
|
||||
Future<int> _queueMissingSeasonEpisodes(PlexMetadata season, PlexClient client,
|
||||
{DownloadVersionConfig? versionConfig}) async {
|
||||
int queuedCount = 0;
|
||||
|
||||
// Fetch all episodes
|
||||
final episodes = await client.getChildren(season.ratingKey);
|
||||
|
||||
for (final episode in episodes) {
|
||||
@@ -813,9 +841,11 @@ class DownloadProvider extends ChangeNotifier {
|
||||
(progress.status != DownloadStatus.completed &&
|
||||
progress.status != DownloadStatus.downloading &&
|
||||
progress.status != DownloadStatus.queued)) {
|
||||
await _queueSingleDownload(episodeWithServer, client);
|
||||
queuedCount++;
|
||||
appLogger.d('Queued missing episode: ${episode.title} ($episodeGlobalKey)');
|
||||
final queued = await _queueSingleDownload(episodeWithServer, client, versionConfig: versionConfig);
|
||||
if (queued) {
|
||||
queuedCount++;
|
||||
appLogger.d('Queued missing episode: ${episode.title} ($episodeGlobalKey)');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import '../utils/content_utils.dart';
|
||||
import '../utils/rating_utils.dart';
|
||||
import '../models/download_models.dart';
|
||||
import '../services/download_storage_service.dart';
|
||||
import '../utils/download_version_utils.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/offline_watch_provider.dart';
|
||||
@@ -640,10 +641,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
// Delete failed download and retry
|
||||
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
|
||||
if (versionConfig == null || !context.mounted) return;
|
||||
|
||||
await downloadProvider.deleteDownload(globalKey);
|
||||
try {
|
||||
await downloadProvider.queueDownload(metadata, client);
|
||||
await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig);
|
||||
|
||||
if (context.mounted) {
|
||||
showSuccessSnackBar(context, t.downloads.downloadQueued);
|
||||
@@ -682,9 +685,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
} else if (retry && context.mounted) {
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
|
||||
if (versionConfig == null || !context.mounted) return;
|
||||
|
||||
await downloadProvider.deleteDownload(globalKey);
|
||||
try {
|
||||
await downloadProvider.queueDownload(metadata, client);
|
||||
await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig);
|
||||
if (context.mounted) {
|
||||
showSuccessSnackBar(context, t.downloads.downloadQueued);
|
||||
}
|
||||
@@ -714,8 +721,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
// Queue only the missing episodes
|
||||
final count = await downloadProvider.queueMissingEpisodes(metadata, client);
|
||||
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
|
||||
if (versionConfig == null || !context.mounted) return;
|
||||
|
||||
final count = await downloadProvider.queueMissingEpisodes(
|
||||
metadata,
|
||||
client,
|
||||
versionConfig: versionConfig,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
final message = count > 0
|
||||
@@ -761,8 +774,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
onPressed: () async {
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
|
||||
if (versionConfig == null || !context.mounted) return;
|
||||
|
||||
try {
|
||||
final count = await downloadProvider.queueDownload(metadata, client);
|
||||
final count = await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig);
|
||||
if (context.mounted) {
|
||||
final message = count > 1
|
||||
? t.downloads.episodesQueued(count: count)
|
||||
@@ -1058,6 +1075,20 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
return getServerBoundClient(context);
|
||||
}
|
||||
|
||||
/// Resolve version selection for download using shared utility.
|
||||
Future<DownloadVersionConfig?> _resolveDownloadVersion(
|
||||
BuildContext context,
|
||||
PlexMetadata metadata,
|
||||
PlexClient client,
|
||||
) {
|
||||
return resolveDownloadVersion(
|
||||
context,
|
||||
metadata,
|
||||
client,
|
||||
fallbackVersions: _fullMetadata?.mediaVersions,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadFullMetadata() async {
|
||||
setState(() {
|
||||
_isLoadingMetadata = true;
|
||||
|
||||
@@ -329,6 +329,7 @@ class DownloadManagerService {
|
||||
int priority = 0,
|
||||
bool downloadSubtitles = true,
|
||||
bool downloadArtwork = true,
|
||||
int mediaIndex = 0,
|
||||
}) async {
|
||||
final globalKey = metadata.globalKey;
|
||||
|
||||
@@ -349,6 +350,7 @@ class DownloadManagerService {
|
||||
parentRatingKey: metadata.parentRatingKey,
|
||||
grandparentRatingKey: metadata.grandparentRatingKey,
|
||||
status: DownloadStatus.queued.index,
|
||||
mediaIndex: mediaIndex,
|
||||
);
|
||||
|
||||
// Ensure metadata is in cache before pinning.
|
||||
@@ -430,14 +432,15 @@ class DownloadManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
var playbackData = await client.getVideoPlaybackData(metadata.ratingKey);
|
||||
final selectedMediaIndex = existing.mediaIndex;
|
||||
var playbackData = await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: selectedMediaIndex);
|
||||
if (playbackData.videoUrl == null) {
|
||||
// Cache may contain a synthetic entry (from _cacheMetadataForOffline) without
|
||||
// Media/Part data. Force a fresh network fetch to populate the cache properly.
|
||||
appLogger.w('No video URL from cache for $globalKey, retrying via network');
|
||||
final fetched = await client.getMetadataWithImages(ratingKey);
|
||||
if (fetched != null) metadata = fetched.copyWith(serverId: serverId);
|
||||
playbackData = await client.getVideoPlaybackData(metadata.ratingKey);
|
||||
playbackData = await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: selectedMediaIndex);
|
||||
if (playbackData.videoUrl == null) throw Exception('Could not get video URL for $globalKey');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../widgets/app_icon.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Configuration for download version selection, threaded through the queue pipeline.
|
||||
class DownloadVersionConfig {
|
||||
final int mediaIndex;
|
||||
final Set<String> acceptedSignatures;
|
||||
final Future<int?> Function(PlexMetadata episode, List<PlexMediaVersion> versions)? onVersionMismatch;
|
||||
|
||||
DownloadVersionConfig({
|
||||
this.mediaIndex = 0,
|
||||
Set<String>? acceptedSignatures,
|
||||
this.onVersionMismatch,
|
||||
}) : acceptedSignatures = acceptedSignatures ?? {};
|
||||
|
||||
/// Create from a selected version's signature.
|
||||
factory DownloadVersionConfig.fromSignature(
|
||||
String signature, {
|
||||
int mediaIndex = 0,
|
||||
Future<int?> Function(PlexMetadata, List<PlexMediaVersion>)? onVersionMismatch,
|
||||
}) {
|
||||
return DownloadVersionConfig(
|
||||
mediaIndex: mediaIndex,
|
||||
acceptedSignatures: {signature},
|
||||
onVersionMismatch: onVersionMismatch,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve version selection for a download. Shows picker if needed.
|
||||
/// Returns null if the user cancels, or a config with the selection.
|
||||
Future<DownloadVersionConfig?> resolveDownloadVersion(
|
||||
BuildContext context,
|
||||
PlexMetadata metadata,
|
||||
PlexClient client, {
|
||||
List<PlexMediaVersion>? fallbackVersions,
|
||||
}) async {
|
||||
final mediaType = metadata.mediaType;
|
||||
|
||||
if (mediaType == PlexMediaType.movie || mediaType == PlexMediaType.episode) {
|
||||
final versions = metadata.mediaVersions ?? fallbackVersions;
|
||||
if (versions != null && versions.length > 1) {
|
||||
final selectedIndex = await showVersionPickerDialog(context, versions, t.downloads.selectVersion);
|
||||
if (selectedIndex == null || !context.mounted) return null;
|
||||
return DownloadVersionConfig(mediaIndex: selectedIndex);
|
||||
}
|
||||
return DownloadVersionConfig();
|
||||
}
|
||||
|
||||
if (mediaType == PlexMediaType.show || mediaType == PlexMediaType.season) {
|
||||
final versions = await fetchRepresentativeVersions(client, metadata);
|
||||
if (versions != null && versions.length > 1) {
|
||||
if (!context.mounted) return null;
|
||||
final selectedIndex = await showVersionPickerDialog(context, versions, t.downloads.selectVersion);
|
||||
if (selectedIndex == null || !context.mounted) return null;
|
||||
return DownloadVersionConfig.fromSignature(
|
||||
versions[selectedIndex].signature,
|
||||
mediaIndex: selectedIndex,
|
||||
onVersionMismatch: (episode, episodeVersions) async {
|
||||
if (!context.mounted) return null;
|
||||
return showVersionPickerDialog(
|
||||
context,
|
||||
episodeVersions,
|
||||
'${episode.displayTitle} - ${t.downloads.selectVersion}',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
return DownloadVersionConfig();
|
||||
}
|
||||
|
||||
return DownloadVersionConfig();
|
||||
}
|
||||
|
||||
/// Show a dialog for selecting a media version.
|
||||
/// Returns the selected index, or null if cancelled.
|
||||
Future<int?> showVersionPickerDialog(BuildContext context, List<PlexMediaVersion> versions, String title) {
|
||||
return showDialog<int>(
|
||||
context: context,
|
||||
builder: (dialogContext) => SimpleDialog(
|
||||
title: Text(title),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: List.generate(versions.length, (index) {
|
||||
final version = versions[index];
|
||||
return SimpleDialogOption(
|
||||
onPressed: () => Navigator.pop(dialogContext, index),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
AppIcon(Symbols.video_file_rounded, fill: 1, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
Text(version.displayLabel, style: Theme.of(dialogContext).textTheme.bodyLarge),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetch media versions from a representative episode (first episode of first season).
|
||||
Future<List<PlexMediaVersion>?> fetchRepresentativeVersions(PlexClient client, PlexMetadata metadata) async {
|
||||
try {
|
||||
String? episodeRatingKey;
|
||||
|
||||
if (metadata.mediaType == PlexMediaType.season) {
|
||||
final episodes = await client.getChildren(metadata.ratingKey);
|
||||
final firstEpisode = episodes.cast<PlexMetadata?>().firstWhere((e) => e?.type == 'episode', orElse: () => null);
|
||||
episodeRatingKey = firstEpisode?.ratingKey;
|
||||
} else if (metadata.mediaType == PlexMediaType.show) {
|
||||
final seasons = await client.getChildren(metadata.ratingKey);
|
||||
// Skip Season 0 (Specials) as it may have different encoding
|
||||
final firstSeason = seasons.cast<PlexMetadata?>().firstWhere(
|
||||
(s) => s?.type == 'season' && (s?.index ?? 0) > 0,
|
||||
orElse: () => seasons.cast<PlexMetadata?>().firstWhere((s) => s?.type == 'season', orElse: () => null),
|
||||
);
|
||||
if (firstSeason != null) {
|
||||
final episodes = await client.getChildren(firstSeason.ratingKey);
|
||||
final firstEpisode =
|
||||
episodes.cast<PlexMetadata?>().firstWhere((e) => e?.type == 'episode', orElse: () => null);
|
||||
episodeRatingKey = firstEpisode?.ratingKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (episodeRatingKey == null) return null;
|
||||
|
||||
final fullMetadata = await client.getMetadataWithImages(episodeRatingKey);
|
||||
return fullMetadata?.mediaVersions;
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to fetch representative versions', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import '../services/plex_client.dart';
|
||||
import '../services/play_queue_launcher.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../utils/download_version_utils.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
@@ -647,27 +648,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final metadata = widget.item as PlexMetadata;
|
||||
final versions = metadata.mediaVersions!;
|
||||
|
||||
final selectedIndex = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (dialogContext) => SimpleDialog(
|
||||
title: Text(t.mediaMenu.playVersion),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: List.generate(versions.length, (index) {
|
||||
final version = versions[index];
|
||||
return SimpleDialogOption(
|
||||
onPressed: () => Navigator.pop(dialogContext, index),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
AppIcon(Symbols.video_file_rounded, fill: 1, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
Text(version.displayLabel, style: Theme.of(dialogContext).textTheme.bodyLarge),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
final selectedIndex = await showVersionPickerDialog(context, versions, t.mediaMenu.playVersion);
|
||||
|
||||
if (selectedIndex != null && context.mounted) {
|
||||
await navigateToVideoPlayer(context, metadata: metadata, selectedMediaIndex: selectedIndex);
|
||||
@@ -1146,9 +1127,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final client = _getClientForItem();
|
||||
|
||||
try {
|
||||
final count = await downloadProvider.queueDownload(metadata, client);
|
||||
final versionConfig = await resolveDownloadVersion(context, metadata, client);
|
||||
if (versionConfig == null) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
final count = await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig);
|
||||
if (context.mounted) {
|
||||
// Show appropriate message based on count
|
||||
final message = count > 1 ? t.downloads.episodesQueued(count: count) : t.downloads.downloadQueued;
|
||||
showSuccessSnackBar(context, message);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user