Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.
Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.
Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
`/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.
Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
from list rows unless named in `Fields`, which would otherwise strip the year
and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
`EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
requested. Without it every recency-ordered surface silently degrades to
library-add time, and `JellyfinApiCache.applyWatchState` stamps
`DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
the cached play time of everything it walked.
Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
returns nothing under every parameter combination tried. The shelf is
therefore reconstructed from a played-episode recency scan plus one
`/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
that covers the scan as well — per-request timeouts cannot bound the pass
because `MediaServerHttpClient` times the connect and receive phases
independently. Rows are stamped with their series' newest play from the same
response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
makes Continue Watching removal a real capability.
Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
`PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
and intro/credit markers fall back to chapter names.
Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
3621 lines
142 KiB
Dart
3621 lines
142 KiB
Dart
// ignore_for_file: prefer_initializing_formals
|
|
|
|
import 'dart:async';
|
|
import '../media/ids.dart';
|
|
import 'dart:io';
|
|
import 'package:background_downloader/background_downloader.dart';
|
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
|
import 'package:path/path.dart' as path;
|
|
import 'package:plezy/utils/media_server_http_client.dart';
|
|
import '../exceptions/media_server_exceptions.dart';
|
|
import '../database/app_database.dart';
|
|
import '../database/download_operations.dart';
|
|
import '../media/download_resolution.dart';
|
|
import '../media/media_backend.dart';
|
|
import '../media/media_item.dart';
|
|
import '../media/media_item_merge.dart';
|
|
import '../media/media_item_types.dart';
|
|
import '../media/media_kind.dart';
|
|
import '../media/media_server_client.dart';
|
|
import 'api_cache.dart';
|
|
import 'download_artwork_helpers.dart';
|
|
import 'download_artwork_service.dart';
|
|
import 'jellyfin_cache_resolver.dart';
|
|
import 'plex_api_cache.dart';
|
|
import 'settings_service.dart';
|
|
import 'saf_storage_service.dart';
|
|
import 'package:saf_util/saf_util_platform_interface.dart' show SafDocumentFile;
|
|
import '../models/download_models.dart';
|
|
import '../services/offline_mode_source.dart';
|
|
import '../services/download_storage_service.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/serial_future_queue.dart';
|
|
import '../utils/active_client_scope.dart';
|
|
import '../utils/codec_utils.dart';
|
|
import '../utils/global_key_utils.dart';
|
|
import '../utils/storage_failure.dart';
|
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
|
|
|
typedef MediaClientResolver = MediaServerClient? Function(ServerId serverId, {String? clientScopeId});
|
|
typedef _NativeTaskForId = Future<Task?> Function(String taskId);
|
|
typedef _NativeResumeTask = Future<bool> Function(DownloadTask task);
|
|
typedef _EpisodeStorageDeletion = ({String? seasonDirUri, String? showDirUri});
|
|
|
|
typedef NativeTaskPartition = ({List<Task> current, List<Task> stale});
|
|
|
|
typedef DownloadLocationSnapshot = ({String? path, String? type});
|
|
|
|
@visibleForTesting
|
|
NativeTaskPartition partitionNativeTasks(Iterable<Task> tasks, String? currentTaskId) {
|
|
final current = <Task>[];
|
|
final stale = <Task>[];
|
|
for (final task in tasks) {
|
|
if (currentTaskId != null && task.taskId == currentTaskId) {
|
|
current.add(task);
|
|
} else {
|
|
stale.add(task);
|
|
}
|
|
}
|
|
return (current: current, stale: stale);
|
|
}
|
|
|
|
const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD');
|
|
|
|
class _DownloadContext {
|
|
final MediaItem metadata;
|
|
final DownloadQueueItem queueItem;
|
|
final String filePath; // Absolute path (normal) or SAF dir URI (SAF mode)
|
|
final String extension;
|
|
final MediaServerClient client;
|
|
final int? showYear;
|
|
final bool isSafMode;
|
|
final String? safRootUri;
|
|
final List<DownloadSubtitleSpec>? subtitles;
|
|
|
|
_DownloadContext({
|
|
required this.metadata,
|
|
required this.queueItem,
|
|
required this.filePath,
|
|
required this.extension,
|
|
required this.client,
|
|
this.showYear,
|
|
this.safRootUri,
|
|
this.isSafMode = false,
|
|
this.subtitles,
|
|
});
|
|
}
|
|
|
|
class DownloadManagerService {
|
|
final AppDatabase _database;
|
|
final DownloadStorageService _storageService;
|
|
final MediaServerHttpClient _http;
|
|
final DownloadArtworkService _artworkService;
|
|
final SafStorageOperations _safStorage;
|
|
final bool? _downloadsSupportedOverride;
|
|
final Future<void> Function(MediaServerClient)? _queueProcessorOverride;
|
|
final Future<void> Function()? _nativeRecoveryOverride;
|
|
final Future<void> Function()? _fileDownloaderInitializerOverride;
|
|
|
|
final DownloadLocationSnapshot Function()? _downloadLocationReader;
|
|
final Future<void> Function(String?)? _downloadPathWriter;
|
|
final Future<void> Function(String?)? _downloadPathTypeWriter;
|
|
final Future<void> Function()? _downloadStorageRefresher;
|
|
|
|
final SerialFutureQueue _safOwnershipQueue = SerialFutureQueue();
|
|
final _progressController = StreamController<DownloadProgress>.broadcast();
|
|
Stream<DownloadProgress> get progressStream => _progressController.stream;
|
|
|
|
@visibleForTesting
|
|
void debugEmitProgress(DownloadProgress progress) {
|
|
if (!_disposed) _progressController.add(progress);
|
|
}
|
|
|
|
final _deletionProgressController = StreamController<DeletionProgress>.broadcast();
|
|
Stream<DeletionProgress> get deletionProgressStream => _deletionProgressController.stream;
|
|
|
|
final Map<String, _DownloadContext> _pendingDownloadContext = {};
|
|
|
|
Future<void>? _supplementaryRepairFuture;
|
|
|
|
// Resolve the correct MediaServerClient for a given serverId/scope
|
|
// (constructor-injected). Falls back to _fallbackClient when no serverId
|
|
// is available.
|
|
final MediaClientResolver _clientResolver;
|
|
MediaServerClient? _fallbackClient;
|
|
|
|
OfflineModeSource? _offlineSource;
|
|
|
|
bool _fileDownloaderInitialized = false;
|
|
static const _downloadGroup = 'video_downloads';
|
|
static const _maxAppRetries = 3;
|
|
static const _nativeRetries = 5;
|
|
static const _defaultAutoRetryDelay = Duration(seconds: 30);
|
|
static const _progressDebounceDelay = Duration(seconds: 2);
|
|
static const _videoExtensions = {'.mp4', '.ogv', '.mkv', '.m4v', '.avi'};
|
|
|
|
// Keys currently being paused — prevents holding queue from promoting them
|
|
final Set<String> _pausingKeys = {};
|
|
|
|
// Keys currently being cancelled — prevents queue promotion/completion races.
|
|
final Set<String> _cancellingKeys = {};
|
|
|
|
// Keys whose completion callback is in-flight — prevents orphan scan from re-queuing them
|
|
final Set<String> _completingKeys = {};
|
|
|
|
// Prevents concurrent _processQueue calls
|
|
bool _isProcessingQueue = false;
|
|
bool _isRepairingArtwork = false;
|
|
bool _disposed = false;
|
|
bool _queueBlockedByStorageFailure = false;
|
|
bool _loggedDownloadsUnsupported = false;
|
|
|
|
// Debounce timers for DB progress writes (keyed by globalKey).
|
|
// UI progress streams are still real-time; only the DB write is debounced.
|
|
final Map<String, Timer> _progressDebounceTimers = {};
|
|
|
|
// App-level auto-retry timers for downloads that exhausted native retries.
|
|
// Keyed by globalKey; each timer fires a fresh re-enqueue after a delay.
|
|
final Map<String, Timer> _autoRetryTimers = {};
|
|
final Duration _autoRetryDelay;
|
|
|
|
// Circuit breaker: consecutive instant failures in _processQueue.
|
|
// Stops the queue when all items fail with the same error (e.g. DNS).
|
|
int _consecutiveQueueFailures = 0;
|
|
static const _maxConsecutiveFailures = 3;
|
|
|
|
static bool get platformDownloadsSupported => downloadsSupportedFor(tvosBuild: _tvosBuild);
|
|
|
|
@visibleForTesting
|
|
static bool downloadsSupportedFor({required bool tvosBuild}) => !tvosBuild;
|
|
|
|
/// Cancels native work and discards resumable partial files so startup can
|
|
/// recover enough space to reopen the application database.
|
|
static Future<void> discardInterruptedNativeDownloadsAfterStorageFailure() async {
|
|
final downloader = FileDownloader();
|
|
try {
|
|
await downloader.reset(group: _downloadGroup);
|
|
} catch (error, stackTrace) {
|
|
appLogger.w('Failed to cancel native downloads during storage recovery', error: error, stackTrace: stackTrace);
|
|
}
|
|
try {
|
|
await downloader.database.deleteAllRecords(group: _downloadGroup);
|
|
} catch (error, stackTrace) {
|
|
appLogger.w('Failed to discard partial downloads during storage recovery', error: error, stackTrace: stackTrace);
|
|
}
|
|
}
|
|
|
|
static Future<bool> shouldBlockDownloadOnCellular() async {
|
|
final List<ConnectivityResult> connectivity;
|
|
try {
|
|
connectivity = await Connectivity().checkConnectivity();
|
|
} catch (e) {
|
|
// connectivity_plus can throw PlatformException on Windows — don't block
|
|
return false;
|
|
}
|
|
return shouldBlockDownloadOnCellularWith(connectivity);
|
|
}
|
|
|
|
/// Same check as [shouldBlockDownloadOnCellular] but uses a pre-read
|
|
/// connectivity result so callers that already queried connectivity don't
|
|
/// pay for a second platform round-trip.
|
|
static Future<bool> shouldBlockDownloadOnCellularWith(List<ConnectivityResult> connectivity) async {
|
|
final settings = await SettingsService.getInstance();
|
|
if (!settings.read(SettingsService.downloadOnWifiOnly)) return false;
|
|
if (connectivity.isEmpty) return false;
|
|
return connectivity.contains(ConnectivityResult.mobile) &&
|
|
!connectivity.contains(ConnectivityResult.wifi) &&
|
|
!connectivity.contains(ConnectivityResult.ethernet);
|
|
}
|
|
|
|
/// Future that completes when interrupted download recovery finishes.
|
|
/// Await this before reading download state from the DB to avoid races.
|
|
late final Future<void> recoveryFuture;
|
|
|
|
// Public parameter names are used by tests and app setup; the private fields
|
|
// cannot be initializing formals without exposing private named parameters.
|
|
DownloadManagerService({
|
|
required AppDatabase database,
|
|
required DownloadStorageService storageService,
|
|
required MediaClientResolver clientResolver,
|
|
MediaServerHttpClient? http,
|
|
@visibleForTesting SafStorageOperations? safStorage,
|
|
@visibleForTesting this._downloadsSupportedOverride,
|
|
@visibleForTesting Future<void> Function(MediaServerClient)? queueProcessorOverride,
|
|
@visibleForTesting Future<void> Function()? fileDownloaderInitializerOverride,
|
|
@visibleForTesting Future<void> Function()? nativeRecoveryOverride,
|
|
@visibleForTesting DownloadLocationSnapshot Function()? downloadLocationReader,
|
|
@visibleForTesting Future<void> Function(String?)? downloadPathWriter,
|
|
@visibleForTesting Future<void> Function(String?)? downloadPathTypeWriter,
|
|
@visibleForTesting Future<void> Function()? downloadStorageRefresher,
|
|
@visibleForTesting Duration autoRetryDelay = _defaultAutoRetryDelay,
|
|
}) : _queueProcessorOverride = queueProcessorOverride,
|
|
_autoRetryDelay = autoRetryDelay,
|
|
_nativeRecoveryOverride = nativeRecoveryOverride,
|
|
_database = database,
|
|
_fileDownloaderInitializerOverride = fileDownloaderInitializerOverride,
|
|
_storageService = storageService,
|
|
_clientResolver = clientResolver,
|
|
_http = http ?? httpClient,
|
|
_safStorage = safStorage ?? SafStorageService.instance,
|
|
_downloadLocationReader = downloadLocationReader,
|
|
_downloadPathWriter = downloadPathWriter,
|
|
_downloadPathTypeWriter = downloadPathTypeWriter,
|
|
_downloadStorageRefresher = downloadStorageRefresher,
|
|
_artworkService = DownloadArtworkService(storageService: storageService, http: http ?? httpClient);
|
|
|
|
bool get downloadsSupported => _downloadsSupportedOverride ?? platformDownloadsSupported;
|
|
|
|
bool _skipDownloadsUnsupported(String operation) {
|
|
if (downloadsSupported) return false;
|
|
if (!_loggedDownloadsUnsupported) {
|
|
_loggedDownloadsUnsupported = true;
|
|
appLogger.i('Downloads are unavailable on this platform; skipping $operation');
|
|
} else {
|
|
appLogger.d('Skipping $operation: downloads unavailable on this platform');
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// Inject the offline-mode source. When `isOffline`, queue/resume paths skip
|
|
/// network work and defer until connectivity returns.
|
|
void setOfflineSource(OfflineModeSource? source) {
|
|
_offlineSource = source;
|
|
}
|
|
|
|
bool get _isOffline => _offlineSource?.isOffline ?? false;
|
|
|
|
Future<T> _serializeSafOwnership<T>(Future<T> Function() action) => _safOwnershipQueue.run(action);
|
|
|
|
DownloadLocationSnapshot _readDownloadLocation() {
|
|
final reader = _downloadLocationReader;
|
|
if (reader != null) return reader();
|
|
final settings = SettingsService.instanceOrNull;
|
|
if (settings == null) {
|
|
return (path: _storageService.safBaseUri, type: _storageService.isUsingSaf ? 'saf' : null);
|
|
}
|
|
return (
|
|
path: settings.read(SettingsService.customDownloadPath),
|
|
type: settings.read(SettingsService.customDownloadPathType),
|
|
);
|
|
}
|
|
|
|
Future<void> _writeDownloadPath(String? value) async {
|
|
final writer = _downloadPathWriter;
|
|
if (writer != null) {
|
|
await writer(value);
|
|
return;
|
|
}
|
|
await SettingsService.instance.write(SettingsService.customDownloadPath, value);
|
|
}
|
|
|
|
Future<void> _writeDownloadPathType(String? value) async {
|
|
final writer = _downloadPathTypeWriter;
|
|
if (writer != null) {
|
|
await writer(value);
|
|
return;
|
|
}
|
|
await SettingsService.instance.write(SettingsService.customDownloadPathType, value);
|
|
}
|
|
|
|
Future<void> _refreshDownloadStorage() async {
|
|
final refresher = _downloadStorageRefresher;
|
|
if (refresher != null) {
|
|
await refresher();
|
|
return;
|
|
}
|
|
await _storageService.refreshCustomPath();
|
|
}
|
|
|
|
Future<String?> _canonicalRootForLocation(DownloadLocationSnapshot location) async {
|
|
if (location.type != 'saf' || location.path == null) return null;
|
|
return _safStorage.resolvePersistedPermissionUri(location.path!);
|
|
}
|
|
|
|
Future<void> setDownloadLocation({required String path, required String pathType}) {
|
|
return _serializeSafOwnership(() => _installDownloadLocation((path: path, type: pathType)));
|
|
}
|
|
|
|
Future<void> resetDownloadLocation() {
|
|
return _serializeSafOwnership(() => _installDownloadLocation((path: null, type: null)));
|
|
}
|
|
|
|
Future<void> _installDownloadLocation(DownloadLocationSnapshot next) async {
|
|
final previous = _readDownloadLocation();
|
|
final previousRoot = await _canonicalRootForLocation(previous);
|
|
final nextRoot = await _canonicalRootForLocation(next);
|
|
if (next.type == 'saf' && next.path != null && nextRoot == null) {
|
|
throw DownloadStorageException(
|
|
'Selected SAF root has no persisted permission',
|
|
next.path!,
|
|
StateError('Persisted SAF permission is unavailable'),
|
|
);
|
|
}
|
|
|
|
var storageRefreshStarted = false;
|
|
try {
|
|
await _writeDownloadPath(next.path);
|
|
await _writeDownloadPathType(next.type);
|
|
storageRefreshStarted = true;
|
|
await _refreshDownloadStorage();
|
|
} catch (error, stackTrace) {
|
|
Object? rollbackError;
|
|
try {
|
|
await _writeDownloadPath(previous.path);
|
|
} catch (error) {
|
|
rollbackError = error;
|
|
}
|
|
try {
|
|
await _writeDownloadPathType(previous.type);
|
|
} catch (error) {
|
|
rollbackError ??= error;
|
|
}
|
|
try {
|
|
await _refreshDownloadStorage();
|
|
} catch (error) {
|
|
rollbackError ??= error;
|
|
}
|
|
if (rollbackError != null) {
|
|
appLogger.e('Failed to restore download location after transition failure', error: rollbackError);
|
|
}
|
|
if (!storageRefreshStarted && nextRoot != null && nextRoot != previousRoot) {
|
|
await _releaseSafRootIfUnowned(nextRoot);
|
|
}
|
|
Error.throwWithStackTrace(error, stackTrace);
|
|
}
|
|
|
|
if (previousRoot != null && previousRoot != nextRoot) {
|
|
await _releaseSafRootIfUnowned(previousRoot);
|
|
}
|
|
}
|
|
|
|
Future<bool> _releaseSafRootIfUnowned(String safRootUri) async {
|
|
if (await _database.countDownloadsReferencingSafRoot(safRootUri) > 0) {
|
|
return false;
|
|
}
|
|
|
|
final selected = _readDownloadLocation();
|
|
final selectedUri = selected.type == 'saf' ? selected.path : null;
|
|
if (selectedUri != null) {
|
|
if (selectedUri == safRootUri) return false;
|
|
final selectedRoot = await _safStorage.resolvePersistedPermissionUri(selectedUri);
|
|
if (selectedRoot == null || selectedRoot == safRootUri) return false;
|
|
}
|
|
return _safStorage.releasePersistedPermission(safRootUri);
|
|
}
|
|
|
|
Future<String?> _replaceDownloadSafRootClaim(String globalKey, String? nextRoot) async {
|
|
final existing = await _database.getDownloadedMedia(globalKey);
|
|
if (existing == null) return null;
|
|
final previousRoot = existing.safRootUri;
|
|
if (previousRoot == nextRoot) return nextRoot;
|
|
await _database.updateDownloadSafRoot(globalKey, nextRoot);
|
|
if (previousRoot != null) {
|
|
await _releaseSafRootIfUnowned(previousRoot);
|
|
}
|
|
return nextRoot;
|
|
}
|
|
|
|
Future<void> _deleteDownloadRowAndRelease(String globalKey) {
|
|
return _serializeSafOwnership(() async {
|
|
final row = await _database.getDownloadedMedia(globalKey);
|
|
String? root = row?.safRootUri;
|
|
final videoUri = row?.videoFilePath;
|
|
if (root == null && videoUri != null && Uri.tryParse(videoUri)?.scheme == 'content') {
|
|
root = await _safStorage.resolvePersistedPermissionUri(videoUri);
|
|
}
|
|
final deletedRoot = await _database.deleteDownload(globalKey);
|
|
root ??= deletedRoot;
|
|
if (root != null) {
|
|
await _releaseSafRootIfUnowned(root);
|
|
}
|
|
});
|
|
}
|
|
|
|
@visibleForTesting
|
|
Future<void> debugClaimDownloadSafRoot(String globalKey, String uri) {
|
|
return _serializeSafOwnership(() async {
|
|
final root = await _safStorage.resolvePersistedPermissionUri(uri);
|
|
if (root == null) {
|
|
throw StateError('SAF root claim could not be canonicalized');
|
|
}
|
|
await _replaceDownloadSafRootClaim(globalKey, root);
|
|
});
|
|
}
|
|
|
|
@visibleForTesting
|
|
Future<void> debugClearDownloadSafRoot(String globalKey) {
|
|
return _serializeSafOwnership(() async {
|
|
await _replaceDownloadSafRootClaim(globalKey, null);
|
|
});
|
|
}
|
|
|
|
@visibleForTesting
|
|
Future<void> debugDeleteDownloadRowAndRelease(String globalKey) {
|
|
return _deleteDownloadRowAndRelease(globalKey);
|
|
}
|
|
|
|
/// Look up the correct client for [serverId].
|
|
/// Returns null if the server is offline — callers should skip/defer the work.
|
|
MediaServerClient? _getClient(ServerId? serverId, {String? clientScopeId}) {
|
|
if (serverId != null) {
|
|
return _clientResolver(serverId, clientScopeId: clientScopeId);
|
|
}
|
|
return _fallbackClient;
|
|
}
|
|
|
|
Future<MediaServerClient?> _getClientForDownloadKey(String globalKey) async {
|
|
final parsed = parseGlobalKey(globalKey);
|
|
if (parsed == null) return _getClient(null);
|
|
final record = await _database.getDownloadedMedia(globalKey);
|
|
return _getClient(parsed.serverId, clientScopeId: record?.clientScopeId);
|
|
}
|
|
|
|
String? activeClientScopeIdForServer(ServerId serverId) {
|
|
final client = _getClient(serverId);
|
|
return resolveActiveClientScopeId(serverId: serverId, cacheServerId: client?.cacheServerId);
|
|
}
|
|
|
|
/// Returns the cache namespace visible to [activeProfileId] for [serverId].
|
|
///
|
|
/// MediaBrowser backends prefer the persisted profile-to-user binding so a
|
|
/// cold launch and a profile switch cannot inherit the physical download
|
|
/// row's creator scope. A live scope is used only when no persisted binding
|
|
/// exists.
|
|
Future<String?> profileClientScopeIdForServer(ServerId serverId, String? activeProfileId) async {
|
|
if (activeProfileId == null || activeProfileId.isEmpty) return null;
|
|
final backend = await _backendForServer(serverId);
|
|
if (backend == null) return null;
|
|
if (backend.usesMediaBrowserApi) {
|
|
final persisted = await JellyfinCacheResolver(_database).findProfileScopeId(serverId, activeProfileId);
|
|
return persisted ?? activeClientScopeIdForServer(serverId);
|
|
}
|
|
return buildPlexProfileScopeId(serverId: serverId, profileId: activeProfileId);
|
|
}
|
|
|
|
/// Bulk-load pinned metadata. Profile-visible hydration reads only exact
|
|
/// owner namespaces; it never pre-merges another user's rows.
|
|
Future<Map<String, MediaItem>> getAllPinnedMetadata({bool preferActiveScope = false, String? activeProfileId}) async {
|
|
if (preferActiveScope) {
|
|
if (activeProfileId == null || activeProfileId.isEmpty) return {};
|
|
final ownerKeys = await _database.getDownloadOwnerKeysForProfile(activeProfileId);
|
|
final allowedByBackend = <MediaBackend, Set<ServerId>>{
|
|
for (final backend in MediaBackend.values) backend: <ServerId>{},
|
|
};
|
|
for (final item in await _database.getAllDownloadedMetadata()) {
|
|
if (!ownerKeys.contains(item.globalKey)) continue;
|
|
final serverId = ServerId(item.serverId);
|
|
final backend = await _backendForServer(serverId);
|
|
if (backend == null) continue;
|
|
final scopeId = await profileClientScopeIdForServer(serverId, activeProfileId);
|
|
if (scopeId != null) allowedByBackend[backend]!.add(ServerId(scopeId));
|
|
}
|
|
final results = await Future.wait(
|
|
MediaBackend.values.map(
|
|
(backend) => ApiCache.forBackend(backend).getAllPinnedMetadata(cacheServerIds: allowedByBackend[backend]),
|
|
),
|
|
);
|
|
return {for (final result in results) ...result};
|
|
}
|
|
|
|
final results = await Future.wait(
|
|
MediaBackend.values.map((backend) => ApiCache.forBackend(backend).getAllPinnedMetadata()),
|
|
);
|
|
return {for (final result in results) ...result};
|
|
}
|
|
|
|
Future<MediaItem?> lookupMetadata(
|
|
ServerId serverId,
|
|
String itemId, {
|
|
bool preferActiveScope = false,
|
|
String? activeProfileId,
|
|
}) async {
|
|
if (preferActiveScope) {
|
|
final activeScopeId = await profileClientScopeIdForServer(serverId, activeProfileId);
|
|
if (activeScopeId == null) return null;
|
|
return _lookupMetadata(serverId, itemId, clientScopeId: activeScopeId);
|
|
}
|
|
|
|
final download = await _database.getDownloadedMedia(buildGlobalKey(serverId, itemId));
|
|
for (final scopeId in _metadataScopeCandidates(serverId, downloadedClientScopeId: download?.clientScopeId)) {
|
|
final hit = await _lookupMetadata(serverId, itemId, clientScopeId: scopeId == serverId ? null : scopeId);
|
|
if (hit != null) return hit;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
List<String> _metadataScopeCandidates(ServerId serverId, {String? downloadedClientScopeId}) {
|
|
final candidates = <String>[
|
|
?downloadedClientScopeId,
|
|
?_getClient(serverId, clientScopeId: downloadedClientScopeId)?.cacheServerId,
|
|
serverId,
|
|
];
|
|
return <String>{
|
|
for (final id in candidates)
|
|
if (id.isNotEmpty) id,
|
|
}.toList(growable: false);
|
|
}
|
|
|
|
Future<MediaItem?> fetchAndPinMetadata(
|
|
ServerId serverId,
|
|
String itemId, {
|
|
bool preferActiveScope = false,
|
|
String? activeProfileId,
|
|
}) async {
|
|
final download = await _database.getDownloadedMedia(buildGlobalKey(serverId, itemId));
|
|
String? clientScopeId;
|
|
if (preferActiveScope) {
|
|
clientScopeId = await profileClientScopeIdForServer(serverId, activeProfileId);
|
|
if (clientScopeId == null) return null;
|
|
} else {
|
|
clientScopeId = download?.clientScopeId;
|
|
}
|
|
final client = _getClient(serverId, clientScopeId: clientScopeId);
|
|
if (client == null || (preferActiveScope && client.cacheServerId != clientScopeId)) return null;
|
|
try {
|
|
final metadata = await client.fetchItem(itemId);
|
|
if (metadata == null) return null;
|
|
await ApiCache.forBackend(client.backend).pinForOffline(ServerId(client.cacheServerId), itemId);
|
|
return metadata;
|
|
} catch (e) {
|
|
appLogger.d('fetchAndPinMetadata failed for $serverId:$itemId', error: e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Resolve which backend the cache row for [serverId] uses. Reads the
|
|
/// `Connections` table directly so the lookup works even when the server
|
|
/// is currently offline (the connection persists across launches).
|
|
///
|
|
/// [JellyfinCacheResolver] reconciles bare machine ids with compound
|
|
/// `${serverMachineId}/$userId` MediaBrowser connection ids without treating
|
|
/// `_` or `%` as wildcards.
|
|
Future<MediaBackend?> _backendForServer(ServerId serverId) async {
|
|
// Prefer a live client — `MediaServerClient.backend` is in memory.
|
|
final live = _getClient(serverId);
|
|
if (live != null) return live.backend;
|
|
final row = await JellyfinCacheResolver(_database).findConnection(serverId);
|
|
if (row == null) return null;
|
|
// Keep the persisted discriminator intact even though both MediaBrowser
|
|
// values dispatch to the same cache implementation.
|
|
return switch (row.kind) {
|
|
'jellyfin' => MediaBackend.jellyfin,
|
|
'emby' => MediaBackend.emby,
|
|
'plex' => MediaBackend.plex,
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
/// Backend-aware metadata lookup. Dispatches to the right `getMetadata`
|
|
/// helper so callers don't have to thread backend identity through every
|
|
/// layer.
|
|
///
|
|
/// When [_backendForServer] can't resolve the backend (no live client and
|
|
/// no `connections` row — happens when a server has been removed but old
|
|
/// download rows still reference it), fan out to every registered backend
|
|
/// cache instead of silently defaulting to Plex. Otherwise MediaBrowser
|
|
/// items would render with blank metadata after a connection is severed.
|
|
Future<MediaItem?> _lookupMetadata(ServerId serverId, String itemId, {String? clientScopeId}) async {
|
|
final backend = await _backendForServer(serverId);
|
|
final live = _getClient(serverId, clientScopeId: clientScopeId);
|
|
if (backend != null) {
|
|
return ApiCache.forBackend(
|
|
backend,
|
|
).getMetadata(ServerId(clientScopeId ?? live?.cacheServerId ?? serverId), itemId);
|
|
}
|
|
appLogger.w('Cache lookup for $serverId:$itemId — backend unresolved; trying all registered backends');
|
|
for (final candidate in MediaBackend.values) {
|
|
if (clientScopeId != null && clientScopeId.isNotEmpty) {
|
|
final scopedHit = await ApiCache.forBackend(candidate).getMetadata(ServerId(clientScopeId), itemId);
|
|
if (scopedHit != null) return scopedHit;
|
|
}
|
|
final hit = await ApiCache.forBackend(candidate).getMetadata(serverId, itemId);
|
|
if (hit != null) return hit;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Set<String> _offlineMetadataIds(DownloadedMediaItem row) => {
|
|
row.ratingKey,
|
|
?row.parentRatingKey,
|
|
?row.grandparentRatingKey,
|
|
};
|
|
|
|
/// Preserve Plex metadata while full logout temporarily leaves downloads
|
|
/// without a profile owner.
|
|
///
|
|
/// Available metadata is sanitized into the transfer cache, while the
|
|
/// durable download always moves to the neutral scope even when its leaf
|
|
/// cache row is missing. The cache and scope changes share one transaction.
|
|
Future<void> preparePlexMetadataForLogoutTransfer() async {
|
|
final rows = await getAllDownloads();
|
|
final cache = PlexApiCache.instance;
|
|
for (final row in rows) {
|
|
final publicServerId = ServerId(row.serverId);
|
|
final owners = await _database.getValidDownloadOwnersForKey(row.globalKey);
|
|
final rowScope = PlexProfileScopeId.tryParse(row.clientScopeId ?? '');
|
|
final ownerHasPlexScope = owners.any(
|
|
(owner) =>
|
|
owner.backend == MediaBackend.plex.id || PlexProfileScopeId.tryParse(owner.clientScopeId ?? '') != null,
|
|
);
|
|
if (rowScope == null && !ownerHasPlexScope && await _backendForServer(publicServerId) != MediaBackend.plex) {
|
|
continue;
|
|
}
|
|
|
|
final sourceScopes = <String, PlexProfileScopeId>{};
|
|
for (final owner in owners) {
|
|
final persistedScope = PlexProfileScopeId.tryParse(owner.clientScopeId ?? '');
|
|
if (persistedScope != null && persistedScope.publicServerId == publicServerId) {
|
|
sourceScopes[persistedScope] = persistedScope;
|
|
}
|
|
final derivedScope = buildPlexProfileScopeId(serverId: publicServerId, profileId: owner.profileId);
|
|
sourceScopes[derivedScope] = derivedScope;
|
|
}
|
|
if (rowScope != null && rowScope.publicServerId == publicServerId) {
|
|
sourceScopes[rowScope] = rowScope;
|
|
}
|
|
|
|
final transferScope = buildPlexTransferScopeId(publicServerId);
|
|
final metadataIds = _offlineMetadataIds(row);
|
|
await _database.transaction(() async {
|
|
for (final id in metadataIds) {
|
|
for (final sourceScope in sourceScopes.values) {
|
|
final copied = await cache.copyPinnedMetadata(
|
|
sourceServerId: sourceScope.cacheServerId,
|
|
destinationServerId: transferScope.cacheServerId,
|
|
ratingKey: id,
|
|
stripProfileState: true,
|
|
);
|
|
if (copied) break;
|
|
}
|
|
}
|
|
await _database.updateDownloadedMediaClientScope(row.globalKey, transferScope);
|
|
for (final id in metadataIds) {
|
|
await cache.deleteAllProfileRowsForItem(publicServerId, id);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Move ownerless full-logout metadata into the adopting profile's private
|
|
/// Plex namespace before profile-visible cache hydration runs.
|
|
Future<void> adoptTransferredPlexMetadataForProfile(String profileId, {bool Function()? isStillActive}) async {
|
|
if (profileId.isEmpty || isStillActive != null && !isStillActive()) return;
|
|
final owners = {for (final owner in await _database.getDownloadOwnersForProfile(profileId)) owner.globalKey: owner};
|
|
final rows = await getAllDownloads();
|
|
final cache = PlexApiCache.instance;
|
|
for (final row in rows) {
|
|
if (isStillActive != null && !isStillActive()) return;
|
|
final owner = owners[row.globalKey];
|
|
final transferScope =
|
|
PlexTransferScopeId.tryParse(row.clientScopeId ?? '') ??
|
|
PlexTransferScopeId.tryParse(owner?.clientScopeId ?? '');
|
|
if (owner == null || transferScope == null || transferScope.publicServerId != ServerId(row.serverId)) continue;
|
|
final destinationScope = buildPlexProfileScopeId(serverId: transferScope.publicServerId, profileId: profileId);
|
|
final metadataIds = _offlineMetadataIds(row);
|
|
await _database.transaction(() async {
|
|
for (final id in metadataIds) {
|
|
await cache.copyPinnedMetadata(
|
|
sourceServerId: transferScope.cacheServerId,
|
|
destinationServerId: destinationScope.cacheServerId,
|
|
ratingKey: id,
|
|
);
|
|
}
|
|
await _database.updateDownloadedMediaClientScope(row.globalKey, destinationScope);
|
|
await _database.updateDownloadOwnerScope(
|
|
profileId: profileId,
|
|
globalKey: row.globalKey,
|
|
backendId: MediaBackend.plex.id,
|
|
clientScopeId: destinationScope,
|
|
);
|
|
for (final id in metadataIds) {
|
|
await cache.deleteForItem(transferScope.cacheServerId, id);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Backend-aware "ensure cached & pin". MediaBrowser backends load playback
|
|
/// extras; Jellyfin can additionally cache native media segments. Other
|
|
/// backends only need the item metadata row. Then pin cached rows so they
|
|
/// survive general cache eviction.
|
|
Future<void> _pinMetadataForOffline(MediaServerClient client, MediaItem metadata) async {
|
|
final serverId = metadata.serverId;
|
|
if (serverId == null) {
|
|
appLogger.w('Cannot pin metadata without serverId');
|
|
return;
|
|
}
|
|
if (client.backend.usesMediaBrowserApi) {
|
|
try {
|
|
await client.fetchPlaybackExtras(metadata.id);
|
|
} catch (e) {
|
|
appLogger.w('fetchPlaybackExtras failed during offline-pin for ${metadata.globalKey}', error: e);
|
|
}
|
|
} else {
|
|
try {
|
|
await client.fetchItem(metadata.id);
|
|
} catch (e) {
|
|
appLogger.w('fetchItem failed during offline-pin for ${metadata.globalKey}', error: e);
|
|
}
|
|
}
|
|
await ApiCache.forBackend(client.backend).pinForOffline(ServerId(client.cacheServerId), metadata.id);
|
|
}
|
|
|
|
Future<void> deleteMetadataForOwner({
|
|
required String globalKey,
|
|
required ServerId serverId,
|
|
required String itemId,
|
|
required String profileId,
|
|
String? backendId,
|
|
String? clientScopeId,
|
|
}) async {
|
|
final scopeId = clientScopeId?.trim();
|
|
if (scopeId != null && scopeId.isNotEmpty && backendId != null) {
|
|
if (await _database.hasDownloadOwnerForCacheScope(globalKey, backendId: backendId, clientScopeId: scopeId)) {
|
|
return;
|
|
}
|
|
final backend = MediaBackend.fromId(backendId);
|
|
await ApiCache.forBackend(backend).deleteForItem(ServerId(scopeId), itemId);
|
|
return;
|
|
}
|
|
if (backendId == null || backendId == MediaBackend.plex.id) {
|
|
final scope = buildPlexProfileScopeId(serverId: serverId, profileId: profileId);
|
|
await PlexApiCache.instance.deleteForItem(scope.cacheServerId, itemId);
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteForItemByServer(ServerId serverId, String itemId, {String? clientScopeId}) async {
|
|
final backend = await _backendForServer(serverId);
|
|
final live = _getClient(serverId, clientScopeId: clientScopeId);
|
|
if (backend != null) {
|
|
await ApiCache.forBackend(
|
|
backend,
|
|
).deleteForItem(ServerId(clientScopeId ?? live?.cacheServerId ?? serverId), itemId);
|
|
return;
|
|
}
|
|
// Backend unresolved — purge from every registered backend so a stale
|
|
// row from either side doesn't outlive the deletion. Idempotent;
|
|
// missing rows are no-ops.
|
|
appLogger.w('Cache delete for $serverId:$itemId — backend unresolved; clearing all registered backends');
|
|
for (final candidate in MediaBackend.values) {
|
|
if (clientScopeId != null && clientScopeId.isNotEmpty) {
|
|
await ApiCache.forBackend(candidate).deleteForItem(ServerId(clientScopeId), itemId);
|
|
}
|
|
await ApiCache.forBackend(candidate).deleteForItem(serverId, itemId);
|
|
}
|
|
}
|
|
|
|
/// Initialize background_downloader with callbacks, notifications, and concurrency config.
|
|
Future<void> _initializeFileDownloader() async {
|
|
if (_fileDownloaderInitialized) return;
|
|
if (_skipDownloadsUnsupported('FileDownloader initialization')) return;
|
|
|
|
FileDownloader()
|
|
.registerCallbacks(
|
|
group: _downloadGroup,
|
|
taskStatusCallback: _onTaskStatusChanged,
|
|
taskProgressCallback: _onTaskProgress,
|
|
)
|
|
.configureNotificationForGroup(
|
|
_downloadGroup,
|
|
running: const TaskNotification('{displayName}', 'Downloading...'),
|
|
complete: const TaskNotification('{displayName}', 'Download complete'),
|
|
error: const TaskNotification('{displayName}', 'Download failed'),
|
|
paused: const TaskNotification('{displayName}', 'Download paused'),
|
|
progressBar: true,
|
|
);
|
|
|
|
// Plex servers can reject concurrent media downloads.
|
|
await FileDownloader().configure(globalConfig: (Config.holdingQueue, (1, 1, 1)));
|
|
|
|
await FileDownloader().trackTasks();
|
|
// Deliver status updates from iOS background-to-foreground transitions
|
|
await FileDownloader().resumeFromBackground();
|
|
|
|
_fileDownloaderInitialized = true;
|
|
}
|
|
|
|
/// Recover downloads that were interrupted when the app was killed.
|
|
/// Uses background_downloader's rescheduleKilledTasks for native recovery,
|
|
/// then scans drift for orphaned items.
|
|
Future<void> recoverInterruptedDownloads() async {
|
|
if (_skipDownloadsUnsupported('download recovery')) return;
|
|
|
|
try {
|
|
try {
|
|
final repaired = await _database.repairMissingQueuedDownloadEntries();
|
|
if (repaired > 0) {
|
|
appLogger.i('Repaired $repaired missing download queue item(s)');
|
|
}
|
|
} catch (e, st) {
|
|
appLogger.e('Failed to repair missing download queue items', error: e, stackTrace: st);
|
|
}
|
|
final nativeRecoveryOverride = _nativeRecoveryOverride;
|
|
if (nativeRecoveryOverride != null) {
|
|
await nativeRecoveryOverride();
|
|
return;
|
|
}
|
|
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Initializing FileDownloader', category: 'downloads')));
|
|
await _initializeFileDownloader();
|
|
|
|
final deletedTempFiles = await FileDownloader().cleanUpOrphanedTempFiles();
|
|
if (deletedTempFiles > 0) {
|
|
appLogger.i('Deleted $deletedTempFiles orphaned downloader temp file(s)');
|
|
}
|
|
|
|
// Let background_downloader re-enqueue tasks killed by the OS
|
|
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Rescheduling killed tasks', category: 'downloads')));
|
|
final (rescheduled, _) = await FileDownloader().rescheduleKilledTasks();
|
|
if (rescheduled.isNotEmpty) {
|
|
appLogger.i('Rescheduled ${rescheduled.length} killed download task(s)');
|
|
}
|
|
|
|
await _reconcileNativeDownloadTasks();
|
|
await _reconcileSafGrantOwnership();
|
|
|
|
// One-time migration: normalize stored file paths that may contain a
|
|
// doubled base-dir prefix from an earlier bug in the recovery callback.
|
|
// Re-run on v2 to also fix paths without a leading / that the v1 migration missed.
|
|
final prefs = (await SettingsService.getInstance()).prefs;
|
|
if ((prefs.getInt('download_paths_normalized_version') ?? 0) < 2) {
|
|
final allItems = await _database.select(_database.downloadedMedia).get();
|
|
var fixed = 0;
|
|
for (final item in allItems) {
|
|
if (item.videoFilePath != null) {
|
|
final vfp = item.videoFilePath!;
|
|
var normalized = await _storageService.toRelativePath(vfp);
|
|
// If toRelativePath didn't help, try extracting from downloads/ onward
|
|
// for paths that lack a leading / but contain nested base-dir fragments
|
|
if (normalized == vfp) {
|
|
final idx = vfp.indexOf('downloads/');
|
|
if (idx > 0) normalized = vfp.substring(idx);
|
|
}
|
|
appLogger.d('Path migration: videoFilePath="$vfp", normalized="$normalized"');
|
|
if (normalized != vfp) {
|
|
await _database.updateVideoFilePath(item.globalKey, normalized);
|
|
fixed++;
|
|
}
|
|
}
|
|
if (item.thumbPath != null) {
|
|
final tp = item.thumbPath!;
|
|
var normalized = await _storageService.toRelativePath(tp);
|
|
if (normalized == tp) {
|
|
final idx = tp.indexOf('downloads/');
|
|
if (idx > 0) normalized = tp.substring(idx);
|
|
}
|
|
if (normalized != tp) {
|
|
await _database.updateArtworkPaths(globalKey: item.globalKey, thumbPath: normalized);
|
|
}
|
|
}
|
|
}
|
|
if (fixed > 0) appLogger.i('Normalized $fixed corrupted download path(s)');
|
|
await prefs.setInt('download_paths_normalized_version', 2);
|
|
}
|
|
|
|
// Scan drift for orphaned items stuck in 'downloading'
|
|
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Scanning for orphaned downloads', category: 'downloads')));
|
|
final allDownloads = await _database.select(_database.downloadedMedia).get();
|
|
for (final item in allDownloads) {
|
|
if (item.status == DownloadStatus.downloading.index) {
|
|
// Skip items whose completion callback is already in-flight (race with trackTasks)
|
|
if (_completingKeys.contains(item.globalKey)) {
|
|
appLogger.d('Skipping orphan check for ${item.globalKey}: completion in progress');
|
|
continue;
|
|
}
|
|
|
|
// Video already downloaded but post-processing didn't complete.
|
|
// Keep the queue row as durable supplementary-download intent.
|
|
if (item.videoFilePath != null) {
|
|
appLogger.i('Download ${item.globalKey} has video but incomplete post-processing, completing');
|
|
await _database.updateDownloadStatus(item.globalKey, DownloadStatus.completed.index);
|
|
_emitProgress(item.globalKey, DownloadStatus.completed, 100);
|
|
continue;
|
|
}
|
|
|
|
Task? bgTask;
|
|
if (item.bgTaskId != null) {
|
|
bgTask = await FileDownloader().taskForId(item.bgTaskId!);
|
|
}
|
|
|
|
if (bgTask == null) {
|
|
// No active bg task — orphan, re-queue it
|
|
appLogger.i('Re-queuing orphaned download: ${item.globalKey}');
|
|
await _database.updateDownloadStatus(item.globalKey, DownloadStatus.queued.index);
|
|
await _database.updateBgTaskId(item.globalKey, null);
|
|
await _database.addToQueue(mediaGlobalKey: item.globalKey);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
appLogger.e('Failed to recover interrupted downloads', error: e);
|
|
}
|
|
}
|
|
|
|
Future<void> _reconcileNativeDownloadTasks() async {
|
|
if (!downloadsSupported || !_fileDownloaderInitialized) return;
|
|
|
|
final List<Task> nativeTasks;
|
|
try {
|
|
nativeTasks = await FileDownloader().allTasks(group: _downloadGroup);
|
|
} catch (e) {
|
|
appLogger.w('Failed to enumerate native download tasks during recovery', error: e);
|
|
return;
|
|
}
|
|
if (nativeTasks.isEmpty) return;
|
|
|
|
final tasksByGlobalKey = <String, List<Task>>{};
|
|
for (final task in nativeTasks) {
|
|
final globalKey = task.metaData;
|
|
if (globalKey.isEmpty) continue;
|
|
(tasksByGlobalKey[globalKey] ??= []).add(task);
|
|
}
|
|
if (tasksByGlobalKey.isEmpty) return;
|
|
|
|
final rows = await _database.select(_database.downloadedMedia).get();
|
|
final rowsByGlobalKey = {for (final row in rows) row.globalKey: row};
|
|
|
|
for (final entry in tasksByGlobalKey.entries) {
|
|
final globalKey = entry.key;
|
|
final tasks = entry.value;
|
|
final row = rowsByGlobalKey[globalKey];
|
|
if (row == null) {
|
|
await _cancelNativeTaskIds(
|
|
globalKey,
|
|
tasks.map((task) => task.taskId),
|
|
reason: 'no download row during recovery',
|
|
);
|
|
continue;
|
|
}
|
|
|
|
switch (DownloadStatus.values[row.status]) {
|
|
case DownloadStatus.downloading:
|
|
await _reconcileDownloadingNativeTasks(row, tasks);
|
|
case DownloadStatus.paused:
|
|
await _reconcilePausedNativeTasks(row, tasks);
|
|
case DownloadStatus.queued:
|
|
await _cancelNativeTaskIds(
|
|
globalKey,
|
|
tasks.map((task) => task.taskId),
|
|
reason: 'queued download during recovery',
|
|
);
|
|
await _database.addToQueue(mediaGlobalKey: globalKey);
|
|
case DownloadStatus.completed:
|
|
case DownloadStatus.failed:
|
|
case DownloadStatus.cancelled:
|
|
case DownloadStatus.partial:
|
|
await _cancelNativeTaskIds(
|
|
globalKey,
|
|
tasks.map((task) => task.taskId),
|
|
reason: 'download status ${DownloadStatus.values[row.status]} during recovery',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@visibleForTesting
|
|
Future<void> debugReconcileSafGrantOwnership({List<Task> nativeTasks = const []}) {
|
|
return _reconcileSafGrantOwnership(nativeTasks: nativeTasks);
|
|
}
|
|
|
|
Future<void> _reconcileSafGrantOwnership({List<Task>? nativeTasks}) {
|
|
return _serializeSafOwnership(() async {
|
|
List<Task> tasks;
|
|
if (nativeTasks != null) {
|
|
tasks = nativeTasks;
|
|
} else {
|
|
try {
|
|
tasks = await FileDownloader().allTasks(group: _downloadGroup);
|
|
} catch (error) {
|
|
appLogger.w('SAF grant reconciliation deferred: native task enumeration failed', error: error);
|
|
return;
|
|
}
|
|
}
|
|
|
|
final rows = await _database.select(_database.downloadedMedia).get();
|
|
final rowsByGlobalKey = {for (final row in rows) row.globalKey: row};
|
|
final activeUriTasks = <String, UriDownloadTask>{};
|
|
for (final task in tasks) {
|
|
if (task is! UriDownloadTask || task.metaData.isEmpty) continue;
|
|
final row = rowsByGlobalKey[task.metaData];
|
|
if (row?.bgTaskId == task.taskId) {
|
|
activeUriTasks[row!.globalKey] = task;
|
|
}
|
|
}
|
|
|
|
final owners = <String>{};
|
|
var resolutionFailed = false;
|
|
final selected = _readDownloadLocation();
|
|
final selectedUri = selected.type == 'saf' ? selected.path : null;
|
|
if (selectedUri != null) {
|
|
final selectedRoot = await _safStorage.resolvePersistedPermissionUri(selectedUri);
|
|
if (selectedRoot == null) {
|
|
resolutionFailed = true;
|
|
} else {
|
|
owners.add(selectedRoot);
|
|
}
|
|
}
|
|
|
|
for (final row in rows) {
|
|
final activeTask = activeUriTasks[row.globalKey];
|
|
String? candidate;
|
|
if (activeTask != null) {
|
|
candidate = activeTask.directoryUri.toString();
|
|
} else if (row.safRootUri != null) {
|
|
candidate = row.safRootUri;
|
|
} else {
|
|
final videoUri = row.videoFilePath;
|
|
if (videoUri != null && Uri.tryParse(videoUri)?.scheme == 'content') {
|
|
candidate = videoUri;
|
|
}
|
|
}
|
|
if (candidate == null) continue;
|
|
|
|
final canonicalRoot = await _safStorage.resolvePersistedPermissionUri(candidate);
|
|
if (canonicalRoot == null) {
|
|
resolutionFailed = true;
|
|
continue;
|
|
}
|
|
owners.add(canonicalRoot);
|
|
if (row.safRootUri != canonicalRoot) {
|
|
await _database.updateDownloadSafRoot(row.globalKey, canonicalRoot);
|
|
}
|
|
}
|
|
|
|
final persistedRoots = await _safStorage.getPersistedPermissionUris();
|
|
if (persistedRoots == null || resolutionFailed) return;
|
|
|
|
for (final persistedRoot in persistedRoots) {
|
|
if (!owners.contains(persistedRoot)) {
|
|
await _safStorage.releasePersistedPermission(persistedRoot);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<int> _retainUniqueCurrentNativeTask(
|
|
DownloadedMediaItem row,
|
|
List<Task> tasks, {
|
|
required String statusLabel,
|
|
}) async {
|
|
final partition = partitionNativeTasks(tasks, row.bgTaskId);
|
|
if (partition.current.length != 1) return partition.current.length;
|
|
await _cancelNativeTaskIds(
|
|
row.globalKey,
|
|
partition.stale.map((task) => task.taskId),
|
|
reason: 'duplicate $statusLabel task during recovery',
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
Future<void> _reconcileDownloadingNativeTasks(DownloadedMediaItem row, List<Task> tasks) async {
|
|
final currentMatchCount = await _retainUniqueCurrentNativeTask(row, tasks, statusLabel: 'downloading');
|
|
if (currentMatchCount == 1) return;
|
|
|
|
if (currentMatchCount > 1) {
|
|
appLogger.w('Multiple native tasks share current task id ${row.bgTaskId} for ${row.globalKey}; re-queueing');
|
|
await _cancelNativeTaskIds(
|
|
row.globalKey,
|
|
tasks.map((task) => task.taskId),
|
|
reason: 'duplicate current task id during recovery',
|
|
);
|
|
await _database.updateBgTaskId(row.globalKey, null);
|
|
await _database.updateDownloadProgress(row.globalKey, 0, 0, 0);
|
|
await _transitionStatus(row.globalKey, DownloadStatus.queued);
|
|
await _database.addToQueue(mediaGlobalKey: row.globalKey);
|
|
return;
|
|
}
|
|
|
|
if (tasks.length == 1) {
|
|
final taskId = tasks.single.taskId;
|
|
appLogger.i('Adopting recovered native task $taskId for ${row.globalKey}');
|
|
await _database.updateBgTaskId(row.globalKey, taskId);
|
|
return;
|
|
}
|
|
|
|
await _cancelNativeTaskIds(
|
|
row.globalKey,
|
|
tasks.map((task) => task.taskId),
|
|
reason: 'ambiguous downloading tasks during recovery',
|
|
);
|
|
await _database.updateBgTaskId(row.globalKey, null);
|
|
await _database.updateDownloadProgress(row.globalKey, 0, 0, 0);
|
|
await _transitionStatus(row.globalKey, DownloadStatus.queued);
|
|
await _database.addToQueue(mediaGlobalKey: row.globalKey);
|
|
}
|
|
|
|
Future<void> _reconcilePausedNativeTasks(DownloadedMediaItem row, List<Task> tasks) async {
|
|
final currentMatchCount = await _retainUniqueCurrentNativeTask(row, tasks, statusLabel: 'paused');
|
|
if (currentMatchCount == 1) return;
|
|
|
|
if (currentMatchCount > 1) {
|
|
appLogger.w(
|
|
'Multiple paused native tasks share current task id ${row.bgTaskId} for ${row.globalKey}; clearing task',
|
|
);
|
|
}
|
|
|
|
await _cancelNativeTaskIds(
|
|
row.globalKey,
|
|
tasks.map((task) => task.taskId),
|
|
reason: 'unexpected paused native tasks during recovery',
|
|
);
|
|
await _database.updateBgTaskId(row.globalKey, null);
|
|
}
|
|
|
|
Future<void> _cancelNativeTaskIds(String globalKey, Iterable<String> taskIds, {required String reason}) async {
|
|
if (!downloadsSupported) return;
|
|
final ids = taskIds.where((id) => id.isNotEmpty).toSet();
|
|
if (ids.isEmpty) return;
|
|
|
|
try {
|
|
final cancelled = await FileDownloader().cancelTasksWithIds(ids);
|
|
if (cancelled) {
|
|
appLogger.d('Cancelled ${ids.length} native task(s) for $globalKey ($reason): ${ids.join(', ')}');
|
|
}
|
|
} catch (e) {
|
|
appLogger.w('Failed to cancel native tasks for $globalKey ($reason): ${ids.join(', ')}', error: e);
|
|
}
|
|
}
|
|
|
|
/// Resume queued downloads that have no active processing.
|
|
/// Call after a [MediaServerClient] becomes available (e.g. after server connect on launch).
|
|
void resumeQueuedDownloads(MediaServerClient client) {
|
|
if (_skipDownloadsUnsupported('queued download resume')) return;
|
|
|
|
_fallbackClient = client;
|
|
|
|
if (_isOffline) {
|
|
appLogger.d('Skipping resumeQueuedDownloads — offline');
|
|
return;
|
|
}
|
|
|
|
unawaited(repairPendingSupplementaryDownloads());
|
|
unawaited(repairMissingArtworkForDownloads());
|
|
|
|
unawaited(
|
|
_database
|
|
.getNextQueueItem()
|
|
.then((item) {
|
|
if (item != null) {
|
|
appLogger.i('Resuming queued downloads after app restart');
|
|
_processQueue(client);
|
|
}
|
|
})
|
|
.catchError((e, st) {
|
|
appLogger.e('Failed to resume queued downloads', error: e, stackTrace: st);
|
|
}),
|
|
);
|
|
}
|
|
|
|
/// Best-effort repair for downloads that completed while supplementary
|
|
/// artwork was missing, corrupt, or skipped by older queue logic.
|
|
Future<void> repairMissingArtworkForDownloads() async {
|
|
if (_isRepairingArtwork || _isOffline) return;
|
|
_isRepairingArtwork = true;
|
|
try {
|
|
final rows = await _database.select(_database.downloadedMedia).get();
|
|
final ensuredParentKeys = <String>{};
|
|
|
|
for (final row in rows) {
|
|
if (row.status != DownloadStatus.completed.index) continue;
|
|
final client = await _getClientForDownloadKey(row.globalKey);
|
|
if (client == null) continue;
|
|
|
|
final metadata = await _lookupMetadata(ServerId(row.serverId), row.ratingKey, clientScopeId: row.clientScopeId);
|
|
if (metadata == null) continue;
|
|
final withServer = _repairMetadataWithServer(metadata, ServerId(row.serverId));
|
|
await _artworkService.ensureArtworkForMetadata(withServer, client);
|
|
await _backfillArtworkPath(row, withServer);
|
|
|
|
if (!withServer.isEpisode) continue;
|
|
await _repairParentArtwork(
|
|
ServerId(row.serverId),
|
|
withServer.grandparentId,
|
|
client,
|
|
ensuredParentKeys,
|
|
clientScopeId: row.clientScopeId,
|
|
);
|
|
await _repairParentArtwork(
|
|
ServerId(row.serverId),
|
|
withServer.parentId,
|
|
client,
|
|
ensuredParentKeys,
|
|
clientScopeId: row.clientScopeId,
|
|
);
|
|
}
|
|
} catch (e, st) {
|
|
appLogger.w('Missing artwork repair failed', error: e, stackTrace: st);
|
|
} finally {
|
|
_isRepairingArtwork = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _repairParentArtwork(
|
|
ServerId serverId,
|
|
String? ratingKey,
|
|
MediaServerClient client,
|
|
Set<String> ensuredKeys, {
|
|
String? clientScopeId,
|
|
}) async {
|
|
if (ratingKey == null || ratingKey.isEmpty) return;
|
|
final globalKey = buildGlobalKey(ServerId(serverId), ratingKey);
|
|
if (!ensuredKeys.add(globalKey)) return;
|
|
final cached = await _lookupMetadata(ServerId(serverId), ratingKey, clientScopeId: clientScopeId);
|
|
var metadata = cached;
|
|
if (!_isOffline) {
|
|
try {
|
|
final fetched = await client.fetchItem(ratingKey);
|
|
if (fetched != null) {
|
|
metadata = mergeFetchedMediaItem(fallbackServerId: serverId, existing: cached, fetched: fetched);
|
|
await ApiCache.forBackend(client.backend).pinForOffline(ServerId(client.cacheServerId), metadata.id);
|
|
}
|
|
} catch (e) {
|
|
appLogger.d('Artwork repair parent metadata fetch failed for $globalKey', error: e);
|
|
}
|
|
}
|
|
if (metadata == null) return;
|
|
final withServer = _repairMetadataWithServer(metadata, ServerId(serverId));
|
|
await _artworkService.ensureArtworkForMetadata(withServer, client);
|
|
}
|
|
|
|
MediaItem _repairMetadataWithServer(MediaItem metadata, ServerId serverId) {
|
|
return metadata.serverId == null ? metadata.copyWith(serverId: serverId) : metadata;
|
|
}
|
|
|
|
Future<void> _backfillArtworkPath(DownloadedMediaItem row, MediaItem metadata) async {
|
|
final thumbPath = metadata.thumbPath;
|
|
if (thumbPath == null || thumbPath.isEmpty) return;
|
|
final normalized = artworkStorageKey(thumbPath);
|
|
if (row.thumbPath == normalized) return;
|
|
|
|
await _database.updateArtworkPaths(globalKey: row.globalKey, thumbPath: normalized);
|
|
if (_disposed) return;
|
|
_progressController.add(
|
|
DownloadProgress(
|
|
globalKey: row.globalKey,
|
|
status: DownloadStatus.values[row.status],
|
|
progress: row.status == DownloadStatus.completed.index ? 100 : row.progress,
|
|
downloadedBytes: row.downloadedBytes,
|
|
totalBytes: row.totalBytes ?? 0,
|
|
thumbPath: normalized,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Repairs completed videos whose retained queue row records unsettled
|
|
/// supplementary work. Concurrent reconnects share one repair pass.
|
|
Future<void> repairPendingSupplementaryDownloads() {
|
|
if (_disposed || _isOffline) return Future.value();
|
|
final activeRepair = _supplementaryRepairFuture;
|
|
if (activeRepair != null) return activeRepair;
|
|
|
|
final repair = _repairPendingSupplementaryDownloads();
|
|
_supplementaryRepairFuture = repair;
|
|
return repair.whenComplete(() {
|
|
if (identical(_supplementaryRepairFuture, repair)) {
|
|
_supplementaryRepairFuture = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _repairPendingSupplementaryDownloads() async {
|
|
final List<DownloadQueueItem> queueItems;
|
|
try {
|
|
queueItems = await _database.getPendingSupplementaryQueueItems();
|
|
} catch (e, st) {
|
|
appLogger.w('Could not read pending supplementary downloads', error: e, stackTrace: st);
|
|
return;
|
|
}
|
|
for (final queueItem in queueItems) {
|
|
if (_disposed || _isOffline) return;
|
|
final globalKey = queueItem.mediaGlobalKey;
|
|
try {
|
|
final client = await _getClientForDownloadKey(globalKey);
|
|
if (client == null) {
|
|
appLogger.d('Deferring supplementary download $globalKey: server offline');
|
|
continue;
|
|
}
|
|
|
|
final metadata = await _resolveMetadata(globalKey);
|
|
if (metadata == null) {
|
|
appLogger.w('No metadata for deferred supplementary download: $globalKey');
|
|
continue;
|
|
}
|
|
|
|
final record = await _database.getDownloadedMedia(globalKey);
|
|
int? showYear;
|
|
if (metadata.isEpisode && metadata.grandparentId != null && metadata.serverId != null) {
|
|
showYear = await _fetchShowYear(
|
|
ServerId(metadata.serverId!),
|
|
metadata.grandparentId,
|
|
clientScopeId: record?.clientScopeId,
|
|
);
|
|
}
|
|
|
|
final settled = await _runSupplementaryDownloads(
|
|
globalKey,
|
|
metadata,
|
|
client,
|
|
isRepair: true,
|
|
downloadArtwork: queueItem.downloadArtwork,
|
|
downloadSubtitles: queueItem.downloadSubtitles,
|
|
record: record,
|
|
showYear: showYear,
|
|
);
|
|
if (settled.artwork && settled.subtitles) {
|
|
await _database.removeFromQueue(globalKey);
|
|
appLogger.i('Deferred supplementary downloads completed for $globalKey');
|
|
} else {
|
|
await _database.updateSupplementaryQueueIntent(
|
|
globalKey,
|
|
downloadSubtitles: !settled.subtitles,
|
|
downloadArtwork: !settled.artwork,
|
|
);
|
|
}
|
|
} catch (e, st) {
|
|
appLogger.w('Deferred supplementary downloads failed for $globalKey', error: e, stackTrace: st);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cancel any per-download timers (progress debounce + auto-retry) for [key].
|
|
/// Idempotent; safe to call from any terminal/pause path.
|
|
void _cancelDownloadTimers(String key) {
|
|
_progressDebounceTimers.remove(key)?.cancel();
|
|
_autoRetryTimers.remove(key)?.cancel();
|
|
}
|
|
|
|
/// Delete a file if it exists and log the deletion
|
|
/// Returns true if file was deleted, false otherwise
|
|
Future<bool> _deleteFileIfExists(File file, String description) async {
|
|
try {
|
|
if (await file.exists()) {
|
|
await file.delete();
|
|
appLogger.i('Deleted $description: ${file.path}');
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
appLogger.w('Failed to delete $description: ${file.path}', error: e);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Delete a SAF file or directory. Missing targets are a silent no-op.
|
|
Future<void> _tryDeleteSaf(String uri, {required bool isDir, required String description}) async {
|
|
final ok = await _safStorage.delete(uri, isDir: isDir);
|
|
if (ok) appLogger.i('Deleted $description: $uri');
|
|
}
|
|
|
|
/// Recursively delete a SAF directory — lists children in parallel, deletes
|
|
/// leaves, recurses into subdirectories, then removes the dir itself.
|
|
/// Manual recursion because DocumentsProvider-level recursion isn't guaranteed
|
|
/// across providers.
|
|
Future<void> _deleteSafDirRecursive(String dirUri, {required String description}) async {
|
|
final saf = _safStorage;
|
|
final children = await saf.list(dirUri);
|
|
if (children != null && children.isNotEmpty) {
|
|
await Future.wait(
|
|
children.map((child) {
|
|
return child.isDir
|
|
? _deleteSafDirRecursive(child.uri, description: description)
|
|
: saf.delete(child.uri, isDir: false);
|
|
}),
|
|
);
|
|
}
|
|
await _tryDeleteSaf(dirUri, isDir: true, description: description);
|
|
}
|
|
|
|
/// Walk a chain of SAF directory URIs (deepest-first) and delete each that is empty.
|
|
/// Stops at the first non-empty directory. Skips missing/null entries.
|
|
Future<void> _deleteEmptySafDirsInOrder(List<String?> dirUris) async {
|
|
final saf = _safStorage;
|
|
for (final uri in dirUris) {
|
|
if (uri == null) break;
|
|
if (!await saf.exists(uri, isDir: true)) continue;
|
|
final children = await saf.list(uri);
|
|
if (children == null || children.isNotEmpty) break;
|
|
if (!await saf.delete(uri, isDir: true)) break;
|
|
appLogger.i('Cleaned up empty SAF directory: $uri');
|
|
}
|
|
}
|
|
|
|
/// Find a SAF file in [dirUri] whose name (minus extension) matches [baseName].
|
|
Future<SafDocumentFile?> _findSafFileByBaseName(String dirUri, String baseName) async {
|
|
final children = await _safStorage.list(dirUri);
|
|
if (children == null) return null;
|
|
for (final child in children) {
|
|
if (!child.isDir && path.basenameWithoutExtension(child.name) == baseName) return child;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Delete the canonical target file and any pre-existing numbered duplicates
|
|
/// in [safDirUri] before re-enqueueing a SAF download. SAF DocumentsProviders
|
|
/// auto-number on createDocument when a name conflict exists, which would
|
|
/// otherwise produce "name (1).ext" / "name.ext (1)" corrupt duplicates on
|
|
/// every app-level retry.
|
|
Future<void> _cleanupSafTargetFile(String safDirUri, String safFileName) async {
|
|
final children = await _safStorage.list(safDirUri);
|
|
if (children == null) return;
|
|
|
|
// Match BOTH numbering schemes a DocumentsProvider may use:
|
|
// "S02E11 - The Hunt (1).mkv" - inserted before extension (most providers)
|
|
// "S02E11 - The Hunt.mkv (1)" - appended after full name (Downloads tree)
|
|
final base = path.basenameWithoutExtension(safFileName);
|
|
final ext = path.extension(safFileName);
|
|
final dup = RegExp(
|
|
'^${RegExp.escape(base)} \\(\\d+\\)${RegExp.escape(ext)}\$|'
|
|
'^${RegExp.escape(safFileName)} \\(\\d+\\)\$',
|
|
);
|
|
|
|
await Future.wait([
|
|
for (final child in children)
|
|
if (!child.isDir && (child.name == safFileName || dup.hasMatch(child.name)))
|
|
_tryDeleteSaf(
|
|
child.uri,
|
|
isDir: false,
|
|
description: child.name == safFileName
|
|
? 'stale SAF video before re-download'
|
|
: 'stale SAF numbered duplicate',
|
|
),
|
|
]);
|
|
}
|
|
|
|
Future<void> queueDownload({
|
|
required MediaItem metadata,
|
|
required MediaServerClient client,
|
|
int priority = 0,
|
|
bool downloadSubtitles = true,
|
|
bool downloadArtwork = true,
|
|
int mediaIndex = 0,
|
|
}) async {
|
|
if (_skipDownloadsUnsupported('queue download')) return;
|
|
_resumeQueueAfterStorageFailure('new download');
|
|
|
|
final globalKey = metadata.globalKey;
|
|
|
|
final outcome = await _database.insertQueuedDownload(
|
|
serverId: ServerId(metadata.serverId!),
|
|
clientScopeId: client.cacheServerId == metadata.serverId ? null : client.cacheServerId,
|
|
ratingKey: metadata.id,
|
|
globalKey: globalKey,
|
|
type: metadata.kind.id,
|
|
parentRatingKey: metadata.parentId,
|
|
grandparentRatingKey: metadata.grandparentId,
|
|
mediaIndex: mediaIndex,
|
|
mediaSourceId: _mediaSourceIdForIndex(metadata, mediaIndex),
|
|
priority: priority,
|
|
downloadSubtitles: downloadSubtitles,
|
|
downloadArtwork: downloadArtwork,
|
|
);
|
|
if (outcome == QueueDownloadOutcome.unchanged) {
|
|
appLogger.i('Download already active, paused, or completed for $globalKey');
|
|
return;
|
|
}
|
|
|
|
if (outcome == QueueDownloadOutcome.admitted) {
|
|
// Metadata pinning is useful for offline preparation, but the durable
|
|
// download request must remain executable if cache persistence fails.
|
|
try {
|
|
await _pinMetadataForOffline(client, metadata);
|
|
} catch (e, st) {
|
|
appLogger.w('Failed to pin metadata for queued download $globalKey', error: e, stackTrace: st);
|
|
}
|
|
}
|
|
|
|
_emitProgress(globalKey, DownloadStatus.queued, 0);
|
|
unawaited(_processQueue(client));
|
|
}
|
|
|
|
String? _mediaSourceIdForIndex(MediaItem metadata, int mediaIndex) {
|
|
final versions = metadata.mediaVersions;
|
|
if (versions == null || mediaIndex < 0 || mediaIndex >= versions.length) return null;
|
|
final id = versions[mediaIndex].id.trim();
|
|
return id.isEmpty ? null : id;
|
|
}
|
|
|
|
/// Process the download queue — prepares and enqueues items with background_downloader.
|
|
/// Non-blocking: returns after all queued items are enqueued (downloads run natively).
|
|
Future<void> _processQueue(MediaServerClient client) async {
|
|
if (_skipDownloadsUnsupported('download queue processing') || _queueBlockedByStorageFailure) return;
|
|
final queueProcessorOverride = _queueProcessorOverride;
|
|
if (queueProcessorOverride != null) {
|
|
_fallbackClient = client;
|
|
await queueProcessorOverride(client);
|
|
return;
|
|
}
|
|
if (_isProcessingQueue) return;
|
|
_isProcessingQueue = true;
|
|
_fallbackClient = client;
|
|
|
|
try {
|
|
await (_fileDownloaderInitializerOverride?.call() ?? _initializeFileDownloader());
|
|
|
|
while (!_queueBlockedByStorageFailure) {
|
|
if (_consecutiveQueueFailures >= _maxConsecutiveFailures) {
|
|
appLogger.w('Circuit breaker: $_consecutiveQueueFailures consecutive failures, pausing queue');
|
|
break;
|
|
}
|
|
|
|
final nextItem = await _database.getNextQueueItem();
|
|
if (nextItem == null) break;
|
|
|
|
// Resolve the correct client for the item's server/scope — skip if unavailable.
|
|
final itemClient = await _getClientForDownloadKey(nextItem.mediaGlobalKey);
|
|
if (itemClient == null) {
|
|
appLogger.d('Skipping queued download ${nextItem.mediaGlobalKey}: server offline');
|
|
break;
|
|
}
|
|
final enqueued = await _prepareAndEnqueueDownload(nextItem.mediaGlobalKey, itemClient, nextItem);
|
|
if (enqueued) {
|
|
_consecutiveQueueFailures = 0;
|
|
} else {
|
|
_consecutiveQueueFailures++;
|
|
}
|
|
}
|
|
} finally {
|
|
_isProcessingQueue = false;
|
|
}
|
|
}
|
|
|
|
/// Cancel any lingering background task and reset progress before re-enqueuing.
|
|
Future<void> _cleanupStaleDownload(String globalKey) async {
|
|
final existingTaskId = await _database.getBgTaskId(globalKey);
|
|
await _database.updateBgTaskId(globalKey, null);
|
|
_pendingDownloadContext.remove(globalKey);
|
|
await _cancelNativeTasksForGlobalKey(
|
|
globalKey,
|
|
includeTaskId: existingTaskId,
|
|
reason: 'stale task before re-download',
|
|
);
|
|
await _database.updateDownloadProgress(globalKey, 0, 0, 0);
|
|
}
|
|
|
|
Future<void> _requeueDownload(String globalKey, {MediaServerClient? fallbackClient}) async {
|
|
await _transitionStatus(globalKey, DownloadStatus.queued);
|
|
await _database.addToQueue(mediaGlobalKey: globalKey);
|
|
final client = await _getClientForDownloadKey(globalKey) ?? fallbackClient;
|
|
if (client != null) unawaited(_processQueue(client));
|
|
}
|
|
|
|
Future<void> _cancelNativeTask(String globalKey, String taskId, {required String reason}) =>
|
|
_cancelNativeTaskIds(globalKey, [taskId], reason: reason);
|
|
|
|
Future<void> _cancelNativeTasksForGlobalKey(
|
|
String globalKey, {
|
|
String? includeTaskId,
|
|
String? exceptTaskId,
|
|
required String reason,
|
|
}) async {
|
|
if (!downloadsSupported) return;
|
|
final taskIds = <String>{};
|
|
if (includeTaskId != null && includeTaskId != exceptTaskId) taskIds.add(includeTaskId);
|
|
|
|
if (!_fileDownloaderInitialized && taskIds.isEmpty) return;
|
|
|
|
try {
|
|
final nativeTasks = await FileDownloader().allTasks(group: _downloadGroup);
|
|
for (final task in nativeTasks) {
|
|
if (task.metaData == globalKey && task.taskId != exceptTaskId) taskIds.add(task.taskId);
|
|
}
|
|
} catch (e) {
|
|
appLogger.w('Failed to enumerate native tasks for $globalKey ($reason)', error: e);
|
|
}
|
|
|
|
await _cancelNativeTaskIds(globalKey, taskIds, reason: reason);
|
|
}
|
|
|
|
Future<DownloadedMediaItem?> _downloadForCurrentTaskSession(
|
|
String globalKey,
|
|
String taskId, {
|
|
required String event,
|
|
bool cancelStale = false,
|
|
DownloadStatus? requiredStatus,
|
|
}) async {
|
|
final existing = await _database.getDownloadedMedia(globalKey);
|
|
final currentTaskId = existing?.bgTaskId;
|
|
final statusMatches = requiredStatus == null || existing?.status == requiredStatus.index;
|
|
if (existing != null && currentTaskId == taskId && statusMatches) return existing;
|
|
|
|
appLogger.d(
|
|
'Ignoring stale download $event for $globalKey from task $taskId '
|
|
'(current task: ${currentTaskId ?? 'none'}, status: ${existing?.status ?? 'none'})',
|
|
);
|
|
if (cancelStale) {
|
|
await _cancelNativeTask(globalKey, taskId, reason: 'stale $event');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
bool _isNativeTaskActiveStatus(TaskStatus status) {
|
|
return status == TaskStatus.enqueued || status == TaskStatus.running || status == TaskStatus.waitingToRetry;
|
|
}
|
|
|
|
Future<bool> _isInactiveForEnqueue(String globalKey) async {
|
|
if (_cancellingKeys.contains(globalKey)) return true;
|
|
final existing = await _database.getDownloadedMedia(globalKey);
|
|
return existing == null ||
|
|
existing.status == DownloadStatus.completed.index ||
|
|
existing.status == DownloadStatus.cancelled.index;
|
|
}
|
|
|
|
Future<bool> _isCancelledOrDeleted(String globalKey) async {
|
|
if (_cancellingKeys.contains(globalKey)) return true;
|
|
final existing = await _database.getDownloadedMedia(globalKey);
|
|
return existing == null || existing.status == DownloadStatus.cancelled.index;
|
|
}
|
|
|
|
Future<bool> _cancelEnqueuedTaskIfInactive(String globalKey, String taskId) async {
|
|
if (!await _isCancelledOrDeleted(globalKey)) return false;
|
|
if (downloadsSupported) {
|
|
await FileDownloader().cancelTaskWithId(taskId);
|
|
}
|
|
await _database.updateBgTaskId(globalKey, null);
|
|
await _database.removeFromQueue(globalKey);
|
|
_pendingDownloadContext.remove(globalKey);
|
|
return true;
|
|
}
|
|
|
|
/// Hand a prepared task to the native downloader, recording its id first so a
|
|
/// concurrent cancel can find it. Returns true if the download went inactive
|
|
/// while enqueueing and the task was dropped again.
|
|
Future<bool> _enqueuePreparedTask(String globalKey, Task task, String kind) async {
|
|
await _database.updateBgTaskId(globalKey, task.taskId);
|
|
final success = await FileDownloader().enqueue(task);
|
|
if (!success) throw Exception('Failed to enqueue $kind task');
|
|
if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) {
|
|
return true;
|
|
}
|
|
appLogger.i('Enqueued $kind task ${task.taskId} for $globalKey');
|
|
return false;
|
|
}
|
|
|
|
/// Resolve metadata, video URL, and file path, then enqueue a background download task.
|
|
/// Returns true if successfully enqueued, false if it failed immediately.
|
|
Future<bool> _prepareAndEnqueueDownload(
|
|
String globalKey,
|
|
MediaServerClient client,
|
|
DownloadQueueItem queueItem,
|
|
) async {
|
|
if (_skipDownloadsUnsupported('download enqueue')) return false;
|
|
if (_cancellingKeys.contains(globalKey)) return true;
|
|
if (_queueBlockedByStorageFailure) return true;
|
|
|
|
try {
|
|
// Guard: don't re-enqueue an item that's already completed or was deleted
|
|
final existing = await _database.getDownloadedMedia(globalKey);
|
|
if (_cancellingKeys.contains(globalKey) ||
|
|
existing == null ||
|
|
existing.status == DownloadStatus.completed.index ||
|
|
existing.status == DownloadStatus.cancelled.index) {
|
|
appLogger.d('Skipping enqueue for $globalKey: already completed or deleted');
|
|
await _database.removeFromQueue(globalKey);
|
|
return true;
|
|
}
|
|
|
|
appLogger.i('Preparing download for $globalKey');
|
|
await _cleanupStaleDownload(globalKey);
|
|
if (await _isInactiveForEnqueue(globalKey)) {
|
|
appLogger.d('Skipping enqueue for $globalKey: inactive before transition');
|
|
await _database.removeFromQueue(globalKey);
|
|
return true;
|
|
}
|
|
if (_queueBlockedByStorageFailure) return true;
|
|
await _transitionStatus(globalKey, DownloadStatus.downloading);
|
|
|
|
final parsed = parseGlobalKey(globalKey);
|
|
if (parsed == null) throw Exception('Invalid globalKey: $globalKey');
|
|
final serverId = parsed.serverId;
|
|
final ratingKey = parsed.ratingKey;
|
|
|
|
MediaItem? metadata = await _lookupMetadata(serverId, ratingKey, clientScopeId: existing.clientScopeId);
|
|
if (metadata == null) {
|
|
// Cache miss — try re-fetching from server (cache may have been cleared between queue and prepare)
|
|
appLogger.w('Cache miss for $globalKey, attempting network re-fetch');
|
|
try {
|
|
final fetched = await client.fetchItem(ratingKey);
|
|
if (fetched != null) metadata = fetched.copyWith(serverId: serverId);
|
|
} catch (e) {
|
|
appLogger.w('Network re-fetch failed for $globalKey', error: e);
|
|
}
|
|
if (metadata == null) {
|
|
throw Exception('Metadata not found in cache and could not be fetched for $globalKey');
|
|
}
|
|
}
|
|
|
|
final selectedMediaIndex = existing.mediaIndex;
|
|
var resolution = await client.resolveDownload(
|
|
metadata,
|
|
mediaIndex: selectedMediaIndex,
|
|
mediaSourceId: existing.mediaSourceId,
|
|
);
|
|
if (resolution.videoUrl == null) {
|
|
// Cache miss for the per-version fields — refresh from network.
|
|
appLogger.w('No video URL from cache for $globalKey, retrying via network');
|
|
final fetched = await client.fetchItem(ratingKey);
|
|
if (fetched != null) metadata = fetched.copyWith(serverId: serverId);
|
|
resolution = await client.resolveDownload(
|
|
metadata,
|
|
mediaIndex: selectedMediaIndex,
|
|
mediaSourceId: existing.mediaSourceId,
|
|
);
|
|
if (resolution.videoUrl == null) throw Exception('Could not get video URL for $globalKey');
|
|
}
|
|
if (resolution.mediaSourceId != null && resolution.mediaSourceId != existing.mediaSourceId) {
|
|
await _database.updateDownloadMediaSource(globalKey, resolution.mediaSourceId);
|
|
}
|
|
|
|
if (await _isCancelledOrDeleted(globalKey)) {
|
|
appLogger.d('Skipping enqueue for $globalKey: cancelled during preparation');
|
|
await _database.removeFromQueue(globalKey);
|
|
_pendingDownloadContext.remove(globalKey);
|
|
return true;
|
|
}
|
|
|
|
final ext = downloadExtensionFromUrl(resolution.videoUrl!) ?? 'mp4';
|
|
|
|
// Look up show year for episodes
|
|
final showYear = metadata.isEpisode
|
|
? await _fetchShowYear(serverId, metadata.grandparentId, clientScopeId: existing.clientScopeId)
|
|
: null;
|
|
|
|
// Build display name for notifications. Episodes lead with the show,
|
|
// tracks with the artist — same "container - leaf" pattern.
|
|
final trackArtist = metadata.trackArtistTitle;
|
|
final displayName = metadata.isEpisode
|
|
? '${metadata.grandparentTitle ?? metadata.displayTitle} - ${metadata.displayTitle}'
|
|
: metadata.kind == MediaKind.track && trackArtist != null && trackArtist.isNotEmpty
|
|
? '$trackArtist - ${metadata.displayTitle}'
|
|
: metadata.displayTitle;
|
|
|
|
// Get WiFi-only setting for native enforcement
|
|
final settings = await SettingsService.getInstance();
|
|
final requiresWiFi = settings.read(SettingsService.downloadOnWifiOnly);
|
|
final MediaItem resolvedMetadata = metadata;
|
|
|
|
final becameInactive = await _serializeSafOwnership(() async {
|
|
if (_queueBlockedByStorageFailure) return true;
|
|
final metadata = resolvedMetadata;
|
|
final safBaseUri = _storageService.safBaseUri;
|
|
final DownloadTask task;
|
|
final String filePath;
|
|
final String? safRootUri;
|
|
if (_storageService.isUsingSaf && safBaseUri != null) {
|
|
final rootUri = await _safStorage.resolvePersistedPermissionUri(safBaseUri);
|
|
if (rootUri == null) {
|
|
throw StateError('Selected SAF root has no persisted permission');
|
|
}
|
|
await _replaceDownloadSafRootClaim(globalKey, rootUri);
|
|
|
|
// SAF mode: use UriDownloadTask (writes directly to content:// URI,
|
|
// with no pause/resume support).
|
|
final target = _storageService.safTarget(metadata, ext, showYear: showYear, serverId: serverId);
|
|
|
|
final safDirUri = await _safStorage.createNestedDirectories(rootUri, target.components);
|
|
if (safDirUri == null) {
|
|
throw Exception('Failed to create SAF directory');
|
|
}
|
|
|
|
await _cleanupSafTargetFile(safDirUri, target.fileName);
|
|
|
|
task = UriDownloadTask(
|
|
url: resolution.videoUrl!,
|
|
filename: target.fileName,
|
|
directoryUri: Uri.parse(safDirUri),
|
|
group: _downloadGroup,
|
|
updates: Updates.statusAndProgress,
|
|
requiresWiFi: requiresWiFi,
|
|
retries: _nativeRetries,
|
|
allowPause: false,
|
|
metaData: globalKey,
|
|
displayName: displayName,
|
|
);
|
|
filePath = safDirUri;
|
|
safRootUri = rootUri;
|
|
} else {
|
|
await _replaceDownloadSafRootClaim(globalKey, null);
|
|
|
|
// Normal mode: use DownloadTask with pause/resume support.
|
|
final String downloadFilePath;
|
|
if (metadata.isMovie) {
|
|
downloadFilePath = await _storageService.getMovieVideoPath(metadata, ext);
|
|
} else if (metadata.isEpisode) {
|
|
downloadFilePath = await _storageService.getEpisodeVideoPath(metadata, ext, showYear: showYear);
|
|
} else {
|
|
downloadFilePath = await _storageService.getVideoFilePath(serverId, metadata.id, ext);
|
|
}
|
|
|
|
// Clean up partial files from previous attempts to prevent
|
|
// background_downloader from creating numbered copies (File (1).mp4).
|
|
await Future.wait([
|
|
_deleteFileIfExists(File(downloadFilePath), 'stale video before re-download'),
|
|
_deleteFileIfExists(File('$downloadFilePath.part'), 'stale .part before re-download'),
|
|
]);
|
|
|
|
await File(downloadFilePath).parent.create(recursive: true);
|
|
|
|
task = DownloadTask(
|
|
url: resolution.videoUrl!,
|
|
filename: path.basename(downloadFilePath),
|
|
directory: path.dirname(downloadFilePath),
|
|
baseDirectory: BaseDirectory.root,
|
|
group: _downloadGroup,
|
|
updates: Updates.statusAndProgress,
|
|
requiresWiFi: requiresWiFi,
|
|
retries: _nativeRetries,
|
|
allowPause: true,
|
|
metaData: globalKey,
|
|
displayName: displayName,
|
|
);
|
|
filePath = downloadFilePath;
|
|
safRootUri = null;
|
|
}
|
|
|
|
_pendingDownloadContext[globalKey] = _DownloadContext(
|
|
metadata: metadata,
|
|
queueItem: queueItem,
|
|
filePath: filePath,
|
|
extension: ext,
|
|
client: client,
|
|
showYear: showYear,
|
|
isSafMode: safRootUri != null,
|
|
safRootUri: safRootUri,
|
|
subtitles: resolution.externalSubtitlesResolved ? resolution.externalSubtitles : null,
|
|
);
|
|
|
|
return _enqueuePreparedTask(globalKey, task, safRootUri != null ? 'SAF download' : 'download');
|
|
});
|
|
if (becameInactive) return true;
|
|
return true;
|
|
} catch (e, st) {
|
|
if (await _isCancelledOrDeleted(globalKey)) {
|
|
appLogger.d('Ignoring enqueue failure for inactive download $globalKey', error: e);
|
|
await _database.removeFromQueue(globalKey);
|
|
_pendingDownloadContext.remove(globalKey);
|
|
return true;
|
|
}
|
|
appLogger.e('Failed to prepare download for $globalKey', error: e, stackTrace: st);
|
|
final existing = await _database.getDownloadedMedia(globalKey);
|
|
if (_isRetryablePrepareFailure(e) &&
|
|
existing != null &&
|
|
existing.retryCount < _maxAppRetries &&
|
|
existing.status != DownloadStatus.completed.index &&
|
|
existing.status != DownloadStatus.cancelled.index) {
|
|
await _scheduleDownloadRetry(
|
|
globalKey,
|
|
client,
|
|
existing.retryCount,
|
|
e.toString(),
|
|
processQueueAfterProgress: false,
|
|
);
|
|
} else {
|
|
await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: e.toString());
|
|
await _database.removeFromQueue(globalKey);
|
|
}
|
|
_pendingDownloadContext.remove(globalKey);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Callback: background_downloader progress update
|
|
void _onTaskProgress(TaskProgressUpdate update) {
|
|
if (_disposed) return;
|
|
unawaited(
|
|
_handleTaskProgress(update).catchError((Object e, StackTrace st) {
|
|
appLogger.e('Error handling download progress for ${update.task.metaData}', error: e, stackTrace: st);
|
|
}),
|
|
);
|
|
}
|
|
|
|
@visibleForTesting
|
|
Future<void> debugHandleTaskProgress(TaskProgressUpdate update) => _handleTaskProgress(update);
|
|
|
|
Future<void> _handleTaskProgress(TaskProgressUpdate update) async {
|
|
if (_disposed) return;
|
|
final globalKey = update.task.metaData;
|
|
if (globalKey.isEmpty || update.progress < 0) return;
|
|
|
|
final existing = await _downloadForCurrentTaskSession(
|
|
globalKey,
|
|
update.task.taskId,
|
|
event: 'progress',
|
|
cancelStale: true,
|
|
);
|
|
if (existing == null) return;
|
|
if (existing.status != DownloadStatus.downloading.index) {
|
|
appLogger.d('Ignoring progress for inactive download $globalKey from task ${update.task.taskId}');
|
|
await _cancelNativeTask(globalKey, update.task.taskId, reason: 'progress for inactive download');
|
|
return;
|
|
}
|
|
|
|
// If this item is being paused, the holding queue promoted it — cancel it
|
|
if (_pausingKeys.contains(globalKey)) {
|
|
await _cancelNativeTask(globalKey, update.task.taskId, reason: 'pause in progress');
|
|
return;
|
|
}
|
|
if (_cancellingKeys.contains(globalKey)) {
|
|
await _cancelNativeTask(globalKey, update.task.taskId, reason: 'cancellation in progress');
|
|
return;
|
|
}
|
|
|
|
final progress = (update.progress * 100).round().clamp(0, 100);
|
|
final speedBytesPerSec = update.hasNetworkSpeed ? update.networkSpeed * 1024 * 1024 : 0.0;
|
|
final totalBytes = update.hasExpectedFileSize ? update.expectedFileSize : 0;
|
|
final downloadedBytes = totalBytes > 0 ? (update.progress * totalBytes).round() : 0;
|
|
|
|
_progressController.add(
|
|
DownloadProgress(
|
|
globalKey: globalKey,
|
|
status: DownloadStatus.downloading,
|
|
progress: progress,
|
|
downloadedBytes: downloadedBytes,
|
|
totalBytes: totalBytes,
|
|
speed: speedBytesPerSec,
|
|
currentFile: 'video',
|
|
),
|
|
);
|
|
|
|
// Debounce DB writes — only the latest progress value is persisted after
|
|
// a 2-second settle period. The stream above provides real-time UI updates;
|
|
// the DB write is only for crash-recovery state.
|
|
_progressDebounceTimers[globalKey]?.cancel();
|
|
_progressDebounceTimers[globalKey] = Timer(_progressDebounceDelay, () {
|
|
_progressDebounceTimers.remove(globalKey);
|
|
_database.updateDownloadProgress(globalKey, progress, downloadedBytes, totalBytes).catchError((e) {
|
|
appLogger.w('Failed to update download progress in DB', error: e);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Callback: background_downloader status change
|
|
void _onTaskStatusChanged(TaskStatusUpdate update) {
|
|
if (_disposed) return;
|
|
unawaited(
|
|
_handleTaskStatusChanged(update).catchError((Object e, StackTrace st) {
|
|
appLogger.e('Error handling download status for ${update.task.metaData}', error: e, stackTrace: st);
|
|
}),
|
|
);
|
|
}
|
|
|
|
@visibleForTesting
|
|
Future<void> debugHandleTaskStatus(TaskStatusUpdate update) => _handleTaskStatusChanged(update);
|
|
|
|
Future<void> _handleTaskStatusChanged(TaskStatusUpdate update) async {
|
|
if (_disposed) return;
|
|
final globalKey = update.task.metaData;
|
|
if (globalKey.isEmpty) return;
|
|
|
|
appLogger.d('Background task status: ${update.status} for $globalKey (task ${update.task.taskId})');
|
|
|
|
final existing = await _downloadForCurrentTaskSession(
|
|
globalKey,
|
|
update.task.taskId,
|
|
event: 'status ${update.status}',
|
|
cancelStale: _isNativeTaskActiveStatus(update.status),
|
|
requiredStatus: DownloadStatus.downloading,
|
|
);
|
|
if (existing == null) return;
|
|
|
|
try {
|
|
switch (update.status) {
|
|
case TaskStatus.complete:
|
|
await _onDownloadComplete(globalKey, update.task);
|
|
case TaskStatus.failed:
|
|
await _onDownloadFailed(globalKey, update.task.taskId, update.exception);
|
|
case TaskStatus.notFound:
|
|
await _onDownloadPermanentlyFailed(globalKey, update.task.taskId, 'File not found (404)');
|
|
case TaskStatus.canceled:
|
|
if (_pausingKeys.contains(globalKey) || _cancellingKeys.contains(globalKey)) break;
|
|
await _onDownloadCanceled(globalKey, update.task.taskId);
|
|
case TaskStatus.paused:
|
|
appLogger.d('Download paused by system for $globalKey');
|
|
case TaskStatus.waitingToRetry:
|
|
appLogger.d('Download waiting to retry for $globalKey');
|
|
case TaskStatus.enqueued:
|
|
case TaskStatus.running:
|
|
// If this item is being paused, the holding queue promoted it — cancel it
|
|
if (_pausingKeys.contains(globalKey)) {
|
|
await _cancelNativeTask(globalKey, update.task.taskId, reason: 'pause in progress');
|
|
}
|
|
if (_cancellingKeys.contains(globalKey)) {
|
|
await _cancelNativeTask(globalKey, update.task.taskId, reason: 'cancellation in progress');
|
|
}
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
appLogger.e('Error handling download status change for $globalKey', error: e);
|
|
}
|
|
}
|
|
|
|
/// Handle a system-initiated cancel — re-queue unless already completed.
|
|
Future<void> _onDownloadCanceled(String globalKey, String taskId) async {
|
|
if (_completingKeys.contains(globalKey)) return;
|
|
|
|
final existing = await _downloadForCurrentTaskSession(
|
|
globalKey,
|
|
taskId,
|
|
event: 'system cancellation',
|
|
requiredStatus: DownloadStatus.downloading,
|
|
);
|
|
if (existing == null) return;
|
|
|
|
_cancelDownloadTimers(globalKey);
|
|
_pendingDownloadContext.remove(globalKey);
|
|
|
|
appLogger.w('Download cancelled by system for $globalKey, re-queuing');
|
|
await _database.updateBgTaskId(globalKey, null);
|
|
await _requeueDownload(globalKey);
|
|
}
|
|
|
|
void _resumeQueueAfterStorageFailure(String operation) {
|
|
if (!_queueBlockedByStorageFailure) return;
|
|
appLogger.i('Resuming download queue after storage failure: $operation');
|
|
_queueBlockedByStorageFailure = false;
|
|
_consecutiveQueueFailures = 0;
|
|
}
|
|
|
|
Future<void> _handleStorageFullFailure(String globalKey, String taskId) async {
|
|
_queueBlockedByStorageFailure = true;
|
|
for (final timer in _autoRetryTimers.values) {
|
|
timer.cancel();
|
|
}
|
|
_autoRetryTimers.clear();
|
|
|
|
if (downloadsSupported) {
|
|
try {
|
|
await FileDownloader().database.deleteRecordWithId(taskId);
|
|
} catch (error, stackTrace) {
|
|
appLogger.w('Failed to discard partial download for $globalKey', error: error, stackTrace: stackTrace);
|
|
}
|
|
try {
|
|
await FileDownloader().reset(group: _downloadGroup);
|
|
} catch (error, stackTrace) {
|
|
appLogger.w(
|
|
'Failed to stop native download queue after storage exhaustion',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
}
|
|
}
|
|
|
|
final errorMessage = t.downloads.storageFull;
|
|
final failedKeys = await _database.failActiveDownloadsForStorageFull(errorMessage);
|
|
for (final key in failedKeys) {
|
|
_cancelDownloadTimers(key);
|
|
_pendingDownloadContext.remove(key);
|
|
_emitProgress(key, DownloadStatus.failed, 0, errorMessage: errorMessage);
|
|
}
|
|
appLogger.e('Device storage exhausted; stopped ${failedKeys.length} active download(s)');
|
|
}
|
|
|
|
bool _isRetryablePrepareFailure(Object error) {
|
|
if (error is! MediaServerHttpException || error.isCancellation) return false;
|
|
final status = error.statusCode;
|
|
if (status == 401 || status == 403) return false;
|
|
return error.isTransient || status != null && status >= 500;
|
|
}
|
|
|
|
Future<void> _scheduleDownloadRetry(
|
|
String globalKey,
|
|
MediaServerClient client,
|
|
int retryCount,
|
|
String errorMessage, {
|
|
required bool processQueueAfterProgress,
|
|
}) async {
|
|
appLogger.w(
|
|
'Download failed for $globalKey (attempt ${retryCount + 1}/$_maxAppRetries), '
|
|
'scheduling auto-retry in ${_autoRetryDelay.inSeconds}s: $errorMessage',
|
|
);
|
|
await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: errorMessage);
|
|
await _database.removeFromQueue(globalKey);
|
|
_autoRetryTimers.remove(globalKey)?.cancel();
|
|
_autoRetryTimers[globalKey] = Timer(_autoRetryDelay, () {
|
|
_autoRetryTimers.remove(globalKey);
|
|
unawaited(_performAutoRetry(globalKey));
|
|
});
|
|
|
|
if (processQueueAfterProgress) unawaited(_processQueue(client));
|
|
}
|
|
|
|
bool _isStorageFullDownloadFailure(TaskException? exception) {
|
|
return exception != null && isStorageFullMessage(exception.description);
|
|
}
|
|
|
|
/// Handle a failed download — stop the queue on storage exhaustion,
|
|
/// otherwise auto-retry if retries remain.
|
|
Future<void> _onDownloadFailed(String globalKey, String taskId, TaskException? exception) async {
|
|
if (_cancellingKeys.contains(globalKey)) {
|
|
appLogger.d('Ignoring failure for $globalKey: cancellation in progress');
|
|
return;
|
|
}
|
|
if (_completingKeys.contains(globalKey)) {
|
|
appLogger.d('Ignoring failure event for $globalKey: completion in progress');
|
|
return;
|
|
}
|
|
|
|
final existing = await _downloadForCurrentTaskSession(
|
|
globalKey,
|
|
taskId,
|
|
event: 'failure',
|
|
requiredStatus: DownloadStatus.downloading,
|
|
);
|
|
if (existing == null) return;
|
|
_cancelDownloadTimers(globalKey);
|
|
_pendingDownloadContext.remove(globalKey);
|
|
if (_isStorageFullDownloadFailure(exception)) {
|
|
await _handleStorageFullFailure(globalKey, taskId);
|
|
return;
|
|
}
|
|
final errorMessage = exception?.description ?? 'Download failed';
|
|
final retryCount = existing.retryCount;
|
|
|
|
// DNS/connection errors fail instantly and exhaust native retries in milliseconds,
|
|
// creating a retry storm. Treat them as permanent failures.
|
|
final isNetworkError =
|
|
errorMessage.contains('Unable to resolve host') ||
|
|
errorMessage.contains('No address associated with hostname') ||
|
|
errorMessage.contains('Network is unreachable') ||
|
|
errorMessage.contains('Connection refused');
|
|
final isServerError = errorMessage.contains('500 Internal Server Error');
|
|
|
|
final client = await _getClientForDownloadKey(globalKey);
|
|
final hadProgress = existing.downloadedBytes > 0;
|
|
|
|
if (!isNetworkError && !isServerError && retryCount < _maxAppRetries && client != null) {
|
|
// App-level auto-retry: schedule a fresh download after a delay.
|
|
// Each new task gets 5 native retries with Range-based resume.
|
|
await _scheduleDownloadRetry(globalKey, client, retryCount, errorMessage, processQueueAfterProgress: hadProgress);
|
|
} else {
|
|
if (isNetworkError) {
|
|
appLogger.w('Network error for $globalKey, failing permanently (no auto-retry): $errorMessage');
|
|
}
|
|
final userMessage = isServerError ? t.downloads.serverErrorBitrate : errorMessage;
|
|
await _onDownloadPermanentlyFailed(globalKey, taskId, userMessage);
|
|
}
|
|
}
|
|
|
|
/// Handle a non-retryable failure (e.g. 404) — fail immediately without auto-retry.
|
|
Future<void> _onDownloadPermanentlyFailed(String globalKey, String taskId, String errorMessage) async {
|
|
if (_cancellingKeys.contains(globalKey)) {
|
|
appLogger.d('Ignoring permanent failure for $globalKey: cancellation in progress');
|
|
return;
|
|
}
|
|
if (_completingKeys.contains(globalKey)) {
|
|
appLogger.d('Ignoring permanent failure event for $globalKey: completion in progress');
|
|
return;
|
|
}
|
|
|
|
final existing = await _downloadForCurrentTaskSession(
|
|
globalKey,
|
|
taskId,
|
|
event: 'permanent failure',
|
|
requiredStatus: DownloadStatus.downloading,
|
|
);
|
|
if (existing == null) return;
|
|
|
|
_cancelDownloadTimers(globalKey);
|
|
_pendingDownloadContext.remove(globalKey);
|
|
|
|
appLogger.e('Download permanently failed for $globalKey: $errorMessage');
|
|
await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: errorMessage);
|
|
await _database.removeFromQueue(globalKey);
|
|
|
|
// Try to enqueue more items from the queue
|
|
final client = await _getClientForDownloadKey(globalKey);
|
|
if (client != null) unawaited(_processQueue(client));
|
|
}
|
|
|
|
/// Execute an app-level auto-retry: transition back to queued and re-enqueue.
|
|
Future<void> _performAutoRetry(String globalKey) async {
|
|
if (_disposed) return;
|
|
final client = await _getClientForDownloadKey(globalKey);
|
|
if (client == null) {
|
|
appLogger.w('Cannot auto-retry $globalKey: no client available');
|
|
return;
|
|
}
|
|
|
|
final existing = await _database.getDownloadedMedia(globalKey);
|
|
if (existing == null || existing.status != DownloadStatus.failed.index) {
|
|
// Download was cancelled/deleted/retried by user during the delay
|
|
return;
|
|
}
|
|
|
|
appLogger.i('Auto-retrying download for $globalKey');
|
|
await _cleanupStaleDownload(globalKey);
|
|
// The retry delay already throttles preparation failures; do not let the
|
|
// immediate queue circuit breaker suppress the next scheduled attempt.
|
|
_consecutiveQueueFailures = 0;
|
|
await _requeueDownload(globalKey, fallbackClient: client);
|
|
}
|
|
|
|
/// Handle a completed video download — store path, download supplementary content, mark done.
|
|
Future<void> _onDownloadComplete(String globalKey, Task task) async {
|
|
_consecutiveQueueFailures = 0;
|
|
// Prevent duplicate concurrent completions (e.g. trackTasks replaying events)
|
|
if (_completingKeys.contains(globalKey)) {
|
|
appLogger.d('Already processing completion for $globalKey, skipping');
|
|
return;
|
|
}
|
|
_completingKeys.add(globalKey);
|
|
try {
|
|
// Fresh DB check — bail if already completed (guards against race with orphan scan)
|
|
final existingCheck = await _downloadForCurrentTaskSession(
|
|
globalKey,
|
|
task.taskId,
|
|
event: 'completion',
|
|
requiredStatus: DownloadStatus.downloading,
|
|
);
|
|
if (_cancellingKeys.contains(globalKey) || existingCheck == null) {
|
|
appLogger.d('Download no longer active for $globalKey, skipping completion');
|
|
return;
|
|
}
|
|
|
|
// Flush any pending debounced progress write + cancel any scheduled retry.
|
|
_cancelDownloadTimers(globalKey);
|
|
final ctx = _pendingDownloadContext.remove(globalKey);
|
|
|
|
// ── Phase 1 (critical): resolve and store the video file path ──
|
|
final String storedPath;
|
|
if (ctx != null) {
|
|
// Happy path: context available from this session
|
|
if (ctx.isSafMode) {
|
|
// UriDownloadTask wrote directly to SAF — find the file URI
|
|
final child = await _safStorage.getChild(ctx.filePath, [task.filename]);
|
|
if (child != null) {
|
|
storedPath = child.uri;
|
|
} else {
|
|
final safRootUri = ctx.safRootUri;
|
|
if (safRootUri == null) {
|
|
throw StateError('SAF download context has no root claim');
|
|
}
|
|
storedPath = await _resolveSafStoredPath(ctx.metadata, ctx.extension, ctx.showYear, safRootUri) ?? '';
|
|
if (storedPath.isEmpty) {
|
|
throw Exception('Cannot determine SAF file URI');
|
|
}
|
|
}
|
|
} else {
|
|
storedPath = await _storageService.toRelativePath(ctx.filePath);
|
|
}
|
|
} else {
|
|
// Recovery path: context missing (app was restarted)
|
|
final existing = await _database.getDownloadedMedia(globalKey);
|
|
if (existing?.videoFilePath != null && existing?.status == DownloadStatus.completed.index) {
|
|
appLogger.d('Download already completed for $globalKey');
|
|
return;
|
|
}
|
|
if (existing?.videoFilePath != null) {
|
|
// Video path set but status not completed — just finish up
|
|
storedPath = existing!.videoFilePath!;
|
|
} else if (task is UriDownloadTask) {
|
|
// SAF mode recovery: restore the row's originating root before
|
|
// resolving any path. The selected root may have changed.
|
|
var safRootUri = existing?.safRootUri;
|
|
safRootUri ??= await _safStorage.resolvePersistedPermissionUri(task.directoryUri.toString());
|
|
if (safRootUri == null) {
|
|
throw StateError('Cannot recover SAF root ownership');
|
|
}
|
|
await _serializeSafOwnership(() => _replaceDownloadSafRootClaim(globalKey, safRootUri));
|
|
|
|
final directChild = await _safStorage.getChild(task.directoryUri.toString(), [task.filename]);
|
|
if (directChild != null) {
|
|
storedPath = directChild.uri;
|
|
} else {
|
|
final parsed = parseGlobalKey(globalKey);
|
|
if (parsed == null) {
|
|
throw Exception('Invalid globalKey for recovery: $globalKey');
|
|
}
|
|
final metadata = await _lookupMetadata(
|
|
parsed.serverId,
|
|
parsed.ratingKey,
|
|
clientScopeId: existing?.clientScopeId,
|
|
);
|
|
if (metadata == null) {
|
|
throw Exception('No metadata for SAF recovery of $globalKey');
|
|
}
|
|
final ext = downloadExtensionFromUrl(task.url) ?? 'mp4';
|
|
storedPath =
|
|
await _resolveSafStoredPathForRecovery(
|
|
metadata,
|
|
ext,
|
|
safRootUri,
|
|
clientScopeId: existing?.clientScopeId,
|
|
) ??
|
|
'';
|
|
if (storedPath.isEmpty) {
|
|
throw Exception('Cannot resolve SAF path on recovery');
|
|
}
|
|
}
|
|
} else {
|
|
// Normal mode recovery: reconstruct from task
|
|
storedPath = await _storageService.toRelativePath('${task.directory}/${task.filename}');
|
|
}
|
|
}
|
|
|
|
await _database.updateVideoFilePath(globalKey, storedPath);
|
|
appLogger.d('Video download completed for $globalKey');
|
|
|
|
// ── Phase 2 (best-effort): supplementary downloads ──
|
|
final persistedQueueItem = await (_database.select(
|
|
_database.downloadQueue,
|
|
)..where((t) => t.mediaGlobalKey.equals(globalKey))).getSingleOrNull();
|
|
final queueItem = ctx?.queueItem ?? persistedQueueItem;
|
|
final downloadArtwork = queueItem?.downloadArtwork ?? true;
|
|
final downloadSubtitles = queueItem?.downloadSubtitles ?? true;
|
|
var artworkSettled = !downloadArtwork;
|
|
var subtitlesSettled = !downloadSubtitles;
|
|
|
|
try {
|
|
final metadata = ctx?.metadata ?? await _resolveMetadata(globalKey);
|
|
final client = ctx?.client ?? await _getClientForDownloadKey(globalKey);
|
|
|
|
if (metadata != null && client != null) {
|
|
final settled = await _runSupplementaryDownloads(
|
|
globalKey,
|
|
metadata,
|
|
client,
|
|
isRepair: false,
|
|
downloadArtwork: downloadArtwork,
|
|
downloadSubtitles: downloadSubtitles,
|
|
record: existingCheck,
|
|
showYear: ctx?.showYear,
|
|
preresolvedSubtitles: ctx?.subtitles,
|
|
);
|
|
artworkSettled = settled.artwork;
|
|
subtitlesSettled = settled.subtitles;
|
|
}
|
|
} catch (e, st) {
|
|
appLogger.w('Supplementary downloads failed for $globalKey (video is saved)', error: e, stackTrace: st);
|
|
}
|
|
|
|
// The primary video is terminal independently of supplementary outcome.
|
|
await _transitionStatus(globalKey, DownloadStatus.completed);
|
|
try {
|
|
if (artworkSettled && subtitlesSettled) {
|
|
await _database.removeFromQueue(globalKey);
|
|
} else if (persistedQueueItem != null) {
|
|
await _database.updateSupplementaryQueueIntent(
|
|
globalKey,
|
|
downloadSubtitles: !subtitlesSettled,
|
|
downloadArtwork: !artworkSettled,
|
|
);
|
|
} else {
|
|
await _database.addToQueue(
|
|
mediaGlobalKey: globalKey,
|
|
priority: queueItem?.priority ?? 0,
|
|
downloadSubtitles: !subtitlesSettled,
|
|
downloadArtwork: !artworkSettled,
|
|
);
|
|
}
|
|
} catch (e, st) {
|
|
appLogger.e('Failed to settle supplementary queue state for $globalKey', error: e, stackTrace: st);
|
|
}
|
|
appLogger.i('Download completed for $globalKey');
|
|
} catch (e) {
|
|
appLogger.e('Post-download processing failed for $globalKey', error: e);
|
|
await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: 'Post-processing failed: $e');
|
|
await _database.removeFromQueue(globalKey);
|
|
} finally {
|
|
_completingKeys.remove(globalKey);
|
|
// Always advance the queue, even after errors
|
|
final nextClient = await _getClientForDownloadKey(globalKey);
|
|
if (nextClient != null) unawaited(_processQueue(nextClient));
|
|
}
|
|
}
|
|
|
|
Future<MediaItem?> _resolveMetadata(String globalKey) async {
|
|
final parsed = parseGlobalKey(globalKey);
|
|
if (parsed == null) return null;
|
|
final record = await _database.getDownloadedMedia(globalKey);
|
|
return _lookupMetadata(parsed.serverId, parsed.ratingKey, clientScopeId: record?.clientScopeId);
|
|
}
|
|
|
|
/// Look up the year of the parent show for an episode (used for folder naming).
|
|
Future<int?> _fetchShowYear(ServerId serverId, String? grandparentRatingKey, {String? clientScopeId}) async {
|
|
if (grandparentRatingKey == null) return null;
|
|
return (await _lookupMetadata(serverId, grandparentRatingKey, clientScopeId: clientScopeId))?.year;
|
|
}
|
|
|
|
Future<String?> _resolveSafStoredPath(MediaItem metadata, String ext, int? showYear, String safRootUri) async {
|
|
final target = _storageService.safTarget(metadata, ext, showYear: showYear, serverId: metadata.serverId);
|
|
|
|
final dirUri = await _safStorage.createNestedDirectories(safRootUri, target.components);
|
|
if (dirUri == null) return null;
|
|
|
|
final child = await _safStorage.getChild(dirUri, [target.fileName]);
|
|
return child?.uri;
|
|
}
|
|
|
|
@visibleForTesting
|
|
Future<int?> debugResolveSafRecoveryShowYear(MediaItem metadata, {String? clientScopeId}) {
|
|
return _resolveSafRecoveryShowYear(metadata, clientScopeId: clientScopeId);
|
|
}
|
|
|
|
Future<String?> _resolveSafStoredPathForRecovery(
|
|
MediaItem metadata,
|
|
String ext,
|
|
String safRootUri, {
|
|
String? clientScopeId,
|
|
}) async {
|
|
final showYear = await _resolveSafRecoveryShowYear(metadata, clientScopeId: clientScopeId);
|
|
return await _resolveSafStoredPath(metadata, ext, showYear, safRootUri) ??
|
|
(showYear == null ? null : await _resolveSafStoredPath(metadata, ext, null, safRootUri));
|
|
}
|
|
|
|
Future<int?> _resolveSafRecoveryShowYear(MediaItem metadata, {String? clientScopeId}) {
|
|
final serverId = metadata.serverId;
|
|
if (!metadata.isEpisode || serverId == null) return Future.value();
|
|
return _fetchShowYear(ServerId(serverId), metadata.grandparentId, clientScopeId: clientScopeId);
|
|
}
|
|
|
|
/// Best-effort supplementary work for an already-stored video (artwork,
|
|
/// chapter thumbnails, external subtitles); reports which half settled so the
|
|
/// caller can do its own queue-row bookkeeping. Shared by the completion path
|
|
/// and the deferred-repair path: [record] carries the media-source
|
|
/// coordinates for re-resolving subtitles, [preresolvedSubtitles] skips that
|
|
/// re-resolve, and [showYear] is caller-supplied because the paths differ.
|
|
Future<({bool artwork, bool subtitles})> _runSupplementaryDownloads(
|
|
String globalKey,
|
|
MediaItem metadata,
|
|
MediaServerClient client, {
|
|
required bool isRepair,
|
|
required bool downloadArtwork,
|
|
required bool downloadSubtitles,
|
|
required DownloadedMediaItem? record,
|
|
required int? showYear,
|
|
List<DownloadSubtitleSpec>? preresolvedSubtitles,
|
|
}) async {
|
|
var artworkSettled = !downloadArtwork;
|
|
if (downloadArtwork) {
|
|
final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client, isRepair: isRepair);
|
|
final chapterArtworkSettled = metadata.serverId == null
|
|
? false
|
|
: await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client);
|
|
artworkSettled = itemArtworkSettled && chapterArtworkSettled;
|
|
}
|
|
|
|
var subtitlesSettled = !downloadSubtitles;
|
|
if (downloadSubtitles) {
|
|
try {
|
|
var subtitles = preresolvedSubtitles;
|
|
if (subtitles == null) {
|
|
final resolution = await client.resolveDownload(
|
|
metadata,
|
|
mediaIndex: record?.mediaIndex ?? 0,
|
|
mediaSourceId: record?.mediaSourceId,
|
|
);
|
|
if (resolution.externalSubtitlesResolved) {
|
|
subtitles = resolution.externalSubtitles;
|
|
} else {
|
|
appLogger.d('Subtitle enrichment remains deferred for $globalKey');
|
|
}
|
|
}
|
|
if (subtitles != null) {
|
|
subtitlesSettled = await _downloadSubtitles(
|
|
globalKey,
|
|
metadata,
|
|
subtitles,
|
|
client,
|
|
isRepair: isRepair,
|
|
showYear: showYear,
|
|
);
|
|
}
|
|
} catch (e, st) {
|
|
appLogger.w('Could not resolve subtitles for $globalKey', error: e, stackTrace: st);
|
|
}
|
|
}
|
|
|
|
return (artwork: artworkSettled, subtitles: subtitlesSettled);
|
|
}
|
|
|
|
Future<bool> _downloadArtwork(
|
|
String globalKey,
|
|
MediaItem metadata,
|
|
MediaServerClient client, {
|
|
required bool isRepair,
|
|
}) async {
|
|
if (metadata.serverId == null) return false;
|
|
|
|
try {
|
|
if (!isRepair) {
|
|
_emitProgress(globalKey, DownloadStatus.downloading, 0, currentFile: 'artwork');
|
|
}
|
|
|
|
final serverId = metadata.serverId!;
|
|
final specs = client.resolveDownloadArtwork(metadata);
|
|
final artworkSettled = await _artworkService.ensureArtworkSpecs(ServerId(serverId), specs);
|
|
|
|
final storedThumbPath = metadata.thumbPath == null ? null : artworkStorageKey(metadata.thumbPath!);
|
|
await _database.updateArtworkPaths(globalKey: globalKey, thumbPath: storedThumbPath);
|
|
|
|
_emitProgressWithArtwork(globalKey, isRepair: isRepair, thumbPath: storedThumbPath);
|
|
appLogger.d(artworkSettled ? 'Artwork downloaded for $globalKey' : 'Artwork remains incomplete for $globalKey');
|
|
return artworkSettled;
|
|
} catch (e, st) {
|
|
appLogger.w('Failed to download artwork for $globalKey', error: e, stackTrace: st);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Download a single artwork blob if not already on disk. The [spec] carries
|
|
/// both the storage key (used to hash the local filename) and the absolute
|
|
/// URL to fetch.
|
|
Future<bool> _downloadSingleArtwork(ServerId serverId, DownloadArtworkSpec spec) {
|
|
return _artworkService.downloadSingleArtwork(serverId, spec);
|
|
}
|
|
|
|
/// Download all artwork for a metadata item (public method for parent metadata)
|
|
/// Downloads thumb/poster, clearLogo, and background art
|
|
Future<void> downloadArtworkForMetadata(MediaItem metadata, MediaServerClient client) async {
|
|
if (metadata.serverId == null) return;
|
|
final serverId = metadata.serverId!;
|
|
await _artworkService.ensureArtworkSpecs(ServerId(serverId), client.resolveDownloadArtwork(metadata));
|
|
}
|
|
|
|
/// Download chapter thumbnail images for a media item.
|
|
Future<bool> _downloadChapterThumbnails(ServerId serverId, String ratingKey, MediaServerClient client) async {
|
|
try {
|
|
final extras = await client.fetchPlaybackExtras(ratingKey);
|
|
|
|
var allSettled = true;
|
|
var downloadedCount = 0;
|
|
for (final chapter in extras.chapters) {
|
|
final thumb = chapter.thumb;
|
|
if (thumb == null || thumb.isEmpty) continue;
|
|
final url = client.thumbnailUrl(thumb);
|
|
if (url.isEmpty) {
|
|
allSettled = false;
|
|
continue;
|
|
}
|
|
final settled = await _downloadSingleArtwork(serverId, DownloadArtworkSpec(localKey: thumb, url: url));
|
|
if (settled) {
|
|
downloadedCount++;
|
|
} else {
|
|
allSettled = false;
|
|
}
|
|
}
|
|
|
|
if (extras.chapters.isNotEmpty) {
|
|
appLogger.d('Downloaded $downloadedCount/${extras.chapters.length} chapter thumbnails');
|
|
}
|
|
return allSettled;
|
|
} catch (e, st) {
|
|
appLogger.w('Failed to download chapter thumbnails', error: e, stackTrace: st);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// [showYear]: For episodes, pass the show's premiere year (not the episode's year)
|
|
Future<bool> _downloadSubtitles(
|
|
String globalKey,
|
|
MediaItem metadata,
|
|
List<DownloadSubtitleSpec> subtitles,
|
|
MediaServerClient client, {
|
|
required bool isRepair,
|
|
int? showYear,
|
|
}) async {
|
|
if (!isRepair) {
|
|
_emitProgress(globalKey, DownloadStatus.downloading, 0, currentFile: 'subtitles');
|
|
}
|
|
var allSettled = true;
|
|
|
|
for (final subtitle in subtitles) {
|
|
try {
|
|
final extension = CodecUtils.getSubtitleExtension(subtitle.codec);
|
|
final String subtitlePath;
|
|
if (_storageService.isUsingSaf) {
|
|
subtitlePath = await _storageService.getSubtitlePath(
|
|
ServerId(metadata.serverId!),
|
|
metadata.id,
|
|
subtitle.id,
|
|
extension,
|
|
);
|
|
} else if (metadata.isEpisode) {
|
|
subtitlePath = await _storageService.getEpisodeSubtitlePath(
|
|
metadata,
|
|
subtitle.id,
|
|
extension,
|
|
showYear: showYear,
|
|
);
|
|
} else if (metadata.isMovie) {
|
|
subtitlePath = await _storageService.getMovieSubtitlePath(metadata, subtitle.id, extension);
|
|
} else {
|
|
subtitlePath = await _storageService.getSubtitlePath(
|
|
ServerId(metadata.serverId!),
|
|
metadata.id,
|
|
subtitle.id,
|
|
extension,
|
|
);
|
|
}
|
|
|
|
final file = File(subtitlePath);
|
|
if (await file.exists()) {
|
|
appLogger.d('Subtitle ${subtitle.id} already exists for $globalKey');
|
|
continue;
|
|
}
|
|
await file.parent.create(recursive: true);
|
|
await _http.downloadFile(subtitle.url, subtitlePath);
|
|
appLogger.d('Downloaded subtitle ${subtitle.id} for $globalKey');
|
|
} catch (e, st) {
|
|
allSettled = false;
|
|
appLogger.w('Failed to download subtitle ${subtitle.id} for $globalKey', error: e, stackTrace: st);
|
|
}
|
|
}
|
|
|
|
return allSettled;
|
|
}
|
|
|
|
void _emitProgress(
|
|
String globalKey,
|
|
DownloadStatus status,
|
|
int progress, {
|
|
String? errorMessage,
|
|
String? currentFile,
|
|
}) {
|
|
if (_disposed) return;
|
|
_progressController.add(
|
|
DownloadProgress(
|
|
globalKey: globalKey,
|
|
status: status,
|
|
progress: progress,
|
|
errorMessage: errorMessage,
|
|
currentFile: currentFile,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Update download status in database and emit progress notification.
|
|
///
|
|
/// This helper combines two common operations:
|
|
/// 1. Update status in the database
|
|
/// 2. Emit progress to listeners
|
|
///
|
|
/// Default progress is 0 for most statuses, 100 for completed.
|
|
Future<void> _transitionStatus(String globalKey, DownloadStatus status, {int? progress, String? errorMessage}) async {
|
|
await _database.updateDownloadStatus(globalKey, status.index);
|
|
if (status == DownloadStatus.failed && errorMessage != null) {
|
|
await _database.updateDownloadError(globalKey, errorMessage);
|
|
}
|
|
_emitProgress(
|
|
globalKey,
|
|
status,
|
|
progress ?? (status == DownloadStatus.completed ? 100 : 0),
|
|
errorMessage: errorMessage,
|
|
);
|
|
}
|
|
|
|
/// Emit progress update with artwork paths so DownloadProvider can sync
|
|
void _emitProgressWithArtwork(String globalKey, {required bool isRepair, String? thumbPath}) {
|
|
if (_disposed) return;
|
|
_progressController.add(
|
|
DownloadProgress(
|
|
globalKey: globalKey,
|
|
status: isRepair ? DownloadStatus.completed : DownloadStatus.downloading,
|
|
progress: isRepair ? 100 : 0,
|
|
currentFile: 'artwork',
|
|
thumbPath: thumbPath,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Pause a download (works for both downloading and queued items)
|
|
Future<void> pauseDownload(String globalKey) async {
|
|
// Mark as pausing synchronously so callbacks from holding-queue promotions
|
|
// can detect and cancel promoted tasks before any await yields.
|
|
_pausingKeys.add(globalKey);
|
|
|
|
try {
|
|
_cancelDownloadTimers(globalKey);
|
|
final bgTaskId = await _database.getBgTaskId(globalKey);
|
|
await _cancelNativeTasksForGlobalKey(globalKey, exceptTaskId: bgTaskId, reason: 'duplicate task before pause');
|
|
if (bgTaskId != null && downloadsSupported) {
|
|
final task = await FileDownloader().taskForId(bgTaskId);
|
|
if (task != null && task is DownloadTask) {
|
|
// Normal mode: native pause support
|
|
await FileDownloader().pause(task);
|
|
} else {
|
|
// SAF mode (UriDownloadTask) or task not found: cancel (re-download on resume)
|
|
await FileDownloader().cancelTaskWithId(bgTaskId);
|
|
}
|
|
} else if (bgTaskId != null) {
|
|
await _database.updateBgTaskId(globalKey, null);
|
|
}
|
|
_pendingDownloadContext.remove(globalKey);
|
|
await _transitionStatus(globalKey, DownloadStatus.paused);
|
|
await _database.removeFromQueue(globalKey);
|
|
} finally {
|
|
_pausingKeys.remove(globalKey);
|
|
}
|
|
}
|
|
|
|
/// Resume a paused download
|
|
Future<void> resumeDownload(String globalKey, MediaServerClient client) async {
|
|
if (_skipDownloadsUnsupported('download resume')) return;
|
|
_resumeQueueAfterStorageFailure('manual resume');
|
|
|
|
final bgTaskId = await _database.getBgTaskId(globalKey);
|
|
|
|
// Try native resume first (only works for normal-mode DownloadTask that was paused)
|
|
if (bgTaskId != null && await _tryResumeNativeTask(globalKey, bgTaskId)) return;
|
|
|
|
// Native resume failed or not supported (SAF mode) — re-enqueue from scratch
|
|
await _cleanupStaleDownload(globalKey);
|
|
await _requeueDownload(globalKey, fallbackClient: client);
|
|
}
|
|
|
|
Future<bool> _tryResumeNativeTask(
|
|
String globalKey,
|
|
String bgTaskId, {
|
|
_NativeTaskForId? taskForId,
|
|
_NativeResumeTask? resumeTask,
|
|
}) async {
|
|
await _cancelNativeTasksForGlobalKey(globalKey, exceptTaskId: bgTaskId, reason: 'duplicate task before resume');
|
|
|
|
try {
|
|
final task = await (taskForId ?? FileDownloader().taskForId)(bgTaskId);
|
|
if (task == null || task is! DownloadTask) return false;
|
|
|
|
final resumed = await (resumeTask ?? FileDownloader().resume)(task);
|
|
if (!resumed) {
|
|
appLogger.w('Native resume returned false for $globalKey; re-enqueuing from scratch');
|
|
return false;
|
|
}
|
|
|
|
await _transitionStatus(globalKey, DownloadStatus.downloading);
|
|
appLogger.i('Resumed download via background_downloader for $globalKey');
|
|
return true;
|
|
} catch (e) {
|
|
appLogger.w('Native resume failed for $globalKey; re-enqueuing from scratch', error: e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@visibleForTesting
|
|
Future<bool> debugTryResumeNativeTask(
|
|
String globalKey,
|
|
String bgTaskId, {
|
|
required Future<Task?> Function(String taskId) taskForId,
|
|
required Future<bool> Function(DownloadTask task) resumeTask,
|
|
}) {
|
|
return _tryResumeNativeTask(globalKey, bgTaskId, taskForId: taskForId, resumeTask: resumeTask);
|
|
}
|
|
|
|
/// Retry a failed download
|
|
Future<void> retryDownload(String globalKey, MediaServerClient client) async {
|
|
if (_skipDownloadsUnsupported('download retry')) return;
|
|
_resumeQueueAfterStorageFailure('manual retry');
|
|
|
|
_autoRetryTimers.remove(globalKey)?.cancel();
|
|
await _cleanupStaleDownload(globalKey);
|
|
await _database.clearDownloadError(globalKey);
|
|
await _requeueDownload(globalKey, fallbackClient: client);
|
|
}
|
|
|
|
/// Cancel a download
|
|
Future<void> cancelDownload(String globalKey) async {
|
|
_cancellingKeys.add(globalKey);
|
|
try {
|
|
_cancelDownloadTimers(globalKey);
|
|
final bgTaskId = await _database.getBgTaskId(globalKey);
|
|
await _database.updateBgTaskId(globalKey, null);
|
|
await _cancelNativeTasksForGlobalKey(globalKey, includeTaskId: bgTaskId, reason: 'user cancellation');
|
|
_pendingDownloadContext.remove(globalKey);
|
|
await _transitionStatus(globalKey, DownloadStatus.cancelled);
|
|
await _database.removeFromQueue(globalKey);
|
|
} finally {
|
|
_cancellingKeys.remove(globalKey);
|
|
}
|
|
}
|
|
|
|
/// Cancels native work before removing the durable row and reconciling its
|
|
/// persisted SAF grant.
|
|
Future<void> cancelAndRemoveDownload(String globalKey) async {
|
|
await cancelDownload(globalKey);
|
|
await _deleteDownloadRowAndRelease(globalKey);
|
|
}
|
|
|
|
Future<void> deleteDownload(String globalKey) async {
|
|
_cancellingKeys.add(globalKey);
|
|
try {
|
|
_cancelDownloadTimers(globalKey);
|
|
final bgTaskId = await _database.getBgTaskId(globalKey);
|
|
await _database.updateBgTaskId(globalKey, null);
|
|
await _cancelNativeTasksForGlobalKey(globalKey, includeTaskId: bgTaskId, reason: 'delete download');
|
|
_pendingDownloadContext.remove(globalKey);
|
|
|
|
final parsed = parseGlobalKey(globalKey);
|
|
if (parsed == null) {
|
|
await _deleteDownloadRowAndRelease(globalKey);
|
|
return;
|
|
}
|
|
|
|
final serverId = parsed.serverId;
|
|
final ratingKey = parsed.ratingKey;
|
|
final downloadRecord = await _database.getDownloadedMedia(globalKey);
|
|
final clientScopeId = downloadRecord?.clientScopeId;
|
|
final metadata = await _lookupMetadata(serverId, ratingKey, clientScopeId: clientScopeId);
|
|
|
|
if (metadata == null) {
|
|
// Fallback deletion without progress
|
|
await _deleteMediaFilesWithMetadata(serverId, ratingKey, clientScopeId: clientScopeId);
|
|
await _deleteForItemByServer(serverId, ratingKey, clientScopeId: clientScopeId);
|
|
await _deleteDownloadRowAndRelease(globalKey);
|
|
return;
|
|
}
|
|
|
|
final totalItems = await _getTotalItemsToDelete(metadata, serverId, clientScopeId: clientScopeId);
|
|
|
|
_emitDeletionProgress(
|
|
DeletionProgress(
|
|
globalKey: globalKey,
|
|
itemTitle: metadata.displayTitle,
|
|
currentItem: 0,
|
|
totalItems: totalItems,
|
|
),
|
|
);
|
|
|
|
await _deleteMediaFilesWithMetadata(serverId, ratingKey, clientScopeId: clientScopeId);
|
|
|
|
await _deleteForItemByServer(serverId, ratingKey, clientScopeId: clientScopeId);
|
|
|
|
await _deleteDownloadRowAndRelease(globalKey);
|
|
|
|
_emitDeletionProgress(
|
|
DeletionProgress(
|
|
globalKey: globalKey,
|
|
itemTitle: metadata.displayTitle,
|
|
currentItem: totalItems,
|
|
totalItems: totalItems,
|
|
),
|
|
);
|
|
} finally {
|
|
_cancellingKeys.remove(globalKey);
|
|
}
|
|
}
|
|
|
|
void _emitDeletionProgress(DeletionProgress progress) {
|
|
if (_disposed) return;
|
|
_deletionProgressController.add(progress);
|
|
}
|
|
|
|
/// Calculate total items to delete (for progress tracking)
|
|
Future<int> _getTotalItemsToDelete(MediaItem metadata, ServerId serverId, {String? clientScopeId}) async {
|
|
switch (metadata.kind) {
|
|
case MediaKind.episode:
|
|
case MediaKind.movie:
|
|
return 1;
|
|
case MediaKind.season:
|
|
final episodes = await _database.getEpisodesBySeason(metadata.id, serverId: serverId);
|
|
return episodes.length;
|
|
case MediaKind.show:
|
|
final episodes = await _database.getEpisodesByShow(metadata.id, serverId: serverId);
|
|
return episodes.length;
|
|
case MediaKind.album:
|
|
final tracks = await _database.getTracksByAlbum(metadata.id, serverId: serverId);
|
|
return tracks.length;
|
|
case MediaKind.artist:
|
|
final tracks = await _database.getTracksByArtist(metadata.id, serverId: serverId);
|
|
return tracks.length;
|
|
default:
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteMediaFilesWithMetadata(ServerId serverId, String ratingKey, {String? clientScopeId}) async {
|
|
try {
|
|
final gk = buildGlobalKey(ServerId(serverId), ratingKey);
|
|
final downloadRecord = await _database.getDownloadedMedia(gk);
|
|
final scopeId = clientScopeId ?? downloadRecord?.clientScopeId;
|
|
final metadata = await _lookupMetadata(serverId, ratingKey, clientScopeId: scopeId);
|
|
|
|
if (metadata == null) {
|
|
// Fallback: Try database record
|
|
if (downloadRecord?.videoFilePath != null) {
|
|
await _deleteByFilePath(downloadRecord!);
|
|
return;
|
|
}
|
|
appLogger.w('Cannot delete - no metadata for $gk');
|
|
return;
|
|
}
|
|
|
|
switch (metadata.kind) {
|
|
case MediaKind.episode:
|
|
await _deleteEpisodeFiles(metadata, serverId, clientScopeId: scopeId);
|
|
break;
|
|
case MediaKind.season:
|
|
await _deleteSeasonFiles(metadata, serverId, clientScopeId: scopeId);
|
|
break;
|
|
case MediaKind.show:
|
|
await _deleteShowFiles(metadata, serverId, clientScopeId: scopeId);
|
|
break;
|
|
case MediaKind.movie:
|
|
await _deleteMovieFiles(metadata, serverId, clientScopeId: scopeId);
|
|
break;
|
|
// Tracks live in the generic downloads/{serverId}/{ratingKey}/ layout
|
|
// (both file and SAF mode), so deletion is DB-record-driven rather
|
|
// than storage-template-driven like movies/episodes.
|
|
case MediaKind.track:
|
|
if (downloadRecord != null) await _deleteTrackByRecord(downloadRecord);
|
|
break;
|
|
case MediaKind.album:
|
|
await _deleteTracksInContainer(
|
|
tracks: await _database.getTracksByAlbum(metadata.id, serverId: serverId),
|
|
serverId: serverId,
|
|
clientScopeId: scopeId,
|
|
containerKey: metadata.id,
|
|
containerTitle: metadata.displayTitle,
|
|
);
|
|
break;
|
|
case MediaKind.artist:
|
|
await _deleteTracksInContainer(
|
|
tracks: await _database.getTracksByArtist(metadata.id, serverId: serverId),
|
|
serverId: serverId,
|
|
clientScopeId: scopeId,
|
|
containerKey: metadata.id,
|
|
containerTitle: metadata.displayTitle,
|
|
);
|
|
break;
|
|
default:
|
|
appLogger.w('Unknown type for deletion: ${metadata.kind.id}');
|
|
}
|
|
} catch (e, stack) {
|
|
appLogger.e('Error deleting files', error: e, stackTrace: stack);
|
|
}
|
|
}
|
|
|
|
/// Get chapter thumb paths from cached metadata. Backend-aware: routes
|
|
/// through the resolved [MediaServerClient] so Jellyfin items return
|
|
/// their `/Items/.../Images/Chapter/...?tag=...` paths and Plex items
|
|
/// return their `/library/parts/.../indexes/sd/...` paths. Both shapes
|
|
/// hash through [DownloadStorageService] the same way.
|
|
///
|
|
/// `fetchPlaybackExtras` consults each backend's cache first, so this
|
|
/// stays cheap during deletion (no network round-trip when the metadata
|
|
/// is already cached, which it always is for downloaded items).
|
|
Future<List<String>> _getChapterThumbPaths(ServerId serverId, String ratingKey, {String? clientScopeId}) async {
|
|
try {
|
|
final client = _getClient(serverId, clientScopeId: clientScopeId);
|
|
if (client == null) return [];
|
|
final extras = await client.fetchPlaybackExtras(ratingKey);
|
|
return extras.chapters
|
|
.map((ch) => ch.thumb)
|
|
.where((thumb) => thumb != null && thumb.isNotEmpty)
|
|
.cast<String>()
|
|
.toList();
|
|
} catch (e) {
|
|
appLogger.w('Error getting chapter thumb paths for $ratingKey', error: e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Delete chapter thumbnails for a media item (with reference counting).
|
|
///
|
|
/// Pre-loads all chapter paths for other items on the same server in one pass,
|
|
/// then checks membership in a Set — O(items * chapters) instead of
|
|
/// O(thumbs * items * chapters) with repeated DB queries.
|
|
Future<void> _deleteChapterThumbnails(ServerId serverId, String ratingKey, {String? clientScopeId}) async {
|
|
try {
|
|
final record = await _database.getDownloadedMedia(buildGlobalKey(ServerId(serverId), ratingKey));
|
|
final scopeId = clientScopeId ?? record?.clientScopeId;
|
|
final thumbPaths = await _getChapterThumbPaths(serverId, ratingKey, clientScopeId: scopeId);
|
|
|
|
if (thumbPaths.isEmpty) {
|
|
appLogger.d('No chapter thumbnails to delete for $ratingKey');
|
|
return;
|
|
}
|
|
|
|
final otherItems = await _database.getDownloadsByServerId(serverId);
|
|
final inUseThumbPaths = <String>{};
|
|
for (final item in otherItems) {
|
|
if (item.ratingKey == ratingKey) continue;
|
|
final itemChapterPaths = await _getChapterThumbPaths(
|
|
serverId,
|
|
item.ratingKey,
|
|
clientScopeId: item.clientScopeId,
|
|
);
|
|
inUseThumbPaths.addAll(itemChapterPaths);
|
|
}
|
|
|
|
int deletedCount = 0;
|
|
int preservedCount = 0;
|
|
|
|
for (final thumbPath in thumbPaths) {
|
|
try {
|
|
if (inUseThumbPaths.contains(thumbPath)) {
|
|
appLogger.d('Preserving chapter thumbnail (in use): $thumbPath');
|
|
preservedCount++;
|
|
continue;
|
|
}
|
|
|
|
final artworkPath = await _storageService.getArtworkPathFromThumb(serverId, thumbPath);
|
|
if (await _deleteFileIfExists(File(artworkPath), 'chapter thumbnail')) {
|
|
deletedCount++;
|
|
}
|
|
} catch (e) {
|
|
appLogger.w('Failed to delete chapter thumbnail: $thumbPath', error: e);
|
|
}
|
|
}
|
|
|
|
if (deletedCount > 0 || preservedCount > 0) {
|
|
appLogger.i('Deleted $deletedCount of ${thumbPaths.length} chapter thumbnails ($preservedCount preserved)');
|
|
}
|
|
} catch (e, stack) {
|
|
appLogger.w('Error deleting chapter thumbnails for $ratingKey', error: e, stackTrace: stack);
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteEpisodeFiles(
|
|
MediaItem episode,
|
|
ServerId serverId, {
|
|
String? clientScopeId,
|
|
bool skipStorageVideoAndParents = false,
|
|
}) async {
|
|
try {
|
|
final parentMetadata = episode.grandparentId != null
|
|
? await _lookupMetadata(serverId, episode.grandparentId!, clientScopeId: clientScopeId)
|
|
: null;
|
|
final showYear = parentMetadata?.year;
|
|
|
|
final storageDeletion = await _deleteEpisodeStorageVideo(
|
|
episode,
|
|
showYear: showYear,
|
|
skipVideo: skipStorageVideoAndParents,
|
|
);
|
|
|
|
final thumbPath = await _storageService.getEpisodeThumbnailPath(episode, showYear: showYear);
|
|
await _deleteFileIfExists(File(thumbPath), 'episode thumbnail');
|
|
|
|
final subsDir = await _storageService.getEpisodeSubtitlesDirectory(episode, showYear: showYear);
|
|
if (await subsDir.exists()) {
|
|
await subsDir.delete(recursive: true);
|
|
appLogger.i('Deleted episode subtitles: ${subsDir.path}');
|
|
}
|
|
|
|
await _deleteChapterThumbnails(serverId, episode.id, clientScopeId: clientScopeId);
|
|
|
|
if (!skipStorageVideoAndParents) {
|
|
await _cleanupEpisodeStorageParents(episode, showYear, storageDeletion);
|
|
// Safety net: verify the actual DB-recorded file is gone.
|
|
await _ensureDbFileDeleted(serverId, episode.id);
|
|
}
|
|
} catch (e, stack) {
|
|
final storageLabel = _storageService.isUsingSaf ? 'SAF ' : '';
|
|
appLogger.e('Error deleting ${storageLabel}episode files', error: e, stackTrace: stack);
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteSeasonFiles(MediaItem season, ServerId serverId, {String? clientScopeId}) async {
|
|
try {
|
|
final parentMetadata = season.parentId != null
|
|
? await _lookupMetadata(serverId, season.parentId!, clientScopeId: clientScopeId)
|
|
: null;
|
|
final showYear = parentMetadata?.year;
|
|
|
|
final episodesInSeason = await _database.getEpisodesBySeason(season.id, serverId: serverId);
|
|
|
|
final storageLabel = _storageService.isUsingSaf ? ' (SAF)' : '';
|
|
appLogger.d('Deleting ${episodesInSeason.length} episodes in season ${season.id}$storageLabel');
|
|
await _deleteEpisodesInCollection(
|
|
episodes: episodesInSeason,
|
|
serverId: serverId,
|
|
clientScopeId: clientScopeId,
|
|
parentKey: season.id,
|
|
parentTitle: season.displayTitle,
|
|
);
|
|
|
|
await _deleteSeasonStorageDirectory(season, showYear);
|
|
} catch (e, stack) {
|
|
final storageLabel = _storageService.isUsingSaf ? 'SAF ' : '';
|
|
appLogger.e('Error deleting ${storageLabel}season files', error: e, stackTrace: stack);
|
|
}
|
|
}
|
|
|
|
/// Delete episodes in a collection (season or show). In SAF mode, cleans up
|
|
/// app-private subtitle/thumbnail assets per episode — the SAF video files
|
|
/// and parent directories are wiped in one recursive call by the caller.
|
|
Future<void> _deleteEpisodesInCollection({
|
|
required List<DownloadedMediaItem> episodes,
|
|
required ServerId serverId,
|
|
String? clientScopeId,
|
|
required String parentKey,
|
|
required String parentTitle,
|
|
}) async {
|
|
final isSaf = _storageService.isUsingSaf;
|
|
for (int i = 0; i < episodes.length; i++) {
|
|
final episode = episodes[i];
|
|
final episodeGlobalKey = buildGlobalKey(ServerId(serverId), episode.ratingKey);
|
|
|
|
_emitDeletionProgress(
|
|
DeletionProgress(
|
|
globalKey: buildGlobalKey(ServerId(serverId), parentKey),
|
|
itemTitle: parentTitle,
|
|
currentItem: i + 1,
|
|
totalItems: episodes.length,
|
|
currentOperation: 'Deleting episode ${i + 1} of ${episodes.length}',
|
|
),
|
|
);
|
|
|
|
if (isSaf) {
|
|
final episodeScopeId = episode.clientScopeId ?? clientScopeId;
|
|
final episodeMetadata = await _lookupMetadata(
|
|
ServerId(serverId),
|
|
episode.ratingKey,
|
|
clientScopeId: episodeScopeId,
|
|
);
|
|
if (episodeMetadata != null) {
|
|
await _deleteEpisodeFiles(
|
|
episodeMetadata,
|
|
serverId,
|
|
clientScopeId: episodeScopeId,
|
|
skipStorageVideoAndParents: true,
|
|
);
|
|
} else {
|
|
await _deleteChapterThumbnails(ServerId(serverId), episode.ratingKey, clientScopeId: episodeScopeId);
|
|
await _deleteByFilePath(episode);
|
|
}
|
|
} else {
|
|
await _deleteChapterThumbnails(
|
|
ServerId(serverId),
|
|
episode.ratingKey,
|
|
clientScopeId: episode.clientScopeId ?? clientScopeId,
|
|
);
|
|
await _deleteByFilePath(episode);
|
|
}
|
|
|
|
await _deleteForItemByServer(
|
|
ServerId(serverId),
|
|
episode.ratingKey,
|
|
clientScopeId: episode.clientScopeId ?? clientScopeId,
|
|
);
|
|
await _deleteDownloadRowAndRelease(episodeGlobalKey);
|
|
}
|
|
}
|
|
|
|
/// Delete a single downloaded track. File deletion runs off the DB record
|
|
/// (video + .part + empty-parent cleanup via [_deleteByFilePath], which also
|
|
/// handles SAF URIs); the album-cover thumb is reference-counted because
|
|
/// every track of an album shares the same artwork blob.
|
|
Future<void> _deleteTrackByRecord(DownloadedMediaItem record) async {
|
|
final parsed = parseGlobalKey(record.globalKey);
|
|
final keepThumb =
|
|
parsed != null &&
|
|
record.thumbPath != null &&
|
|
await _isThumbPathInUseByOthers(parsed.serverId, record.thumbPath!, excludingGlobalKey: record.globalKey);
|
|
await _deleteByFilePath(record, deleteThumb: !keepThumb);
|
|
}
|
|
|
|
/// Whether any other download row on [serverId] references [thumbPath].
|
|
/// Mirrors the chapter-thumbnail in-use check: shared artwork survives
|
|
/// until the last referencing download is deleted.
|
|
Future<bool> _isThumbPathInUseByOthers(
|
|
ServerId serverId,
|
|
String thumbPath, {
|
|
required String excludingGlobalKey,
|
|
}) async {
|
|
final rows = await _database.getDownloadsByServerId(serverId);
|
|
return rows.any((row) => row.globalKey != excludingGlobalKey && row.thumbPath == thumbPath);
|
|
}
|
|
|
|
/// Delete every downloaded track of an album/artist container, mirroring
|
|
/// [_deleteEpisodesInCollection]: per-track deletion progress, file cleanup,
|
|
/// per-item server-side residue, then the DB rows.
|
|
Future<void> _deleteTracksInContainer({
|
|
required List<DownloadedMediaItem> tracks,
|
|
required ServerId serverId,
|
|
String? clientScopeId,
|
|
required String containerKey,
|
|
required String containerTitle,
|
|
}) async {
|
|
appLogger.d('Deleting ${tracks.length} tracks in container $containerKey');
|
|
for (int i = 0; i < tracks.length; i++) {
|
|
final track = tracks[i];
|
|
final trackGlobalKey = buildGlobalKey(ServerId(serverId), track.ratingKey);
|
|
|
|
_emitDeletionProgress(
|
|
DeletionProgress(
|
|
globalKey: buildGlobalKey(ServerId(serverId), containerKey),
|
|
itemTitle: containerTitle,
|
|
currentItem: i + 1,
|
|
totalItems: tracks.length,
|
|
currentOperation: 'Deleting track ${i + 1} of ${tracks.length}',
|
|
),
|
|
);
|
|
|
|
await _deleteTrackByRecord(track);
|
|
await _deleteForItemByServer(
|
|
ServerId(serverId),
|
|
track.ratingKey,
|
|
clientScopeId: track.clientScopeId ?? clientScopeId,
|
|
);
|
|
await _deleteDownloadRowAndRelease(trackGlobalKey);
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteShowFiles(MediaItem show, ServerId serverId, {String? clientScopeId}) async {
|
|
try {
|
|
final episodesInShow = await _database.getEpisodesByShow(show.id, serverId: serverId);
|
|
|
|
final storageLabel = _storageService.isUsingSaf ? ' (SAF)' : '';
|
|
appLogger.d('Deleting ${episodesInShow.length} episodes in show ${show.id}$storageLabel');
|
|
await _deleteEpisodesInCollection(
|
|
episodes: episodesInShow,
|
|
serverId: serverId,
|
|
clientScopeId: clientScopeId,
|
|
parentKey: show.id,
|
|
parentTitle: show.displayTitle,
|
|
);
|
|
|
|
await _deleteShowStorageDirectory(show);
|
|
} catch (e, stack) {
|
|
final storageLabel = _storageService.isUsingSaf ? 'SAF ' : '';
|
|
appLogger.e('Error deleting ${storageLabel}show files', error: e, stackTrace: stack);
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteMovieFiles(MediaItem movie, ServerId serverId, {String? clientScopeId}) async {
|
|
try {
|
|
await _deleteMovieStorageDirectory(movie);
|
|
|
|
await _deleteChapterThumbnails(serverId, movie.id, clientScopeId: clientScopeId);
|
|
|
|
// Safety net: verify the actual DB-recorded file is gone
|
|
await _ensureDbFileDeleted(serverId, movie.id);
|
|
} catch (e, stack) {
|
|
final storageLabel = _storageService.isUsingSaf ? 'SAF ' : '';
|
|
appLogger.e('Error deleting ${storageLabel}movie files', error: e, stackTrace: stack);
|
|
}
|
|
}
|
|
|
|
/// Delete one media directory and everything under it, on either storage backend.
|
|
/// [safComponents] and [fileDirectory] are thunks so only the branch that runs
|
|
/// resolves its path — the file-mode getters create the directory as a side effect.
|
|
Future<void> _deleteStorageDirectory({
|
|
required List<String> Function() safComponents,
|
|
required Future<Directory> Function() fileDirectory,
|
|
required String label,
|
|
}) async {
|
|
if (_storageService.isUsingSaf) {
|
|
final safBaseUri = _storageService.safBaseUri;
|
|
if (safBaseUri == null) return;
|
|
final dir = await _safStorage.getChild(safBaseUri, safComponents());
|
|
if (dir != null) {
|
|
await _deleteSafDirRecursive(dir.uri, description: '$label directory');
|
|
}
|
|
return;
|
|
}
|
|
|
|
final dir = await fileDirectory();
|
|
if (await dir.exists()) {
|
|
await dir.delete(recursive: true);
|
|
appLogger.i('Deleted $label directory: ${dir.path}');
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteMovieStorageDirectory(MediaItem movie) {
|
|
return _deleteStorageDirectory(
|
|
safComponents: () => _storageService.getMovieSafPathComponents(movie),
|
|
fileDirectory: () => _storageService.getMovieDirectory(movie),
|
|
label: 'movie',
|
|
);
|
|
}
|
|
|
|
Future<_EpisodeStorageDeletion> _deleteEpisodeStorageVideo(
|
|
MediaItem episode, {
|
|
required int? showYear,
|
|
required bool skipVideo,
|
|
}) async {
|
|
if (_storageService.isUsingSaf) {
|
|
if (skipVideo) return (seasonDirUri: null, showDirUri: null);
|
|
final safBaseUri = _storageService.safBaseUri;
|
|
if (safBaseUri == null) return (seasonDirUri: null, showDirUri: null);
|
|
|
|
final resolved = await Future.wait([
|
|
_safStorage.getChild(safBaseUri, _storageService.getEpisodeSafPathComponents(episode, showYear: showYear)),
|
|
_safStorage.getChild(safBaseUri, _storageService.getShowSafPathComponents(episode, showYear: showYear)),
|
|
]);
|
|
final seasonDirUri = resolved.first?.uri;
|
|
final showDirUri = resolved[1]?.uri;
|
|
if (seasonDirUri != null) {
|
|
final file = await _findSafFileByBaseName(seasonDirUri, _storageService.getEpisodeSafBaseName(episode));
|
|
if (file != null) {
|
|
await _tryDeleteSaf(file.uri, isDir: false, description: 'SAF episode video');
|
|
}
|
|
}
|
|
return (seasonDirUri: seasonDirUri, showDirUri: showDirUri);
|
|
}
|
|
|
|
if (!skipVideo) {
|
|
final videoPathTemplate = await _storageService.getEpisodeVideoPath(episode, 'tmp', showYear: showYear);
|
|
final videoPathWithoutExt = videoPathTemplate.substring(0, videoPathTemplate.lastIndexOf('.'));
|
|
final actualVideoFile = await _findFileWithAnyExtension(videoPathWithoutExt);
|
|
if (actualVideoFile != null) {
|
|
await _deleteFileIfExists(actualVideoFile, 'episode video');
|
|
await _deleteFileIfExists(File('${actualVideoFile.path}.part'), 'partial download');
|
|
}
|
|
}
|
|
return (seasonDirUri: null, showDirUri: null);
|
|
}
|
|
|
|
Future<void> _cleanupEpisodeStorageParents(MediaItem episode, int? showYear, _EpisodeStorageDeletion deletion) async {
|
|
if (_storageService.isUsingSaf) {
|
|
await _deleteEmptySafDirsInOrder([deletion.seasonDirUri, deletion.showDirUri]);
|
|
return;
|
|
}
|
|
await _cleanupEmptyDirectories(episode, showYear);
|
|
}
|
|
|
|
Future<void> _deleteSeasonStorageDirectory(MediaItem season, int? showYear) async {
|
|
await _deleteStorageDirectory(
|
|
safComponents: () => _storageService.getSeasonSafPathComponents(season, showYear: showYear),
|
|
fileDirectory: () => _storageService.getSeasonDirectory(season, showYear: showYear),
|
|
label: 'season',
|
|
);
|
|
|
|
// Drop the parent show directory too if the deleted season left it empty.
|
|
if (_storageService.isUsingSaf) {
|
|
final safBaseUri = _storageService.safBaseUri;
|
|
if (safBaseUri == null) return;
|
|
final showDir = await _safStorage.getChild(
|
|
safBaseUri,
|
|
_storageService.getShowSafPathComponents(season, showYear: showYear),
|
|
);
|
|
await _deleteEmptySafDirsInOrder([showDir?.uri]);
|
|
return;
|
|
}
|
|
await _cleanupShowDirectory(season, showYear);
|
|
}
|
|
|
|
Future<void> _deleteShowStorageDirectory(MediaItem show) {
|
|
return _deleteStorageDirectory(
|
|
safComponents: () => _storageService.getShowSafPathComponents(show),
|
|
fileDirectory: () => _storageService.getShowDirectory(show),
|
|
label: 'show',
|
|
);
|
|
}
|
|
|
|
/// Safety net: after metadata-based deletion, verify the actual DB-recorded
|
|
/// video file is gone. If not, delete it and clean up parent directories.
|
|
Future<void> _ensureDbFileDeleted(ServerId serverId, String ratingKey) async {
|
|
try {
|
|
final globalKey = buildGlobalKey(ServerId(serverId), ratingKey);
|
|
final record = await _database.getDownloadedMedia(globalKey);
|
|
if (record?.videoFilePath == null) return;
|
|
|
|
final storedPath = record!.videoFilePath!;
|
|
if (_storageService.isSafUri(storedPath)) {
|
|
// SAF mode: parent cleanup is handled by the type-specific SAF helpers —
|
|
// here we only verify the video URI itself is gone.
|
|
if (await _safStorage.exists(storedPath, isDir: false)) {
|
|
appLogger.w('Safety net: SAF video still exists after metadata deletion, deleting: $storedPath');
|
|
await _safStorage.delete(storedPath, isDir: false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
final videoPath = await _storageService.ensureAbsolutePath(storedPath);
|
|
final videoFile = File(videoPath);
|
|
if (await videoFile.exists()) {
|
|
appLogger.w('Safety net: video still exists after metadata deletion, deleting: $videoPath');
|
|
}
|
|
await _deleteFilesystemVideoAssets(videoPath);
|
|
} catch (e, stack) {
|
|
appLogger.w('Safety net deletion failed', error: e, stackTrace: stack);
|
|
}
|
|
}
|
|
|
|
/// Walk up from a directory toward the downloads root, removing empty dirs.
|
|
Future<void> _cleanupEmptyParentDirectories(Directory dir) async {
|
|
try {
|
|
final downloadsDir = await _storageService.getDownloadsDirectory();
|
|
var current = dir;
|
|
while (current.path != downloadsDir.path && current.path.startsWith(downloadsDir.path)) {
|
|
if (!await current.exists()) {
|
|
current = current.parent;
|
|
continue;
|
|
}
|
|
final contents = await current.list().toList();
|
|
if (contents.isEmpty) {
|
|
await current.delete();
|
|
appLogger.i('Cleaned up empty directory: ${current.path}');
|
|
current = current.parent;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
appLogger.w('Error cleaning up parent directories', error: e);
|
|
}
|
|
}
|
|
|
|
/// Clean up empty directories after deleting episode (file mode only — the
|
|
/// SAF deleters call [_deleteEmptySafDirsInOrder] directly).
|
|
Future<void> _cleanupEmptyDirectories(MediaItem episode, int? showYear) async {
|
|
if (_storageService.isUsingSaf) return;
|
|
final seasonDir = await _storageService.getSeasonDirectory(episode, showYear: showYear);
|
|
|
|
if (await seasonDir.exists()) {
|
|
final contents = await seasonDir.list().toList();
|
|
final hasVideos = contents.any(
|
|
(e) => _videoExtensions.any((ext) => e.path.endsWith(ext)) || e.path.contains('_subs'),
|
|
);
|
|
|
|
if (!hasVideos) {
|
|
if (!await _isSeasonArtworkInUse(episode, showYear)) {
|
|
await seasonDir.delete(recursive: true);
|
|
appLogger.i('Deleted empty season directory: ${seasonDir.path}');
|
|
await _cleanupShowDirectory(episode, showYear);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Clean up show directory if empty (file mode only).
|
|
Future<void> _cleanupShowDirectory(MediaItem metadata, int? showYear) async {
|
|
if (_storageService.isUsingSaf) return;
|
|
final showDir = await _storageService.getShowDirectory(metadata, showYear: showYear);
|
|
|
|
if (await showDir.exists()) {
|
|
final contents = await showDir.list().toList();
|
|
final hasSeasons = contents.any((e) => e is Directory && e.path.contains('Season '));
|
|
|
|
if (!hasSeasons) {
|
|
if (!await _isShowArtworkInUse(metadata, showYear)) {
|
|
await showDir.delete(recursive: true);
|
|
appLogger.i('Deleted empty show directory: ${showDir.path}');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<bool> _isSeasonArtworkInUse(MediaItem episode, int? _) async {
|
|
final seasonKey = episode.parentId;
|
|
if (seasonKey == null) return false;
|
|
|
|
final otherEpisodes = await _database.getEpisodesBySeason(seasonKey);
|
|
|
|
return otherEpisodes.any((e) => e.globalKey != episode.globalKey);
|
|
}
|
|
|
|
Future<bool> _isShowArtworkInUse(MediaItem metadata, int? _) async {
|
|
final showKey = metadata.grandparentId ?? metadata.parentId ?? metadata.id;
|
|
|
|
// Use targeted query instead of full table scan
|
|
final showEpisodes = await _database.getEpisodesByShow(showKey);
|
|
|
|
return showEpisodes.any((item) => item.globalKey != metadata.globalKey);
|
|
}
|
|
|
|
Future<File?> _findFileWithAnyExtension(String pathWithoutExt) async {
|
|
final dir = Directory(path.dirname(pathWithoutExt));
|
|
final baseName = path.basename(pathWithoutExt);
|
|
|
|
if (!await dir.exists()) return null;
|
|
|
|
try {
|
|
final files = await dir
|
|
.list()
|
|
.where(
|
|
(e) =>
|
|
e is File &&
|
|
path.basenameWithoutExtension(e.path) == baseName &&
|
|
_videoExtensions.contains(path.extension(e.path).toLowerCase()),
|
|
)
|
|
.toList();
|
|
|
|
return files.isNotEmpty ? files.first as File : null;
|
|
} catch (e) {
|
|
appLogger.w('Error finding file: $pathWithoutExt', error: e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Delete a downloaded file and the sidecars derived from its path. Sidecar
|
|
/// cleanup is independent of the primary file because interrupted or manual
|
|
/// video removal must not strand `.part` files or subtitle directories.
|
|
Future<void> _deleteFilesystemVideoAssets(String videoPath) async {
|
|
final videoFile = File(videoPath);
|
|
await _deleteFileIfExists(videoFile, 'video file');
|
|
await _deleteFileIfExists(File('$videoPath.part'), 'partial download');
|
|
|
|
final subsPath = videoPath.replaceAll(RegExp(r'\.[^.]+$'), '_subs');
|
|
final subsDir = Directory(subsPath);
|
|
if (await subsDir.exists()) {
|
|
await subsDir.delete(recursive: true);
|
|
appLogger.i('Deleted subtitles: $subsPath');
|
|
}
|
|
|
|
await _cleanupEmptyParentDirectories(videoFile.parent);
|
|
}
|
|
|
|
/// Fallback deletion using file paths from database.
|
|
///
|
|
/// [deleteThumb] lets callers preserve a shared artwork blob — album-cover
|
|
/// thumbs are deduped by path hash across every track of the album, so a
|
|
/// single-track delete must keep the file while sibling rows reference it.
|
|
Future<void> _deleteByFilePath(DownloadedMediaItem record, {bool deleteThumb = true}) async {
|
|
try {
|
|
if (record.videoFilePath != null && _storageService.isSafUri(record.videoFilePath!)) {
|
|
// Metadata is gone by the time this fallback runs, so parent-dir cleanup
|
|
// is not attempted here — SAF URIs don't expose a parent reliably.
|
|
await _tryDeleteSaf(record.videoFilePath!, isDir: false, description: 'SAF video file');
|
|
} else if (record.videoFilePath != null) {
|
|
final videoPath = await _storageService.ensureAbsolutePath(record.videoFilePath!);
|
|
await _deleteFilesystemVideoAssets(videoPath);
|
|
}
|
|
|
|
// thumbPath is a server-side API path (Plex /library/metadata/.../thumb,
|
|
// Jellyfin /Items/.../Images/Primary), not a local file path —
|
|
// resolve it via getArtworkPathFromThumb
|
|
if (deleteThumb && record.thumbPath != null) {
|
|
final parsed = parseGlobalKey(record.globalKey);
|
|
if (parsed != null) {
|
|
final thumbPath = await _storageService.getArtworkPathFromThumb(parsed.serverId, record.thumbPath!);
|
|
await _deleteFileIfExists(File(thumbPath), 'thumbnail');
|
|
}
|
|
}
|
|
} catch (e, stack) {
|
|
appLogger.e('Error in fallback deletion', error: e, stackTrace: stack);
|
|
}
|
|
}
|
|
|
|
Future<List<DownloadedMediaItem>> getAllDownloads() {
|
|
return _database.select(_database.downloadedMedia).get();
|
|
}
|
|
|
|
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) {
|
|
return _database.getDownloadedMedia(globalKey);
|
|
}
|
|
|
|
/// Save metadata for a media item (show, season, movie, or episode)
|
|
/// Used to persist parent metadata (shows/seasons) for offline display.
|
|
///
|
|
/// All backends have read-path cache-through, so the work is just to
|
|
/// hit `client.fetchItem` (idempotent) and pin the resulting row.
|
|
Future<void> saveMetadata(MediaItem metadata, MediaServerClient client) async {
|
|
if (metadata.serverId == null) {
|
|
appLogger.w('Cannot save metadata without serverId');
|
|
return;
|
|
}
|
|
await _pinMetadataForOffline(client, metadata);
|
|
}
|
|
|
|
void dispose() {
|
|
_disposed = true;
|
|
for (final timer in _progressDebounceTimers.values) {
|
|
timer.cancel();
|
|
}
|
|
_progressDebounceTimers.clear();
|
|
for (final timer in _autoRetryTimers.values) {
|
|
timer.cancel();
|
|
}
|
|
_autoRetryTimers.clear();
|
|
_pendingDownloadContext.clear();
|
|
_completingKeys.clear();
|
|
_pausingKeys.clear();
|
|
_cancellingKeys.clear();
|
|
_progressController.close();
|
|
_deletionProgressController.close();
|
|
}
|
|
}
|
|
|
|
String? downloadExtensionFromUrl(String url) {
|
|
final uri = Uri.tryParse(url);
|
|
if (uri == null) return null;
|
|
final lastSegment = uri.pathSegments.isEmpty ? '' : uri.pathSegments.last;
|
|
final lastDot = lastSegment.lastIndexOf('.');
|
|
if (lastDot != -1 && lastDot < lastSegment.length - 1) {
|
|
return _safeDownloadExtension(lastSegment.substring(lastDot + 1));
|
|
}
|
|
for (final entry in uri.queryParameters.entries) {
|
|
if (entry.key.toLowerCase() == 'container') {
|
|
return _safeDownloadExtension(entry.value);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String? _safeDownloadExtension(String raw) {
|
|
final ext = raw.split(RegExp(r'[,|]')).first.trim().replaceFirst(RegExp(r'^\.+'), '').toLowerCase();
|
|
if (ext.isEmpty || !RegExp(r'^[a-z0-9][a-z0-9._-]{0,39}$').hasMatch(ext)) return null;
|
|
return ext;
|
|
}
|