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
This commit is contained in:
edde746
2026-08-06 03:45:08 +02:00
parent 9d51a040c3
commit 1b6a811c07
52 changed files with 2278 additions and 259 deletions
+89 -12
View File
@@ -1565,15 +1565,75 @@ class Translations$mediaMenu$en {
/// en: 'File Info'
String get fileInfo => 'File Info';
/// en: 'Delete from server'
String get deleteFromServer => 'Delete from server';
/// en: 'Delete episode from server'
String get deleteEpisodeFromServer => 'Delete episode from server';
/// en: 'Delete this media and its files from your server?'
String get confirmDelete => 'Delete this media and its files from your server?';
/// en: 'Delete season from server'
String get deleteSeasonFromServer => 'Delete season from server';
/// en: 'Delete show from server'
String get deleteShowFromServer => 'Delete show from server';
/// en: 'Delete movie from server'
String get deleteMovieFromServer => 'Delete movie from server';
/// en: 'Delete this episode?'
String get deleteEpisodeTitle => 'Delete this episode?';
/// en: 'Delete this season?'
String get deleteSeasonTitle => 'Delete this season?';
/// en: 'Delete this show?'
String get deleteShowTitle => 'Delete this show?';
/// en: 'Delete this movie?'
String get deleteMovieTitle => 'Delete this movie?';
/// en: 'Delete episode'
String get deleteEpisodeConfirm => 'Delete episode';
/// en: 'Delete season'
String get deleteSeasonConfirm => 'Delete season';
/// en: 'Delete show'
String get deleteShowConfirm => 'Delete show';
/// en: 'Delete movie'
String get deleteMovieConfirm => 'Delete movie';
/// en: 'Delete anyway'
String get deleteAnyway => 'Delete anyway';
/// en: 'Permanently delete ${title} from your server?'
String confirmDeleteTarget({required Object title}) => 'Permanently delete ${title} from your server?';
/// en: 'This includes all episodes and their files.'
String get deleteMultipleWarning => 'This includes all episodes and their files.';
/// en: '(one) {This deletes all ${n} episode in it, and its file.} (other) {This deletes all ${n} episodes in it, and their files.}'
String deleteEpisodeCountWarning({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n,
one: 'This deletes all ${n} episode in it, and its file.',
other: 'This deletes all ${n} episodes in it, and their files.',
);
/// en: '(one) {This item is stored as ${n} file, which will be deleted.} (other) {This item is stored across ${n} files, and all of them will be deleted.}'
String deleteMultiPartWarning({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n,
one: 'This item is stored as ${n} file, which will be deleted.',
other: 'This item is stored across ${n} files, and all of them will be deleted.',
);
/// en: '(one) {${n} other episode is stored in the same file and will be deleted too:} (other) {${n} other episodes are stored in the same file and will be deleted too:}'
String deleteSharedFileHeading({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n,
one: '${n} other episode is stored in the same file and will be deleted too:',
other: '${n} other episodes are stored in the same file and will be deleted too:',
);
/// en: 'Plezy could not check which files this will remove, so it may delete more than the item named above. Cancel and try again, or delete anyway.'
String get deleteScopeUnverifiedProbeFailed => 'Plezy could not check which files this will remove, so it may delete more than the item named above. Cancel and try again, or delete anyway.';
/// en: 'Your server did not provide file details for this item, so Plezy cannot check which files this will remove. It may delete more than the item named above.'
String get deleteScopeUnverifiedNoFileInfo => 'Your server did not provide file details for this item, so Plezy cannot check which files this will remove. It may delete more than the item named above.';
/// en: 'Media item deleted successfully'
String get mediaDeletedSuccessfully => 'Media item deleted successfully';
@@ -6539,9 +6599,28 @@ extension on Translations {
'mediaMenu.shufflePlay' => 'Shuffle Play',
'mediaMenu.shuffleNotAvailableOffline' => 'Shuffle not available offline',
'mediaMenu.fileInfo' => 'File Info',
'mediaMenu.deleteFromServer' => 'Delete from server',
'mediaMenu.confirmDelete' => 'Delete this media and its files from your server?',
'mediaMenu.deleteEpisodeFromServer' => 'Delete episode from server',
'mediaMenu.deleteSeasonFromServer' => 'Delete season from server',
'mediaMenu.deleteShowFromServer' => 'Delete show from server',
'mediaMenu.deleteMovieFromServer' => 'Delete movie from server',
'mediaMenu.deleteEpisodeTitle' => 'Delete this episode?',
'mediaMenu.deleteSeasonTitle' => 'Delete this season?',
'mediaMenu.deleteShowTitle' => 'Delete this show?',
'mediaMenu.deleteMovieTitle' => 'Delete this movie?',
'mediaMenu.deleteEpisodeConfirm' => 'Delete episode',
'mediaMenu.deleteSeasonConfirm' => 'Delete season',
'mediaMenu.deleteShowConfirm' => 'Delete show',
'mediaMenu.deleteMovieConfirm' => 'Delete movie',
'mediaMenu.deleteAnyway' => 'Delete anyway',
'mediaMenu.confirmDeleteTarget' => ({required Object title}) => 'Permanently delete ${title} from your server?',
'mediaMenu.deleteMultipleWarning' => 'This includes all episodes and their files.',
'mediaMenu.deleteEpisodeCountWarning' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'This deletes all ${n} episode in it, and its file.', other: 'This deletes all ${n} episodes in it, and their files.', ),
_ => null,
} ?? switch (path) {
'mediaMenu.deleteMultiPartWarning' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'This item is stored as ${n} file, which will be deleted.', other: 'This item is stored across ${n} files, and all of them will be deleted.', ),
'mediaMenu.deleteSharedFileHeading' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} other episode is stored in the same file and will be deleted too:', other: '${n} other episodes are stored in the same file and will be deleted too:', ),
'mediaMenu.deleteScopeUnverifiedProbeFailed' => 'Plezy could not check which files this will remove, so it may delete more than the item named above. Cancel and try again, or delete anyway.',
'mediaMenu.deleteScopeUnverifiedNoFileInfo' => 'Your server did not provide file details for this item, so Plezy cannot check which files this will remove. It may delete more than the item named above.',
'mediaMenu.mediaDeletedSuccessfully' => 'Media item deleted successfully',
'mediaMenu.mediaFailedToDelete' => 'Failed to delete media item',
'mediaMenu.rate' => 'Rate',
@@ -6555,8 +6634,6 @@ extension on Translations {
'rateSheet.notAvailable' => 'No match found',
'rateSheet.noConnectedServices' => 'Connect a service in Settings to rate there.',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, movie',
_ => null,
} ?? switch (path) {
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, TV show',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
'accessibility.mediaCardSeason' => ({required Object title, required Object seasonInfo}) => '${title}, ${seasonInfo}',
@@ -7052,6 +7129,8 @@ extension on Translations {
'explore.badge.nextEpisodeIn' => ({required Object episode, required Object duration}) => 'Ep ${episode} in ${duration}',
'explore.badge.nextAiringIn' => ({required Object duration}) => 'Next in ${duration}',
'explore.badge.episodesShort' => ({required Object n}) => '${n} eps',
_ => null,
} ?? switch (path) {
'explore.badge.minutesPerEpisode' => ({required Object n}) => '${n} min/ep',
'explore.badge.adult' => '18+',
'explore.stats.listed' => ({required Object n}) => '${n} listed',
@@ -7069,8 +7148,6 @@ extension on Translations {
'explore.stats.completed' => ({required Object n}) => '${n} completed',
'explore.stats.onHold' => ({required Object n}) => '${n} on hold',
'explore.stats.dropped' => ({required Object n}) => '${n} dropped',
_ => null,
} ?? switch (path) {
'explore.season.winter' => 'Winter',
'explore.season.spring' => 'Spring',
'explore.season.summer' => 'Summer',
@@ -7566,6 +7643,8 @@ extension on Translations {
'performanceOverlay.maxCll' => 'MaxCLL',
'performanceOverlay.maxFall' => 'MaxFALL',
'performanceOverlay.cacheUsed' => 'Cache Used',
_ => null,
} ?? switch (path) {
'performanceOverlay.cacheLimit' => 'Cache Limit',
'performanceOverlay.speed' => 'Speed',
'performanceOverlay.player' => 'Player',
@@ -7583,8 +7662,6 @@ extension on Translations {
'externalPlayer.playerCommand' => 'Command',
'externalPlayer.playerPackage' => 'Package Name',
'externalPlayer.playerUrlScheme' => 'URL Scheme',
_ => null,
} ?? switch (path) {
'externalPlayer.off' => 'Off',
'externalPlayer.launchFailed' => 'Failed to open external player',
'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} is not installed',