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 files; /// Other library items backed by one of [files], which the server will /// destroy alongside the target. final List 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? completePartFiles(List 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 = {}; 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 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 _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 = []; 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?, DeleteImpactUnverifiedReason?)> _completeFilesFor( MediaItem item, MediaServerClient client, ) async { final inline = completePartFiles(item.mediaVersions ?? const []); 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 []); return resolved != null ? (resolved, null) : (null, DeleteImpactUnverifiedReason.noFileInfo); } DeleteImpact _verified(Set files, List shared) => DeleteImpact._( scope: files.length > 1 || shared.isNotEmpty ? DeleteImpactScope.broad : DeleteImpactScope.exclusive, files: files, sharedWith: shared, );