Files
plezy/lib/utils/download_utils.dart
edde746 a56b9a3dfb Merge the deduplication and dead-code removal pass
Consolidates duplicated logic behind shared implementations — paginated
grid tabs, focus chrome, cached remote stores, sheet selection columns,
the server artifact store and a test fixture layer — and removes code
that had become unreachable. Net reduction of about 5,500 lines with no
behaviour change.

Where a fix had landed separately in code that moved into a shared
helper, the fix was re-applied inside the helper rather than left behind
in the copy that went away.
2026-07-26 19:41:23 +02:00

568 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import '../media/ids.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../focus/focusable_action_bar.dart';
import '../i18n/strings.g.dart';
import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../media/media_server_client.dart';
import '../database/app_database.dart';
import '../providers/download_provider.dart';
import '../providers/multi_server_provider.dart';
import '../services/settings_service.dart';
import '../services/sync_rule_executor.dart';
import '../widgets/background_download_warning_banner.dart';
import '../widgets/dialog_action_button.dart';
import '../widgets/focusable_list_tile.dart';
import 'app_logger.dart';
import 'content_utils.dart';
import 'dialogs.dart';
import 'download_version_utils.dart';
import 'platform_detector.dart';
import 'snackbar_helper.dart';
@visibleForTesting
String? validateEpisodeCountInput(String text, {required bool allowZero}) {
final count = int.tryParse(text);
if (count == null || count < 0 || (!allowZero && count == 0)) {
return t.downloads.invalidEpisodeCount;
}
return null;
}
/// Dialog option for the download picker. Typed to avoid stringly-typed values.
enum _DownloadChoice { all, unwatched, next5, next10, custom, delete }
/// Whether the user chose a one-time download or a persistent sync rule.
enum _SyncChoice { downloadOnce, keepSynced }
enum SyncRuleRemovalResult { ruleOnly, ruleAndDownloads }
String syncRuleRemovalMessage(SyncRuleRemovalResult result) => result == SyncRuleRemovalResult.ruleAndDownloads
? t.downloads.syncRuleAndDownloadsRemoved
: t.downloads.syncRuleRemoved;
/// Result of the download dialog + queue operation.
class DownloadResult {
final int count;
final bool syncRuleCreated;
final bool syncRuleUpdated;
/// `true` when the rule targets a collection/playlist — affects the
/// "created" snackbar wording (no "unwatched episodes" suffix).
final bool isListRule;
/// `true` when the queued leaves are tracks (album/artist/track download)
/// — picks "tracks queued" over "episodes queued" wording.
final bool isMusic;
const DownloadResult({
required this.count,
this.syncRuleCreated = false,
this.syncRuleUpdated = false,
this.isListRule = false,
this.isMusic = false,
});
String toSnackBarMessage() {
if (syncRuleUpdated) return t.downloads.syncRuleUpdated;
if (syncRuleCreated) {
return isListRule ? t.downloads.syncRuleListCreated : t.downloads.syncRuleCreated(count: count.toString());
}
if (count > 1) return isMusic ? t.downloads.tracksQueued(count: count) : t.downloads.episodesQueued(count: count);
return t.downloads.downloadQueued;
}
}
/// Shows download options dialog for shows/seasons, then queues the download.
/// For movies/episodes, queues directly without a dialog.
/// Returns a [DownloadResult], or null if cancelled.
///
/// When [onDelete] is provided (i.e. the item already has downloads), a
/// "Delete download" row is appended to the show/season options dialog so the
/// completed-download button can double as a "download more / delete" menu.
/// Selecting it runs [onDelete] and returns null.
Future<DownloadResult?> showDownloadOptionsAndQueue(
BuildContext context, {
required MediaItem metadata,
required MediaServerClient client,
required DownloadProvider downloadProvider,
Future<void> Function()? onDelete,
}) async {
if (!await confirmBackgroundDownloadRestrictions(context) || !context.mounted) return null;
final kind = metadata.kind;
var filter = DownloadFilter.all;
int? maxCount;
bool keepSynced = false;
// Remembered "Include Specials" choice; the toggle is only shown for whole
// shows (a single season has no Specials to drop).
final settings = SettingsService.instanceOrNull;
bool includeSpecials = settings?.read(SettingsService.downloadIncludeSpecials) ?? true;
if (kind == MediaKind.show || kind == MediaKind.season) {
int? customCount;
final options = <({IconData? icon, String label, _DownloadChoice value})>[
(icon: Symbols.download_rounded, label: t.downloads.allEpisodes, value: _DownloadChoice.all),
(icon: Symbols.visibility_off_rounded, label: t.downloads.unwatchedOnly, value: _DownloadChoice.unwatched),
(icon: Symbols.filter_5_rounded, label: t.downloads.nextNUnwatched(count: 5), value: _DownloadChoice.next5),
(
icon: Symbols.filter_9_plus_rounded,
label: t.downloads.nextNUnwatched(count: 10),
value: _DownloadChoice.next10,
),
(icon: Symbols.tune_rounded, label: t.downloads.customAmount, value: _DownloadChoice.custom),
];
// Already-downloaded show/season: offer deletion as the last row.
if (onDelete != null) {
options.add((icon: Symbols.delete_rounded, label: t.downloads.deleteDownload, value: _DownloadChoice.delete));
}
final selected = await showOptionPickerDialog<_DownloadChoice>(
context,
title: t.downloads.downloadNow,
options: options,
toggle: kind == MediaKind.show
? (
label: t.downloads.includeSpecials,
icon: Symbols.star_rounded,
value: includeSpecials,
onChanged: (value) => includeSpecials = value,
)
: null,
onBeforeClose: (value) async {
if (value != _DownloadChoice.custom) return value;
customCount = await _showEpisodeCountDialog(context);
return customCount != null ? value : null;
},
);
if (selected == null || !context.mounted) return null;
switch (selected) {
case _DownloadChoice.all:
break;
case _DownloadChoice.unwatched:
filter = DownloadFilter.unwatched;
case _DownloadChoice.next5:
filter = DownloadFilter.unwatched;
maxCount = 5;
case _DownloadChoice.next10:
filter = DownloadFilter.unwatched;
maxCount = 10;
case _DownloadChoice.custom:
filter = DownloadFilter.unwatched;
maxCount = customCount;
case _DownloadChoice.delete:
if (onDelete != null) await onDelete();
return null;
}
if (filter == DownloadFilter.unwatched && kind == MediaKind.show && context.mounted) {
final syncChoice = await _showSyncChoiceDialog(context);
if (syncChoice == null || !context.mounted) return null;
keepSynced = syncChoice == _SyncChoice.keepSynced;
}
}
if (!context.mounted) return null;
final versionConfig = await resolveDownloadVersion(context, metadata, client);
if (versionConfig == null || !context.mounted) return null;
// Create or update sync rule before queueing (so the rule exists even if queue fails)
bool syncRuleUpdated = false;
if (keepSynced) {
final syncCount = maxCount ?? 0; // 0 means "all unwatched" for the rule
final ruleKey = downloadProvider.syncRuleKeyFor(ServerId(metadata.serverId ?? client.serverId), metadata.id);
syncRuleUpdated = downloadProvider.hasSyncRule(ruleKey);
await downloadProvider.createSyncRule(
serverId: ServerId(metadata.serverId ?? client.serverId),
ratingKey: metadata.id,
targetType: metadata.kind.id.isNotEmpty ? metadata.kind.id : ContentTypes.show,
episodeCount: syncCount,
mediaIndex: versionConfig.mediaIndex,
includeSpecials: includeSpecials,
targetMetadata: metadata,
);
}
// Remember the toggle for next time (only shown, and thus meaningful, for shows).
if (kind == MediaKind.show) {
await settings?.write(SettingsService.downloadIncludeSpecials, includeSpecials);
}
final count = await downloadProvider.queueDownload(
metadata,
client,
versionConfig: versionConfig,
filter: filter,
maxCount: maxCount,
includeSpecials: includeSpecials,
);
return DownloadResult(
count: count,
syncRuleCreated: keepSynced && !syncRuleUpdated,
syncRuleUpdated: syncRuleUpdated,
isMusic: kind.isMusic,
);
}
/// Shows download options dialog for a collection or playlist, then queues
/// the download. Offers both one-time download and "Keep Synced" (creates or
/// updates a sync rule for the target).
///
/// [rootMetadata] is the collection or playlist itself — used to persist the
/// title/thumb for the sync rule and build the rule's global key.
/// [targetType] must be [ContentTypes.collection] or [ContentTypes.playlist].
Future<DownloadResult?> showListDownloadOptionsAndQueue(
BuildContext context, {
required MediaItem rootMetadata,
required String targetType,
required List<MediaItem> items,
required MediaServerClient client,
required DownloadProvider downloadProvider,
}) async {
assert(targetType == ContentTypes.collection || targetType == ContentTypes.playlist);
if (!await confirmBackgroundDownloadRestrictions(context) || !context.mounted) return null;
final selectedFilter = await showOptionPickerDialog<DownloadFilter>(
context,
title: t.downloads.downloadNow,
options: _filterOptions(DownloadFilter.all, DownloadFilter.unwatched),
);
if (selectedFilter == null || !context.mounted) return null;
final syncChoice = await _showSyncChoiceDialog(context);
if (syncChoice == null || !context.mounted) return null;
final serverId = rootMetadata.serverId ?? client.serverId;
final filterString = selectedFilter == DownloadFilter.unwatched ? SyncRuleFilter.unwatched : SyncRuleFilter.all;
bool syncRuleCreated = false;
bool syncRuleUpdated = false;
SyncRuleItem? syncRule;
if (syncChoice == _SyncChoice.keepSynced) {
final ruleKey = downloadProvider.syncRuleKeyFor(ServerId(serverId), rootMetadata.id);
if (downloadProvider.hasSyncRule(ruleKey)) {
await downloadProvider.updateSyncRuleFilter(ruleKey, filterString);
syncRuleUpdated = true;
} else {
await downloadProvider.createSyncRule(
serverId: ServerId(serverId),
ratingKey: rootMetadata.id,
targetType: targetType,
episodeCount: 0,
mediaIndex: 0,
downloadFilter: filterString,
targetMetadata: rootMetadata,
);
syncRuleCreated = true;
}
syncRule = downloadProvider.getSyncRule(ruleKey);
}
final count = await downloadProvider.queueListDownload(items, client, filter: selectedFilter, syncRule: syncRule);
return DownloadResult(
count: count,
syncRuleCreated: syncRuleCreated,
syncRuleUpdated: syncRuleUpdated,
isListRule: true,
);
}
/// The all/unwatched option rows, shared by the pickers that differ only in
/// how they spell those two values.
List<({IconData? icon, String label, T value})> _filterOptions<T>(T all, T unwatched) => [
(icon: Symbols.download_rounded, label: t.downloads.allEpisodes, value: all),
(icon: Symbols.visibility_off_rounded, label: t.downloads.unwatchedOnly, value: unwatched),
];
/// Asks whether to download once or keep the target synced.
Future<_SyncChoice?> _showSyncChoiceDialog(BuildContext context) => showOptionPickerDialog<_SyncChoice>(
context,
title: t.downloads.downloadNow,
options: [
(icon: Symbols.download_rounded, label: t.downloads.downloadOnce, value: _SyncChoice.downloadOnce),
(icon: Symbols.sync_rounded, label: t.downloads.keepSynced, value: _SyncChoice.keepSynced),
],
);
Future<int?> _showEpisodeCountDialog(
BuildContext context, {
String? title,
String? hintText,
bool allowZero = false,
}) async {
final result = await showTextInputDialog(
context,
title: title ?? t.downloads.howManyEpisodes,
labelText: '',
hintText: hintText ?? '',
confirmText: t.common.ok,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
validator: (text) => validateEpisodeCountInput(text, allowZero: allowZero),
);
if (result == null) return null;
return int.tryParse(result);
}
/// Shows a dialog to edit a sync rule's episode count. Returns true if updated.
Future<bool> editSyncRuleCount(
BuildContext context, {
required DownloadProvider downloadProvider,
required String globalKey,
required int currentCount,
String? displayTitle,
}) async {
final count = await _showEpisodeCountDialog(
context,
title: t.downloads.editEpisodeCount,
hintText: currentCount.toString(),
allowZero: true,
);
if (count == null || !context.mounted) return false;
if (count == 0) {
final removed = await confirmAndRemoveSyncRule(
context,
downloadProvider: downloadProvider,
globalKey: globalKey,
displayTitle: displayTitle ?? globalKey,
);
if (removed != null && context.mounted) {
showSuccessSnackBar(context, syncRuleRemovalMessage(removed));
}
return false;
}
await downloadProvider.updateSyncRuleCount(globalKey, count);
return true;
}
/// Shows a dialog to edit a collection/playlist sync rule's filter. Returns
/// true if the filter changed.
Future<bool> editSyncRuleFilter(
BuildContext context, {
required DownloadProvider downloadProvider,
required String globalKey,
required String currentFilter,
}) async {
final selected = await showOptionPickerDialog<String>(
context,
title: t.downloads.editSyncFilter,
options: _filterOptions(SyncRuleFilter.all, SyncRuleFilter.unwatched),
);
if (selected == null || selected == currentFilter || !context.mounted) return false;
await downloadProvider.updateSyncRuleFilter(globalKey, selected);
return true;
}
/// Shows a confirmation dialog to remove a sync rule.
Future<SyncRuleRemovalResult?> confirmAndRemoveSyncRule(
BuildContext context, {
required DownloadProvider downloadProvider,
required String globalKey,
required String displayTitle,
}) async {
final rule = downloadProvider.getSyncRule(globalKey);
if (rule == null) return null;
final bool deleteDownloads;
if (rule.isListRule) {
final choice = await _showListSyncRuleRemovalDialog(context, displayTitle);
if (choice == null || !context.mounted) return null;
deleteDownloads = choice;
} else {
final confirmed = await showConfirmDialog(
context,
title: t.downloads.removeSyncRule,
message: t.downloads.removeSyncRuleConfirm(title: displayTitle),
confirmText: t.downloads.removeSyncRule,
);
if (!confirmed || !context.mounted) return null;
deleteDownloads = false;
}
try {
if (deleteDownloads) {
final serverManager = context.read<MultiServerProvider>().serverManager;
await downloadProvider.deleteSyncRuleAndDownloads(globalKey, serverManager);
return SyncRuleRemovalResult.ruleAndDownloads;
}
await downloadProvider.deleteSyncRule(globalKey);
return SyncRuleRemovalResult.ruleOnly;
} on SyncRuleCleanupBusyException {
if (context.mounted) showErrorSnackBar(context, t.downloads.syncRuleCleanupBusy);
return null;
} on SyncRuleCleanupUnavailableException {
if (context.mounted) showErrorSnackBar(context, t.downloads.syncRuleCleanupUnavailable);
return null;
} catch (error, stackTrace) {
appLogger.e('Failed to remove sync rule', error: error, stackTrace: stackTrace);
if (context.mounted) {
showErrorSnackBar(context, t.messages.errorLoading(error: error.toString()));
}
return null;
}
}
Future<bool?> _showListSyncRuleRemovalDialog(BuildContext context, String displayTitle) {
var deleteDownloads = false;
return showScopedDialog<bool>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
final colorScheme = Theme.of(context).colorScheme;
return AlertDialog(
title: Text(t.downloads.removeSyncRule),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(t.downloads.removeListSyncRuleConfirm(title: displayTitle)),
const SizedBox(height: 12),
FocusableSwitchListTile(
key: const ValueKey('delete_sync_rule_downloads'),
value: deleteDownloads,
onChanged: (value) => setState(() => deleteDownloads = value),
title: Text(t.downloads.deleteSyncRuleDownloads),
subtitle: Text(t.downloads.deleteSyncRuleDownloadsDescription),
contentPadding: EdgeInsets.zero,
),
],
),
actions: [
DialogActionButton(
autofocus: true,
onPressed: () => Navigator.pop(dialogContext),
label: t.common.cancel,
),
DialogActionButton(
onPressed: () => Navigator.pop(dialogContext, deleteDownloads),
label: t.downloads.removeSyncRule,
isPrimary: true,
style: deleteDownloads
? FilledButton.styleFrom(backgroundColor: colorScheme.error, foregroundColor: colorScheme.onError)
: null,
),
],
);
},
);
},
);
}
/// Whether this rule targets a collection or playlist (as opposed to a
/// show/season). Shared by detail screens, the sync rules screen, and the
/// context menu to dispatch between count vs. filter editing.
extension SyncRuleItemDispatch on SyncRuleItem {
bool get isListRule => targetType == ContentTypes.collection || targetType == ContentTypes.playlist;
}
/// Open the right sync-rule edit dialog for [globalKey] and show a success
/// snackbar when anything changed. Used by both detail screens and the
/// context menu so they don't each reimplement the get-rule / edit / snack
/// dance.
Future<void> manageSyncRule(
BuildContext context, {
required DownloadProvider downloadProvider,
required String globalKey,
String? displayTitle,
}) async {
final rule = downloadProvider.getSyncRule(globalKey);
if (rule == null) return;
final bool updated;
if (rule.isListRule) {
updated = await editSyncRuleFilter(
context,
downloadProvider: downloadProvider,
globalKey: globalKey,
currentFilter: rule.downloadFilter,
);
} else {
updated = await editSyncRuleCount(
context,
downloadProvider: downloadProvider,
globalKey: globalKey,
currentCount: rule.episodeCount,
displayTitle: displayTitle ?? rule.ratingKey,
);
}
if (updated && context.mounted) {
showSuccessSnackBar(context, t.downloads.syncRuleUpdated);
}
}
/// Confirm + remove a sync rule and show a success snackbar.
Future<void> removeSyncRuleAndSnack(
BuildContext context, {
required DownloadProvider downloadProvider,
required String globalKey,
required String displayTitle,
}) async {
final removed = await confirmAndRemoveSyncRule(
context,
downloadProvider: downloadProvider,
globalKey: globalKey,
displayTitle: displayTitle,
);
if (removed != null && context.mounted) {
showSuccessSnackBar(context, syncRuleRemovalMessage(removed));
}
}
/// The download / manage-sync-rule app-bar pair shared by the collection and
/// playlist detail screens: one entry that downloads (or edits the existing
/// rule) and, when a rule exists, one that removes it. Both are hidden on
/// Apple TV, which has no downloads UI.
///
/// [hasRule] stays caller-computed so each screen keeps its own
/// `context.select` short-circuit, and [showDownload] carries the screen's
/// own visibility predicate for the first entry.
List<FocusableAction> buildSyncRuleActions(
BuildContext context, {
required String ruleKey,
required String displayTitle,
required bool hasRule,
required bool showDownload,
required VoidCallback onDownload,
}) {
if (PlatformDetector.isAppleTV()) return const [];
return [
if (showDownload)
FocusableAction(
icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded,
tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow,
onPressed: hasRule
? () => manageSyncRule(context, downloadProvider: context.read<DownloadProvider>(), globalKey: ruleKey)
: onDownload,
iconColor: hasRule ? Colors.teal : null,
),
if (hasRule)
FocusableAction(
icon: Symbols.sync_disabled_rounded,
tooltip: t.downloads.removeSyncRule,
onPressed: () => removeSyncRuleAndSnack(
context,
downloadProvider: context.read<DownloadProvider>(),
globalKey: ruleKey,
displayTitle: displayTitle,
),
),
];
}