using AIStudio.Chat; using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Tools.Media; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Rust; namespace AIStudio.Tools.Services; /// /// Coordinates serialized visible media imports and independent voice transcriptions. /// public sealed class MediaTranscriptionService(RustService rustService, SettingsManager settingsManager, ILogger logger) : IDisposable { private const string NORMALIZED_OUTPUT_EXTENSION = ".webm"; private const string NORMALIZED_OUTPUT_FORMAT = "webm"; private const string NORMALIZED_OUTPUT_CODEC = "opus"; private static readonly byte[] WEBM_EBML_SIGNATURE = [0x1A, 0x45, 0xDF, 0xA3]; /// Serializes attachment and file-content imports. private readonly SemaphoreSlim importQueue = new(1, 1); /// Protects operation ownership and owner-specific import state. private readonly Lock stateLock = new(); /// All operations retained so disposal can cancel voice and import work. private readonly HashSet operations = []; /// The active or queued import operation for each owner. private readonly Dictionary currentImports = []; /// The latest active or unacknowledged terminal state for each owner. private readonly Dictionary snapshots = []; /// Successful results waiting for their concrete UI target. private readonly Dictionary pendingDeliveries = []; /// Terminal notifications waiting for their owner surface to be displayed. private readonly Dictionary outcomes = []; /// Owners whose complete file batches are managed by this service. private readonly HashSet activeBatches = []; /// Batch-level cancellation keeps Stop effective between two files. private readonly Dictionary batchCancellations = []; /// Prevents new work after disposal. private bool disposed; /// Raised only with the owner whose copied state changed. public event Action? StateChanged; /// Gets whether one owner has queued, running, or canceling media work. public bool IsBusy(MediaImportOwner owner) { lock (this.stateLock) return this.activeBatches.Contains(owner); } /// Gets the last retained state for one owner. public MediaImportSnapshot? GetSnapshot(MediaImportOwner owner) { lock (this.stateLock) return this.snapshots.GetValueOrDefault(owner); } /// Gets copied retained snapshots for navigation indicators. public IReadOnlyCollection GetSnapshots() { lock (this.stateLock) return [.. this.snapshots.Values]; } /// Gets copied results that have not yet been applied by one target. public MediaImportDelivery? GetPendingDelivery(MediaImportTarget target) { lock (this.stateLock) { if (!this.pendingDeliveries.TryGetValue(target, out var pending)) return null; return new() { Target = target, Attachments = [.. pending.Attachments], Text = pending.Text, }; } } /// Removes exactly the results that one target applied successfully. public void AcknowledgeDelivery(MediaImportDelivery delivery) { lock (this.stateLock) { if (!this.pendingDeliveries.TryGetValue(delivery.Target, out var pending)) return; var acknowledgedPaths = delivery.Attachments.Select(attachment => attachment.FilePath).ToHashSet(StringComparer.Ordinal); pending.Attachments.RemoveAll(attachment => acknowledgedPaths.Contains(attachment.FilePath)); if (delivery.Text is not null && string.Equals(pending.Text, delivery.Text, StringComparison.Ordinal)) pending.Text = null; if (pending.Attachments.Count is 0 && pending.Text is null) this.pendingDeliveries.Remove(delivery.Target); } } /// Consumes one terminal notification when its owner surface is displayed. public MediaImportOutcome? TryConsumeOutcome(MediaImportOwner owner) { MediaImportOutcome? outcome; lock (this.stateLock) { if (!this.outcomes.Remove(owner, out outcome)) return null; if (this.snapshots.GetValueOrDefault(owner) is { IsBusy: false }) this.snapshots.Remove(owner); } this.NotifyStateChanged(owner); return outcome; } /// Discards retained inactive state and deletes unclaimed managed transcript files. public void ClearOwnerState(MediaImportOwner owner) { List discardedAttachments = []; lock (this.stateLock) { if (this.activeBatches.Contains(owner)) return; this.snapshots.Remove(owner); this.outcomes.Remove(owner); foreach (var target in this.pendingDeliveries.Keys.Where(target => target.Owner == owner).ToList()) { discardedAttachments.AddRange(this.pendingDeliveries[target].Attachments); this.pendingDeliveries.Remove(target); } } foreach (var attachment in discardedAttachments) ManagedTranscriptAttachment.TryDeleteOwnedFile(attachment); this.NotifyStateChanged(owner); } /// Starts an owner-managed attachment batch and returns without holding the UI event handler. public bool TryStartAttachmentBatch(IReadOnlyList mediaPaths, MediaImportTarget target, ChatThread? ownerChat = null) { this.ThrowIfDisposed(); if (mediaPaths.Count is 0) return false; lock (this.stateLock) { if (!this.activeBatches.Add(target.Owner)) return false; this.batchCancellations[target.Owner] = new(); this.outcomes.Remove(target.Owner); } this.UpdateImportState(target, Path.GetFileName(mediaPaths[0]), MediaTranscriptionPhase.QUEUED, null, MediaImportStatus.QUEUED); _ = Task.Run(() => this.RunAttachmentBatchAsync(mediaPaths, target, ownerChat)); return true; } /// Starts a reattachable file-content import for one stable assistant field. public bool TryStartTextImport(string mediaPath, MediaImportTarget target) { this.ThrowIfDisposed(); lock (this.stateLock) { if (!this.activeBatches.Add(target.Owner)) return false; this.batchCancellations[target.Owner] = new(); this.outcomes.Remove(target.Owner); } this.UpdateImportState(target, Path.GetFileName(mediaPath), MediaTranscriptionPhase.QUEUED, null, MediaImportStatus.QUEUED); _ = Task.Run(() => this.RunTextImportAsync(mediaPath, target)); return true; } /// Completes a field import independently of the originating Blazor component. private async Task RunTextImportAsync(string mediaPath, MediaImportTarget target) { CancellationTokenSource cancellation; lock (this.stateLock) cancellation = this.batchCancellations[target.Owner]; var status = MediaImportStatus.SUCCEEDED; List failures = []; List warnings = []; try { var result = await this.TranscribeImportAsync(mediaPath, target, cancellation.Token); if (result.Status is MediaTranscriptionResultStatus.SUCCEEDED) this.AddCompletedText(target, result.Text); else if (result.Status is MediaTranscriptionResultStatus.CANCELLED) status = MediaImportStatus.CANCELLED; else if (result.Status is MediaTranscriptionResultStatus.NO_AUDIBLE_SIGNAL) { status = MediaImportStatus.WARNING; warnings.Add(new(Path.GetFileName(mediaPath), result.UserMessage)); } else { status = MediaImportStatus.FAILED; failures.Add(new(Path.GetFileName(mediaPath), result.UserMessage, result.ErrorCode)); } } catch (OperationCanceledException) { status = MediaImportStatus.CANCELLED; } catch (Exception exception) { logger.LogError(exception, "Owner media text import failed for '{Owner}' and target '{TargetId}'.", target.Owner, target.TargetId); status = MediaImportStatus.FAILED; failures.Add(new(Path.GetFileName(mediaPath), TB("The media file could not be transcribed."))); } finally { lock (this.stateLock) { this.activeBatches.Remove(target.Owner); if (this.batchCancellations.Remove(target.Owner, out var ownedCancellation)) ownedCancellation.Dispose(); } this.CompleteImport(target, Path.GetFileName(mediaPath), status, failures, warnings); } } /// Stores a completed field transcript for reattachment after navigation. private void AddCompletedText(MediaImportTarget target, string text) { lock (this.stateLock) { if (!this.pendingDeliveries.TryGetValue(target, out var pending)) this.pendingDeliveries[target] = pending = new(); pending.Text = text; } this.NotifyStateChanged(target.Owner); } /// Serially transcribes a complete owner batch while retaining every successful result. private async Task RunAttachmentBatchAsync(IReadOnlyList mediaPaths, MediaImportTarget target, ChatThread? ownerChat) { CancellationToken batchToken; lock (this.stateLock) batchToken = this.batchCancellations[target.Owner].Token; var status = MediaImportStatus.SUCCEEDED; var currentFileName = Path.GetFileName(mediaPaths[0]); List failures = []; List warnings = []; try { foreach (var mediaPath in mediaPaths) { currentFileName = Path.GetFileName(mediaPath); batchToken.ThrowIfCancellationRequested(); var result = await this.TranscribeImportAsync(mediaPath, target, batchToken); if (result.Status is MediaTranscriptionResultStatus.CANCELLED) { status = MediaImportStatus.CANCELLED; break; } if (result.Status is MediaTranscriptionResultStatus.NO_AUDIBLE_SIGNAL) { if (status is MediaImportStatus.SUCCEEDED) status = MediaImportStatus.WARNING; warnings.Add(new(currentFileName, result.UserMessage)); continue; } if (result.Status is not MediaTranscriptionResultStatus.SUCCEEDED) { status = MediaImportStatus.FAILED; failures.Add(new(currentFileName, result.UserMessage, result.ErrorCode)); continue; } var isPersistedChat = ownerChat is not null && WorkspaceBehaviour.IsChatExisting(new LoadChat(ownerChat.WorkspaceId, ownerChat.ChatId)); var attachment = isPersistedChat ? await WorkspaceBehaviour.CreateManagedTranscriptAsync(ownerChat!, mediaPath, result.Text) : await ManagedTranscriptAttachment.CreateStagedAsync(mediaPath, result.Text); if (ownerChat is not null && attachment is { } managed && ownerChat.PendingMediaTranscripts.All(existing => existing.FilePath != managed.FilePath)) ownerChat.PendingMediaTranscripts.Add(managed); if (isPersistedChat) await WorkspaceBehaviour.StoreChatAsync(ownerChat!); this.AddCompletedAttachment(target, attachment); } } catch (OperationCanceledException) { status = MediaImportStatus.CANCELLED; } catch (Exception exception) { logger.LogError(exception, "Owner media batch failed for '{Owner}'.", target.Owner); status = MediaImportStatus.FAILED; failures.Add(new(currentFileName, TB("The media file could not be transcribed."))); } finally { lock (this.stateLock) { this.activeBatches.Remove(target.Owner); if (this.batchCancellations.Remove(target.Owner, out var cancellation)) cancellation.Dispose(); } this.CompleteImport(target, currentFileName, status, failures, warnings); } } /// Adds a successful partial result to the retained owner snapshot. private void AddCompletedAttachment(MediaImportTarget target, FileAttachment attachment) { lock (this.stateLock) { if (!this.pendingDeliveries.TryGetValue(target, out var pending)) this.pendingDeliveries[target] = pending = new(); if (pending.Attachments.All(existing => existing.FilePath != attachment.FilePath)) pending.Attachments.Add(attachment); } this.NotifyStateChanged(target.Owner); } /// /// Transcribes an attachment or file-content import on the serialized visible lane. /// /// Source media path. /// Media import target. /// Caller cancellation token. /// A typed terminal result. private async Task TranscribeImportAsync(string mediaPath, MediaImportTarget target, CancellationToken token = default) { this.ThrowIfDisposed(); var operation = this.CreateOperation(target, token); lock (this.stateLock) { if (!this.currentImports.TryAdd(target.Owner, operation)) throw new InvalidOperationException($"Media owner '{target.Owner}' already has an active operation."); } this.UpdateImportState(target, Path.GetFileName(mediaPath), MediaTranscriptionPhase.QUEUED, null, MediaImportStatus.QUEUED); try { await this.importQueue.WaitAsync(operation.Cancellation.Token); operation.HasQueueLease = true; this.UpdateImportState(target, Path.GetFileName(mediaPath), MediaTranscriptionPhase.PROBING, 0.0, MediaImportStatus.RUNNING); return await this.TranscribeCoreAsync(mediaPath, operation, updateImportState: true); } catch (OperationCanceledException) { return MediaTranscriptionResult.Cancelled(); } finally { lock (this.stateLock) { if (this.currentImports.GetValueOrDefault(target.Owner) == operation) this.currentImports.Remove(target.Owner); } this.ReleaseOperation(operation); if (operation.HasQueueLease) this.importQueue.Release(); } } /// /// Transcribes a voice recording independently of the visible import lane. /// /// Voice recording path. /// Caller cancellation token. /// A typed terminal result. public async Task TranscribeVoiceAsync(string mediaPath, CancellationToken token = default) { this.ThrowIfDisposed(); var operation = this.CreateOperation(null, token); try { return await this.TranscribeCoreAsync(mediaPath, operation, updateImportState: false); } finally { this.ReleaseOperation(operation); } } /// Cancels only the queued or active operation belonging to one owner. public async Task StopAsync(MediaImportOwner owner) { MediaOperation? operation; MediaImportSnapshot? snapshot; lock (this.stateLock) { operation = this.currentImports.GetValueOrDefault(owner); this.batchCancellations.GetValueOrDefault(owner)?.Cancel(); operation?.Cancellation.Cancel(); snapshot = this.snapshots.GetValueOrDefault(owner); } if (snapshot is not null && this.IsBusy(owner)) this.UpdateImportState(snapshot.Target, snapshot.CurrentFileName, MediaTranscriptionPhase.CANCELING, null, MediaImportStatus.CANCELING); if (!string.IsNullOrWhiteSpace(operation?.JobId)) await rustService.CancelMediaJobAsync(operation.JobId); } /// Runs normalization, provider resolution, and upload for one owned operation. /// Source media path. /// Operation-specific cancellation and Rust-job state. /// Whether progress belongs to the visible import lane. /// A typed terminal result. private async Task TranscribeCoreAsync(string mediaPath, MediaOperation operation, bool updateImportState) { var normalizedPath = Path.Combine(Path.GetTempPath(), "mindwork-ai-studio-media", $"{operation.Id:N}.webm"); Directory.CreateDirectory(Path.GetDirectoryName(normalizedPath)!); try { var normalized = await this.NormalizeAsync(mediaPath, normalizedPath, operation, updateImportState); if (normalized.Result is null) return normalized.Error is null ? MediaTranscriptionResult.Failed(TB("The media pipeline ended without an output file.")) : MediaTranscriptionResult.Failed(UserMessageFor(normalized.Error.Code), normalized.Error.Code); var uploadContractError = await ValidateNormalizedProviderUploadAsync(normalized.Result, normalizedPath, operation.Cancellation.Token); if (uploadContractError is not null) { logger.LogError("Refusing the transcription provider upload because the normalized media contract validation failed: {Diagnostic}", uploadContractError); return MediaTranscriptionResult.Failed(TB("The media pipeline ended without an output file.")); } if (!normalized.Result.HasAudibleSignal) { logger.LogInformation("Skipping transcription for '{MediaPath}' because its maximum audio peak does not exceed the practical-silence threshold.", mediaPath); return MediaTranscriptionResult.NoAudibleSignal(TB("The audio track contains no audible signal, so there is nothing to transcribe.")); } var providerSettings = this.ResolveProvider(); if (providerSettings is null) return MediaTranscriptionResult.Failed(TB("No usable transcription provider is configured.")); if (updateImportState) this.UpdateImportState(operation.Target!.Value, Path.GetFileName(mediaPath), MediaTranscriptionPhase.UPLOADING, null, MediaImportStatus.RUNNING); var provider = providerSettings.CreateProvider(); if (provider.Provider is LLMProviders.NONE) return MediaTranscriptionResult.Failed(TB("The configured transcription provider could not be created.")); var sourceSize = File.Exists(mediaPath) ? new FileInfo(mediaPath).Length : 0; var normalizedSize = new FileInfo(normalizedPath).Length; var reductionPercent = sourceSize > 0 ? (1.0 - (double)normalizedSize / sourceSize) * 100.0 : 0.0; logger.LogInformation("Transcribing normalized WebM/Opus media '{NormalizedPath}' ({NormalizedSize} bytes; source '{SourcePath}' {SourceSize} bytes; size reduction {ReductionPercent:F1}%) with provider '{Provider}' and model '{Model}'.", normalizedPath, normalizedSize, mediaPath, sourceSize, reductionPercent, providerSettings.UsedLLMProvider, providerSettings.Model); var providerResult = await provider.TranscribeAudioAsync(providerSettings.Model, normalizedPath, settingsManager, operation.Cancellation.Token); operation.Cancellation.Token.ThrowIfCancellationRequested(); if (!providerResult.Success) { logger.LogWarning("The transcription provider failed for '{MediaPath}': {Diagnostic}", mediaPath, providerResult.ErrorMessage); return MediaTranscriptionResult.Failed(TB("The transcription provider could not transcribe the media file.")); } return MediaTranscriptionResult.Succeeded(providerResult.Text.Trim()); } catch (OperationCanceledException) { return MediaTranscriptionResult.Cancelled(); } catch (Exception exception) { logger.LogError(exception, "Media transcription failed for '{MediaPath}'.", mediaPath); return MediaTranscriptionResult.Failed(TB("The media file could not be transcribed.")); } finally { // NormalizeAsync does not return from cancellation until Rust has reached a terminal // phase, so deleting both paths here cannot race a still-writing worker. if (!this.RetainNormalizedMediaIfRequested(normalizedPath, operation.Id)) this.DeleteTemporaryFile(normalizedPath); this.DeleteTemporaryFile(normalizedPath + ".partial"); } } /// Validates the fail-closed WebM/Opus contract before provider upload. private static async Task ValidateNormalizedProviderUploadAsync(MediaJobResult result, string expectedOutputPath, CancellationToken token) { if (string.IsNullOrWhiteSpace(result.OutputPath)) return "Rust returned an empty normalized output path."; string actualFullPath; string expectedFullPath; try { actualFullPath = Path.GetFullPath(result.OutputPath); expectedFullPath = Path.GetFullPath(expectedOutputPath); } catch (Exception exception) { return $"The normalized output path is invalid: {exception.Message}"; } var pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; if (!string.Equals(actualFullPath, expectedFullPath, pathComparison)) return $"Rust returned the unexpected output path '{result.OutputPath}' instead of '{expectedOutputPath}'."; if (!string.Equals(Path.GetExtension(actualFullPath), NORMALIZED_OUTPUT_EXTENSION, StringComparison.OrdinalIgnoreCase)) return $"The normalized output path '{actualFullPath}' does not use the required '{NORMALIZED_OUTPUT_EXTENSION}' extension."; if (!string.Equals(result.OutputFormat, NORMALIZED_OUTPUT_FORMAT, StringComparison.Ordinal)) return $"Rust returned output format '{result.OutputFormat}' instead of '{NORMALIZED_OUTPUT_FORMAT}'."; if (!string.Equals(result.OutputCodec, NORMALIZED_OUTPUT_CODEC, StringComparison.Ordinal)) return $"Rust returned output codec '{result.OutputCodec}' instead of '{NORMALIZED_OUTPUT_CODEC}'."; if (!File.Exists(actualFullPath)) return $"The normalized output file '{actualFullPath}' does not exist."; var header = new byte[WEBM_EBML_SIGNATURE.Length]; var bytesRead = 0; try { await using var stream = File.OpenRead(actualFullPath); while (bytesRead < header.Length) { var count = await stream.ReadAsync(header.AsMemory(bytesRead), token); if (count is 0) break; bytesRead += count; } } catch (IOException exception) { return $"The normalized output file '{actualFullPath}' could not be read: {exception.Message}"; } catch (UnauthorizedAccessException exception) { return $"The normalized output file '{actualFullPath}' could not be read: {exception.Message}"; } if (bytesRead != header.Length || !header.AsSpan().SequenceEqual(WEBM_EBML_SIGNATURE)) return $"The normalized output file '{actualFullPath}' does not begin with the WebM/Matroska EBML signature."; return null; } /// Runs the Rust normalization job and drains cancellation to a terminal event. /// Source media path. /// Owned temporary output path. /// Operation-specific state. /// Whether progress belongs to the import lane. /// The terminal runtime result or error. private async Task<(MediaJobResult? Result, MediaJobError? Error)> NormalizeAsync( string mediaPath, string normalizedPath, MediaOperation operation, bool updateImportState) { // The quick POST is intentionally not cancelled: losing its response could orphan a job // whose ID the client never received. Cancellation is applied immediately after ownership. var jobId = await rustService.StartMediaJobAsync(mediaPath, normalizedPath, CancellationToken.None); operation.JobId = jobId; try { operation.Cancellation.Token.ThrowIfCancellationRequested(); await foreach (var mediaEvent in rustService.StreamMediaJobEventsAsync(jobId, operation.Cancellation.Token)) { if (updateImportState && mediaEvent.Phase is MediaJobPhase.PROBING or MediaJobPhase.TRANSCODING) { var phase = mediaEvent.Phase is MediaJobPhase.PROBING ? MediaTranscriptionPhase.PROBING : MediaTranscriptionPhase.TRANSCODING; this.UpdateImportState(operation.Target!.Value, Path.GetFileName(mediaPath), phase, mediaEvent.Progress, MediaImportStatus.RUNNING); } switch (mediaEvent.Phase) { case MediaJobPhase.COMPLETED: return (mediaEvent.Result, null); case MediaJobPhase.FAILED: if (mediaEvent.Error is not null) logger.LogWarning("Rust media normalization failed for '{MediaPath}' with {Code}: {Diagnostic}", mediaPath, mediaEvent.Error.Code, mediaEvent.Error.Message); return (null, mediaEvent.Error); case MediaJobPhase.CANCELLED: throw new OperationCanceledException(operation.Cancellation.Token); } } return (null, null); } catch (OperationCanceledException) { await rustService.CancelMediaJobAsync(jobId, CancellationToken.None); await this.DrainTerminalEventAsync(jobId); throw; } } /// Waits for Rust cleanup after cooperative cancellation. /// Owned Rust job identifier. private async Task DrainTerminalEventAsync(string jobId) { await foreach (var _ in rustService.StreamMediaJobEventsAsync(jobId, CancellationToken.None)) { // The stream itself ends immediately after the first terminal snapshot or event. } } /// Resolves the configured provider after confidence validation. private TranscriptionProvider? ResolveProvider() { var providerId = settingsManager.ConfigurationData.App.UseTranscriptionProvider; if (string.IsNullOrWhiteSpace(providerId)) return null; var providerSettings = settingsManager.ConfigurationData.TranscriptionProviders.FirstOrDefault(x => x.Id == providerId); if (providerSettings is null) return null; var minimumLevel = settingsManager.GetMinimumConfidenceLevel(Components.NONE); return providerSettings.UsedLLMProvider.GetConfidence(settingsManager).Level >= minimumLevel ? providerSettings : null; } /// Creates and registers operation-owned cancellation state. /// Optional visible media import target. /// Caller token linked to the operation. /// The registered operation. private MediaOperation CreateOperation(MediaImportTarget? target, CancellationToken token) { var operation = new MediaOperation(target, token); lock (this.stateLock) this.operations.Add(operation); return operation; } /// Unregisters and disposes completed operation state. /// Completed operation. private void ReleaseOperation(MediaOperation operation) { lock (this.stateLock) this.operations.Remove(operation); operation.Dispose(); } /// Updates and publishes copied state for exactly one owner. private void UpdateImportState(MediaImportTarget target, string fileName, MediaTranscriptionPhase phase, double? progress, MediaImportStatus status) { var snapshot = new MediaImportSnapshot { Owner = target.Owner, Target = target, CurrentFileName = fileName, Phase = phase, Progress = progress, Status = status, }; lock (this.stateLock) this.snapshots[target.Owner] = snapshot; this.NotifyStateChanged(target.Owner); } /// Publishes one retained terminal result after an entire target batch ended. private void CompleteImport( MediaImportTarget target, string fileName, MediaImportStatus status, IReadOnlyList failures, IReadOnlyList warnings) { lock (this.stateLock) { this.snapshots[target.Owner] = new() { Owner = target.Owner, Target = target, CurrentFileName = fileName, Phase = MediaTranscriptionPhase.IDLE, Progress = null, Status = status, }; this.outcomes[target.Owner] = new() { Owner = target.Owner, Status = status, Failures = [.. failures], Warnings = [.. warnings], }; } this.NotifyStateChanged(target.Owner); } /// Publishes state changes without allowing one stale UI subscriber to fault a worker. private void NotifyStateChanged(MediaImportOwner owner) { if (this.StateChanged is not { } stateChanged) return; foreach (var @delegate in stateChanged.GetInvocationList()) { var handler = (Action)@delegate; try { handler(owner); } catch (Exception exception) { logger.LogWarning(exception, "A media state subscriber failed for owner '{Owner}'.", owner); } } } /// Maps runtime codes to localized user-facing fallback text. private static string UserMessageFor(MediaJobErrorCode code) => code switch { MediaJobErrorCode.FILE_NOT_FOUND => TB("The selected media file no longer exists."), MediaJobErrorCode.UNSAFE_FILE or MediaJobErrorCode.NOT_MEDIA => TB("The selected file cannot be processed as media."), MediaJobErrorCode.NO_AUDIO_TRACK => TB("The selected media file does not contain an audio track."), MediaJobErrorCode.UNSUPPORTED_CONTAINER or MediaJobErrorCode.UNSUPPORTED_CODEC or MediaJobErrorCode.UNSUPPORTED_OPUS_MAPPING => TB("This media format or audio codec is not supported."), MediaJobErrorCode.UNKNOWN_FORMAT or MediaJobErrorCode.DAMAGED_CONTAINER => TB("The media file is damaged or its format could not be identified."), _ => TB("The media file could not be prepared for transcription."), }; /// Deletes one operation-owned temporary file on a best-effort basis. private void DeleteTemporaryFile(string path) { try { if (File.Exists(path)) File.Delete(path); } catch (Exception exception) { logger.LogWarning(exception, "Could not delete operation-owned temporary media file '{Path}'.", path); } } /// Retains the exact provider upload only for opt-in debug diagnostics. private bool RetainNormalizedMediaIfRequested(string normalizedPath, Guid operationId) { #if DEBUG if (!string.Equals(Environment.GetEnvironmentVariable("MINDWORK_AI_RETAIN_NORMALIZED_MEDIA"), "true", StringComparison.OrdinalIgnoreCase) || !File.Exists(normalizedPath)) return false; try { var diagnosticDirectory = Path.Combine(Path.GetTempPath(), "mindwork-ai-studio-media", "diagnostics"); Directory.CreateDirectory(diagnosticDirectory); var diagnosticPath = Path.Combine(diagnosticDirectory, $"{operationId:N}.webm"); File.Move(normalizedPath, diagnosticPath, overwrite: true); foreach (var oldPath in new DirectoryInfo(diagnosticDirectory).EnumerateFiles("*.webm").OrderByDescending(file => file.LastWriteTimeUtc).Skip(10)) oldPath.Delete(); logger.LogInformation("Retained normalized media diagnostic '{DiagnosticPath}'.", diagnosticPath); return true; } catch (Exception exception) { logger.LogWarning(exception, "Could not retain normalized media diagnostic for operation '{OperationId}'.", operationId); } #endif return false; } /// Returns localized text while registering the US-English fallback with I18N. private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(MediaTranscriptionService).Namespace, nameof(MediaTranscriptionService)); /// Throws when a caller attempts to start work after disposal. private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this.disposed, this); /// Cancels every active import and voice operation and releases owned resources. public void Dispose() { MediaOperation[] active; CancellationTokenSource[] batches; lock (this.stateLock) { if (this.disposed) return; this.disposed = true; active = [.. this.operations]; batches = [.. this.batchCancellations.Values]; } foreach (var operation in active) operation.Cancellation.Cancel(); foreach (var batch in batches) batch.Cancel(); // The semaphore may still be released by an operation unwinding after cancellation. } /// Cancellation and runtime-job ownership for exactly one media operation. private sealed class MediaOperation : IDisposable { /// Creates operation state linked to a caller token. /// Optional visible media import target. /// Caller cancellation token. public MediaOperation(MediaImportTarget? target, CancellationToken token) { this.Target = target; this.Cancellation = CancellationTokenSource.CreateLinkedTokenSource(token); } /// Gets the optional visible import target; voice operations have none. public MediaImportTarget? Target { get; } /// Gets the unique temporary-path identifier. public Guid Id { get; } = Guid.NewGuid(); /// Gets the operation-owned cancellation source. public CancellationTokenSource Cancellation { get; } /// Gets or sets the Rust job after its POST response establishes ownership. public string? JobId { get; set; } /// Gets or sets whether this operation currently owns the serialized lane. public bool HasQueueLease { get; set; } /// Disposes operation-owned cancellation state. public void Dispose() => this.Cancellation.Dispose(); } /// Mutable successful results waiting for acknowledgement by one target. private sealed class PendingDelivery { public List Attachments { get; } = []; public string? Text { get; set; } } }