diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 3a2b9059..7ed46818 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -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'); + } + } }, ); } diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index 28e81134..22d2f799 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -198,6 +198,18 @@ class $DownloadedMediaTable extends DownloadedMedia type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _mediaIndexMeta = const VerificationMeta( + 'mediaIndex', + ); + @override + late final GeneratedColumn mediaIndex = GeneratedColumn( + 'media_index', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); @override List 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 toColumns(bool nullToAbsent) { @@ -517,6 +542,7 @@ class DownloadedMediaItem extends DataClass if (!nullToAbsent || bgTaskId != null) { map['bg_task_id'] = Variable(bgTaskId); } + map['media_index'] = Variable(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(json['errorMessage']), retryCount: serializer.fromJson(json['retryCount']), bgTaskId: serializer.fromJson(json['bgTaskId']), + mediaIndex: serializer.fromJson(json['mediaIndex']), ); } @override @@ -606,6 +634,7 @@ class DownloadedMediaItem extends DataClass 'errorMessage': serializer.toJson(errorMessage), 'retryCount': serializer.toJson(retryCount), 'bgTaskId': serializer.toJson(bgTaskId), + 'mediaIndex': serializer.toJson(mediaIndex), }; } @@ -627,6 +656,7 @@ class DownloadedMediaItem extends DataClass Value errorMessage = const Value.absent(), int? retryCount, Value 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 { @@ -775,6 +812,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { final Value errorMessage; final Value retryCount; final Value bgTaskId; + final Value mediaIndex; const DownloadedMediaCompanion({ this.id = const Value.absent(), this.serverId = const Value.absent(), @@ -793,6 +831,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { 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 { 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 { Expression? errorMessage, Expression? retryCount, Expression? bgTaskId, + Expression? mediaIndex, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -855,6 +896,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { 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 { Value? errorMessage, Value? retryCount, Value? bgTaskId, + Value? mediaIndex, }) { return DownloadedMediaCompanion( id: id ?? this.id, @@ -895,6 +938,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { errorMessage: errorMessage ?? this.errorMessage, retryCount: retryCount ?? this.retryCount, bgTaskId: bgTaskId ?? this.bgTaskId, + mediaIndex: mediaIndex ?? this.mediaIndex, ); } @@ -954,6 +998,9 @@ class DownloadedMediaCompanion extends UpdateCompanion { if (bgTaskId.present) { map['bg_task_id'] = Variable(bgTaskId.value); } + if (mediaIndex.present) { + map['media_index'] = Variable(mediaIndex.value); + } return map; } @@ -976,7 +1023,8 @@ class DownloadedMediaCompanion extends UpdateCompanion { ..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 errorMessage, Value retryCount, Value bgTaskId, + Value mediaIndex, }); typedef $$DownloadedMediaTableUpdateCompanionBuilder = DownloadedMediaCompanion Function({ @@ -2524,6 +2573,7 @@ typedef $$DownloadedMediaTableUpdateCompanionBuilder = Value errorMessage, Value retryCount, Value bgTaskId, + Value mediaIndex, }); class $$DownloadedMediaTableFilterComposer @@ -2619,6 +2669,11 @@ class $$DownloadedMediaTableFilterComposer column: $table.bgTaskId, builder: (column) => ColumnFilters(column), ); + + ColumnFilters 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 get mediaIndex => $composableBuilder( + column: $table.mediaIndex, + builder: (column) => ColumnOrderings(column), + ); } class $$DownloadedMediaTableAnnotationComposer @@ -2791,6 +2851,11 @@ class $$DownloadedMediaTableAnnotationComposer GeneratedColumn get bgTaskId => $composableBuilder(column: $table.bgTaskId, builder: (column) => column); + + GeneratedColumn get mediaIndex => $composableBuilder( + column: $table.mediaIndex, + builder: (column) => column, + ); } class $$DownloadedMediaTableTableManager @@ -2847,6 +2912,7 @@ class $$DownloadedMediaTableTableManager Value errorMessage = const Value.absent(), Value retryCount = const Value.absent(), Value bgTaskId = const Value.absent(), + Value 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 errorMessage = const Value.absent(), Value retryCount = const Value.absent(), Value bgTaskId = const Value.absent(), + Value 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))) diff --git a/lib/database/download_operations.dart b/lib/database/download_operations.dart index 2ec4336f..a8c532ac 100644 --- a/lib/database/download_operations.dart +++ b/lib/database/download_operations.dart @@ -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, ); diff --git a/lib/database/tables.dart b/lib/database/tables.dart index 17b0ca3b..cf8979f6 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -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. diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 3046891a..47dd0ad0 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -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", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 1b8f11b6..db7c3dd2 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index f806e5a3..5d2cd05a 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index ab3a3e1e..4f13c784 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 25be32e1..dc415658 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index c3d32a65..36785965 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 009747a8..96b1db25 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -703,7 +703,8 @@ "noDownloadsTree": "ダウンロードなし", "pauseAll": "すべて一時停止", "resumeAll": "すべて再開", - "deleteAll": "すべて削除" + "deleteAll": "すべて削除", + "selectVersion": "バージョンを選択" }, "shaders": { "title": "シェーダー", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 46e88712..6998c9f4 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -703,7 +703,8 @@ "noDownloadsTree": "다운로드 없음", "pauseAll": "모두 일시정지", "resumeAll": "모두 재개", - "deleteAll": "모두 삭제" + "deleteAll": "모두 삭제", + "selectVersion": "버전 선택" }, "shaders": { "title": "셰이더", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index eb6832a4..269d919d 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -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", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index da6c8d23..a549a61e 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 9d0e2b7b..cf126307 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -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", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 46881201..4bd96703 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -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", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 68d178af..1919668a 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -703,7 +703,8 @@ "noDownloadsTree": "Нет загрузок", "pauseAll": "Приостановить все", "resumeAll": "Возобновить все", - "deleteAll": "Удалить все" + "deleteAll": "Удалить все", + "selectVersion": "Выбрать версию" }, "shaders": { "title": "Шейдеры", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index c537969d..1602ea9c 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index d56450e9..a2c632fd 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -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', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 1f851f21..8a705208 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index c5aa7ea6..f81da097 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 62f1e894..cc023d52 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 7577029b..f2722005 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -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', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 400f1eab..ed7399cf 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -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', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 38d64a84..21d2ea35 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -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画像スケーリング', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 56b043f9..a17cba13 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -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 이미지 스케일링', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index fcb267f6..4cdab733 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -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', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index d244b68f..fd1aacc3 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -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', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index dcd39b1f..d7b72fc4 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -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', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 15426532..e01bfa87 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -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', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 325a2b8b..eeeb11a7 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -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 для более чёткого видео', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 18d63d5c..badcb211 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 3a791e30..a6f582a1 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -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' => '创建播放列表', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 80d18b6e..e8afab67 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 4f72d4eb..01af4fa0 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -601,7 +601,8 @@ "noDownloadsTree": "暂无下载", "pauseAll": "全部暂停", "resumeAll": "全部继续", - "deleteAll": "全部删除" + "deleteAll": "全部删除", + "selectVersion": "选择版本" }, "playlists": { "title": "播放列表", diff --git a/lib/models/plex_media_version.dart b/lib/models/plex_media_version.dart index a03466ed..78e23cb9 100644 --- a/lib/models/plex_media_version.dart +++ b/lib/models/plex_media_version.dart @@ -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 versions, Set 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; } diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 6c79437c..fb9229d2 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -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 queueDownload(PlexMetadata metadata, PlexClient client) async { + Future 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 _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 _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 _queueShowDownload(PlexMetadata show, PlexClient client) async { + Future _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 _queueSeasonDownload(PlexMetadata season, PlexClient client) async { + Future _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 queueMissingEpisodes(PlexMetadata metadata, PlexClient client) async { + Future 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 _queueMissingShowEpisodes(PlexMetadata show, PlexClient client) async { + Future _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 _queueMissingSeasonEpisodes(PlexMetadata season, PlexClient client) async { + Future _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)'); + } } } } diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index d6fd6511..8cc65a57 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -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 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 } 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 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 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 return getServerBoundClient(context); } + /// Resolve version selection for download using shared utility. + Future _resolveDownloadVersion( + BuildContext context, + PlexMetadata metadata, + PlexClient client, + ) { + return resolveDownloadVersion( + context, + metadata, + client, + fallbackVersions: _fullMetadata?.mediaVersions, + ); + } + Future _loadFullMetadata() async { setState(() { _isLoadingMetadata = true; diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 9293ba7b..3ff84fbb 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -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'); } diff --git a/lib/utils/download_version_utils.dart b/lib/utils/download_version_utils.dart new file mode 100644 index 00000000..c7d71cef --- /dev/null +++ b/lib/utils/download_version_utils.dart @@ -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 acceptedSignatures; + final Future Function(PlexMetadata episode, List versions)? onVersionMismatch; + + DownloadVersionConfig({ + this.mediaIndex = 0, + Set? acceptedSignatures, + this.onVersionMismatch, + }) : acceptedSignatures = acceptedSignatures ?? {}; + + /// Create from a selected version's signature. + factory DownloadVersionConfig.fromSignature( + String signature, { + int mediaIndex = 0, + Future Function(PlexMetadata, List)? 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 resolveDownloadVersion( + BuildContext context, + PlexMetadata metadata, + PlexClient client, { + List? 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 showVersionPickerDialog(BuildContext context, List versions, String title) { + return showDialog( + 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?> 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().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().firstWhere( + (s) => s?.type == 'season' && (s?.index ?? 0) > 0, + orElse: () => seasons.cast().firstWhere((s) => s?.type == 'season', orElse: () => null), + ); + if (firstSeason != null) { + final episodes = await client.getChildren(firstSeason.ratingKey); + final firstEpisode = + episodes.cast().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; + } +} diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 3ce06b15..8bade493 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -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 { final metadata = widget.item as PlexMetadata; final versions = metadata.mediaVersions!; - final selectedIndex = await showDialog( - 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 { 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); }