201 lines
8.5 KiB
Dart
201 lines
8.5 KiB
Dart
import 'dart:async';
|
|||
|
|
|
||
|
|
import '../media/media_item.dart';
|
||
|
|
import '../media/media_kind.dart';
|
||
|
|
import '../media/media_server_client.dart';
|
||
|
|
import '../media/media_version.dart';
|
||
|
|
import 'app_logger.dart';
|
||
|
|
import 'media_server_timeouts.dart';
|
||
|
|
|
||
|
|
/// How much a server-side delete will actually destroy.
|
||
|
|
enum DeleteImpactScope {
|
||
|
|
/// Verified: the delete removes exactly one file and no other library item
|
||
|
|
/// is backed by it.
|
||
|
|
exclusive,
|
||
|
|
|
||
|
|
/// Verified: the delete removes more than one file, or files that also back
|
||
|
|
/// other items (a Plex `S01E01-E03.mkv` multi-episode file).
|
||
|
|
broad,
|
||
|
|
|
||
|
|
/// Not established. The confirmation must not claim a scope.
|
||
|
|
unverified,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Why a scope could not be established, which decides whether retrying helps.
|
||
|
|
enum DeleteImpactUnverifiedReason {
|
||
|
|
/// A metadata request failed or timed out. Transient; retrying may work.
|
||
|
|
probeFailed,
|
||
|
|
|
||
|
|
/// The server answered but withheld file paths — Plex hides them from
|
||
|
|
/// restricted users. Retrying will never help.
|
||
|
|
noFileInfo,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The verified blast radius of deleting one item.
|
||
|
|
class DeleteImpact {
|
||
|
|
final DeleteImpactScope scope;
|
||
|
|
|
||
|
|
/// Unique server-side paths this delete removes. Empty when [scope] is
|
||
|
|
/// [DeleteImpactScope.unverified].
|
||
|
|
final Set<String> files;
|
||
|
|
|
||
|
|
/// Other library items backed by one of [files], which the server will
|
||
|
|
/// destroy alongside the target.
|
||
|
|
final List<MediaItem> sharedWith;
|
||
|
|
|
||
|
|
final DeleteImpactUnverifiedReason? reason;
|
||
|
|
|
||
|
|
const DeleteImpact._({required this.scope, required this.files, required this.sharedWith, this.reason});
|
||
|
|
|
||
|
|
const DeleteImpact._unverified(DeleteImpactUnverifiedReason reason)
|
||
|
|
: this._(scope: DeleteImpactScope.unverified, files: const {}, sharedWith: const [], reason: reason);
|
||
|
|
|
||
|
|
bool get isUnverified => scope == DeleteImpactScope.unverified;
|
||
|
|
bool get isBroad => scope == DeleteImpactScope.broad;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Every distinct file backing [versions], or null when the list cannot be
|
||
|
|
/// trusted to be complete.
|
||
|
|
///
|
||
|
|
/// Deliberately NOT [MediaItem.allPartFiles]: that getter drops parts whose
|
||
|
|
/// `file` is null, so a three-part version exposing one path yields a
|
||
|
|
/// one-entry set that silently understates the delete. Anything a
|
||
|
|
/// confirmation asserts must survive that case, so a single path-less part
|
||
|
|
/// invalidates the whole answer. `null` versions, an empty version list, and a
|
||
|
|
/// version whose parts were synthesized by `PlexMappers.mediaVersion` (which
|
||
|
|
/// fabricates one file-less part when the payload carried none) all fail here,
|
||
|
|
/// which is the intent.
|
||
|
|
Set<String>? completePartFiles(List<MediaVersion> versions) {
|
||
|
|
// No early return for an empty list: the `sawPart` gate below already
|
||
|
|
// rejects it, and every other "nothing to trust" shape with it.
|
||
|
|
final files = <String>{};
|
||
|
|
var sawPart = false;
|
||
|
|
for (final version in versions) {
|
||
|
|
for (final part in version.parts) {
|
||
|
|
sawPart = true;
|
||
|
|
final file = part.file;
|
||
|
|
if (file == null || file.isEmpty) return null;
|
||
|
|
files.add(file);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return sawPart ? files : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Cooperative stop signal. `Future.timeout` completes the future the caller
|
||
|
|
/// awaits but leaves the work behind it running, so the probe has to be told
|
||
|
|
/// to stand down explicitly.
|
||
|
|
class _ProbeCancellation {
|
||
|
|
bool isCancelled = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Establish what deleting [item] will destroy, before asking the user.
|
||
|
|
///
|
||
|
|
/// Returns [DeleteImpactScope.unverified] rather than guessing whenever any
|
||
|
|
/// input is missing. Callers must surface that as an explicit danger, never as
|
||
|
|
/// a normal confirmation: a silent fall-through would let a dialog assert
|
||
|
|
/// single-file scope on unknown data, which is the failure mode behind #1781.
|
||
|
|
///
|
||
|
|
/// Bounded by [timeout] because it runs behind a modal spinner. Expiry also
|
||
|
|
/// cancels the probe, so a season of thin rows cannot keep walking itself
|
||
|
|
/// after the confirmation has already given up on it. The request already in
|
||
|
|
/// flight cannot be recalled — the neutral client exposes no abort handle for
|
||
|
|
/// item lookups — so exactly one may outlive the deadline, and no further one
|
||
|
|
/// starts.
|
||
|
|
Future<DeleteImpact> resolveDeleteImpact({
|
||
|
|
required MediaItem item,
|
||
|
|
required MediaServerClient client,
|
||
|
|
Duration timeout = MediaServerTimeouts.deleteImpactProbe,
|
||
|
|
}) async {
|
||
|
|
final cancellation = _ProbeCancellation();
|
||
|
|
try {
|
||
|
|
return await _resolve(item, client, cancellation).timeout(
|
||
|
|
timeout,
|
||
|
|
onTimeout: () {
|
||
|
|
cancellation.isCancelled = true;
|
||
|
|
appLogger.w('Delete impact probe timed out for ${item.backend.id} item ${item.id}');
|
||
|
|
return const DeleteImpact._unverified(DeleteImpactUnverifiedReason.probeFailed);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
} catch (e, st) {
|
||
|
|
cancellation.isCancelled = true;
|
||
|
|
appLogger.w('Delete impact probe failed for ${item.backend.id} item ${item.id}', error: e, stackTrace: st);
|
||
|
|
return const DeleteImpact._unverified(DeleteImpactUnverifiedReason.probeFailed);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The value every abandoned path returns. Nothing reads it — the caller's
|
||
|
|
/// future has already completed — it only has to stop the walk.
|
||
|
|
const _abandoned = DeleteImpact._unverified(DeleteImpactUnverifiedReason.probeFailed);
|
||
|
|
|
||
|
|
Future<DeleteImpact> _resolve(MediaItem item, MediaServerClient client, _ProbeCancellation cancellation) async {
|
||
|
|
final own = await _completeFilesFor(item, client);
|
||
|
|
// Cancelled while the item's own lookup was outstanding: stop before asking
|
||
|
|
// the server for the season's children.
|
||
|
|
if (cancellation.isCancelled) return _abandoned;
|
||
|
|
final ownFiles = own.$1;
|
||
|
|
if (ownFiles == null) return DeleteImpact._unverified(own.$2!);
|
||
|
|
|
||
|
|
// Only an episode can be one of several items sharing a file. A movie's
|
||
|
|
// blast radius is its own part list.
|
||
|
|
final parentId = item.kind == MediaKind.episode ? item.parentId : null;
|
||
|
|
if (parentId == null) return _verified(ownFiles, const []);
|
||
|
|
|
||
|
|
final siblings = (await client.fetchChildren(parentId)).where((sibling) => sibling.id != item.id).toList();
|
||
|
|
|
||
|
|
// Resolved one sibling at a time, bailing on the first whose paths stay
|
||
|
|
// unknown. Both backends normally carry paths on browse rows (Plex
|
||
|
|
// `/children` includes `Part.file`; Jellyfin episode rows request
|
||
|
|
// `MediaSources`), so a detail refetch here is the degraded path — but on a
|
||
|
|
// thin season it is the rule, and `Future.wait` would then fan out one
|
||
|
|
// request per episode. Rows that already carry paths cost no request at all.
|
||
|
|
final shared = <MediaItem>[];
|
||
|
|
for (final sibling in siblings) {
|
||
|
|
// Re-checked every iteration, because the deadline may have expired while
|
||
|
|
// the previous sibling's lookup was outstanding. This is the gate that
|
||
|
|
// keeps an abandoned probe from walking the rest of the season; a check
|
||
|
|
// after the await would only skip discarded bookkeeping.
|
||
|
|
if (cancellation.isCancelled) return _abandoned;
|
||
|
|
final (files, reason) = await _completeFilesFor(sibling, client);
|
||
|
|
// A sibling whose paths stayed unknown could be backed by this very file.
|
||
|
|
// Absence of evidence is not evidence of absence.
|
||
|
|
if (files == null) return DeleteImpact._unverified(reason!);
|
||
|
|
if (files.intersection(ownFiles).isNotEmpty) shared.add(sibling);
|
||
|
|
}
|
||
|
|
|
||
|
|
return _verified(ownFiles, shared);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Every file backing [item], or the reason the list could not be trusted.
|
||
|
|
///
|
||
|
|
/// Deliberately not `resolveMediaVersions`: that helper returns inline
|
||
|
|
/// versions without ever consulting the detail endpoint, so a thin browse row
|
||
|
|
/// — including the single file-less part `PlexMappers.mediaVersion`
|
||
|
|
/// fabricates when a payload carried none — would be mistaken for a server
|
||
|
|
/// that withholds paths. It also flattens fetch failures into an empty list,
|
||
|
|
/// erasing the transient-vs-permanent distinction the confirmation copy needs.
|
||
|
|
Future<(Set<String>?, DeleteImpactUnverifiedReason?)> _completeFilesFor(
|
||
|
|
MediaItem item,
|
||
|
|
MediaServerClient client,
|
||
|
|
) async {
|
||
|
|
final inline = completePartFiles(item.mediaVersions ?? const <MediaVersion>[]);
|
||
|
|
if (inline != null) return (inline, null);
|
||
|
|
|
||
|
|
final MediaItem? full;
|
||
|
|
try {
|
||
|
|
full = await client.fetchItem(item.id);
|
||
|
|
} catch (e, st) {
|
||
|
|
appLogger.w('Delete impact: failed to resolve files for item ${item.id}', error: e, stackTrace: st);
|
||
|
|
return (null, DeleteImpactUnverifiedReason.probeFailed);
|
||
|
|
}
|
||
|
|
if (full == null) return (null, DeleteImpactUnverifiedReason.probeFailed);
|
||
|
|
|
||
|
|
final resolved = completePartFiles(full.mediaVersions ?? const <MediaVersion>[]);
|
||
|
|
return resolved != null ? (resolved, null) : (null, DeleteImpactUnverifiedReason.noFileInfo);
|
||
|
|
}
|
||
|
|
|
||
|
|
DeleteImpact _verified(Set<String> files, List<MediaItem> shared) => DeleteImpact._(
|
||
|
|
scope: files.length > 1 || shared.isNotEmpty ? DeleteImpactScope.broad : DeleteImpactScope.exclusive,
|
||
|
|
files: files,
|
||
|
|
sharedWith: shared,
|
||
|
|
);
|