Files
plezy/lib/utils/delete_impact.dart
T
edde746 1b6a811c07 fix(delete): name the delete target and verify what its files back
"Delete from server" read identically for an episode, a season and a
whole show: same menu label, same dialog title, same red button, and a
body that named nothing. The menu header did not disambiguate either,
because MediaItem.displayTitle collapses an episode to its show name.
A reporter deleted a whole series from the detail hero's ⋮ believing it
acted on the episode he had highlighted, and the confirmation gave him
nothing to catch it with. Every one of those strings now names the kind,
and the body names the exact item — show, season and episode number, and
episode title.

Deleting a single item also destroyed files the confirmation never
mentioned: a Plex multi-episode file (S01E01-E03.mkv) takes its other
episodes with it, and a split item takes every part. The dialog now
reports that up front and, on success, emits deletion events for the
siblings the server destroyed so their rows do not linger.

The scope behind that warning is only asserted when it is established.
MediaItem.allPartFiles drops parts with no path, so a non-empty set
proves nothing about the ones it filtered out; a version is trusted only
when every part reports a file. A browse row that omits paths is missing
evidence rather than proof of a distinct file, so both the target and
each candidate sibling fall back to the detail endpoint before any
conclusion — otherwise a thin row, including the file-less part
PlexMappers fabricates for an empty payload, would look like a server
that withholds paths. When the answer cannot be established the dialog
says so in an error-tinted block and its button reads "Delete anyway",
separating a transient probe failure from a server that never sends
paths. It deliberately does not refuse: Plex withholds paths from
restricted users the server itself authorizes to delete, so failing
closed would take the feature away from them permanently.

Probing a season stays bounded in both directions. Siblings resolve one
at a time, so a season of thin rows cannot fan out a detail request per
episode, and expiry cancels the walk rather than merely abandoning it —
`Future.timeout` completes the future the caller awaits but leaves the
work behind it running, which would resume on the next sibling once the
outstanding request answered. A cooperative flag is checked before each
lookup, so at most the one already in flight outlives the deadline; the
neutral client exposes no abort handle for item lookups, so that one
cannot be recalled.

The spinner covering the probe was only barrierDismissible, which does
not stop system back. Back dismissed it and the cleanup pop then closed
the screen underneath, dropping the user out of the detail page
mid-flow. It now traps back, matching the non-dismissible contract its
own doc claims, which also repairs the log uploader and the file-info
sheet.

Coverage splits by what each layer owns. The dialog, its copy and the
DELETE wiring are backend-neutral and stay in the menu widget tests.
Plex — the backend multi-episode files actually come from — gets the
resolver over a real PlexClient and a mocked transport: a row with no
media at all, scope recovered from /library/metadata/{id}, siblings and
paths from /children, a Part that names no file, a sibling whose path
never resolves, the request count a sixty-episode thin season may cost,
and the rating key the DELETE carries. Those are plain async tests
because the Plex metadata cache is a real database whose I/O the widget
tester's fake clock never drives. Deadline behaviour needs the opposite,
so it is pinned separately under fakeAsync against a gated fake client,
with no wall-clock waiting anywhere.

close #1781
2026-08-06 03:45:08 +02:00

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,
);