Added transcription of audio and video files to local data sources (#1081)

Co-authored-by: Paul Koudelka <106623909+PaulKoudelka@users.noreply.github.com>
This commit is contained in:
Thorsten SommerandPaul Koudelka authored and GitHub committed 2026-10-18 12:06:15 +02:00
1 parent 8642ce2d81
commit 752f7d828a
82 files changed
+3231 -302

No files matched your search

@@ -0,0 +1,209 @@
using System.Text.RegularExpressions;
using AIStudio.Settings;
namespace AIStudio.Tools.Media;
/// <summary>
/// Keeps the transcripts of the audio and video files of one data source below the data directory.
/// </summary>
/// <remarks>
/// A data source never writes into the folders of the user: they may be read-only, a network share,
/// or the read-only home folder of the Flatpak, and a transcript which cannot be stored would be paid
/// for again with every run. The transcripts live in 'mediaTranscripts/&lt;data source id&gt;/&lt;key&gt;.md'
/// instead, where the key is the SHA-256 hash of the content of the media file. Renaming or moving a
/// recording, or a sync tool which only touches its write time, finds the transcript again. The path
/// follows from the id and the key, so nothing stores it.
/// </remarks>
/// <param name="dataDirectory">The data directory of AI Studio.</param>
/// <param name="dataSourceId">The id of the data source, a GUID.</param>
internal sealed partial class DataSourceTranscriptStore(string dataDirectory, string dataSourceId) : IMediaTranscriptLocation
{
/// <summary>
/// The folder below the data directory which holds the transcripts of all data sources.
/// </summary>
public const string FOLDER_NAME = "mediaTranscripts";
private const string FILE_EXTENSION = ".md";
private readonly string directoryPath = Path.Join(dataDirectory, FOLDER_NAME, NormalizeId(dataSourceId));
/// <summary>
/// The store of a data source in the data directory of the running app.
/// </summary>
/// <param name="dataSourceId">The id of the data source.</param>
/// <returns>The store.</returns>
public static DataSourceTranscriptStore ForDataSource(string dataSourceId) => new(SettingsManager.DataDirectory ?? throw new InvalidOperationException("The data directory is not known yet."), dataSourceId);
/// <summary>
/// The store of a data source in the data directory of the running app, or null when there is none to clean up.
/// </summary>
/// <param name="dataSourceId">The id of the data source.</param>
/// <returns>The store, or null while the data directory is not known or for an id which is no GUID.</returns>
public static DataSourceTranscriptStore? TryForDataSource(string dataSourceId) => string.IsNullOrWhiteSpace(SettingsManager.DataDirectory) || !Guid.TryParse(dataSourceId, out _)
? null
: new(SettingsManager.DataDirectory, dataSourceId);
/// <inheritdoc />
/// <remarks>
/// Reads the whole media file to hash it, in pieces. Callers ask only for files which are about
/// to be read anyway because they are new or changed.
/// </remarks>
public async Task<MediaTranscriptSlot> ResolveAsync(string mediaPath, CancellationToken token)
{
var key = await FileContentHash.ComputeAsync(mediaPath, token);
return new(this.GetPath(key), key);
}
/// <summary>
/// Whether a text is a key of this store, i.e. a lowercase SHA-256 hash.
/// </summary>
/// <param name="key">The text to check.</param>
/// <returns>True for a key.</returns>
public static bool IsKey(string key) => KeyPattern().IsMatch(key);
/// <summary>
/// The path of the transcript with the given key.
/// </summary>
/// <param name="key">The key of the transcript.</param>
/// <returns>The path, whether or not the transcript exists.</returns>
public string GetPath(string key)
{
if (!IsKey(key))
throw new ArgumentException("The key of a media transcript has to be a lowercase SHA-256 hash.", nameof(key));
return Path.Join(this.directoryPath, key + FILE_EXTENSION);
}
/// <summary>
/// The keys of all transcripts of this data source.
/// </summary>
/// <returns>The keys; empty when the data source has no transcripts.</returns>
public IReadOnlyList<string> ListKeys()
{
return this.EnumerateOwnFiles()
.Select(file => Path.GetFileName(file.FullName))
.Where(fileName => TranscriptFileNamePattern().IsMatch(fileName))
.Select(Path.GetFileNameWithoutExtension)
.OfType<string>()
.ToList();
}
/// <summary>
/// Deletes the transcript with the given key.
/// </summary>
/// <param name="key">The key of the transcript.</param>
/// <returns>True when a transcript was deleted.</returns>
public bool Delete(string key)
{
var file = new FileInfo(this.GetPath(key));
if (!file.Exists || file.Attributes.HasFlag(FileAttributes.ReparsePoint))
return false;
file.Delete();
return true;
}
/// <summary>
/// Deletes the temporary files which an interrupted write left behind.
/// </summary>
/// <remarks>
/// Call it only while nothing writes a transcript of this data source, e.g. at the end of a run.
/// </remarks>
/// <returns>The number of deleted files.</returns>
public int DeleteTemporaryFiles()
{
var deleted = 0;
foreach (var file in this.EnumerateOwnFiles().Where(file => TemporaryFileNamePattern().IsMatch(file.Name)))
{
file.Delete();
deleted++;
}
return deleted;
}
/// <summary>
/// Deletes every transcript of this data source, together with temporary files a crash left behind.
/// </summary>
/// <remarks>
/// Removes only what this store wrote. Anything else in the folder stays, and the folder with it.
/// </remarks>
public void DeleteAll()
{
foreach (var file in this.EnumerateOwnFiles())
file.Delete();
var directory = new DirectoryInfo(this.directoryPath);
if (directory.Exists && !directory.Attributes.HasFlag(FileAttributes.ReparsePoint) && !directory.EnumerateFileSystemInfos().Any())
directory.Delete();
}
/// <summary>
/// Copies the transcripts of all data sources, e.g. for a backup.
/// </summary>
/// <remarks>
/// Copies only transcripts, file by file and in pieces. Temporary files of an interrupted write,
/// links, and anything else in the folders stay behind.
/// </remarks>
/// <param name="dataDirectory">The data directory to copy from.</param>
/// <param name="targetDirectory">The folder which receives the transcripts below 'mediaTranscripts/', in the same layout.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The number of copied transcripts.</returns>
public static async Task<int> CopyAllAsync(string dataDirectory, string targetDirectory, CancellationToken token)
{
var root = new DirectoryInfo(Path.Join(dataDirectory, FOLDER_NAME));
if (!root.Exists || root.Attributes.HasFlag(FileAttributes.ReparsePoint))
return 0;
var copied = 0;
foreach (var dataSourceFolder in root.EnumerateDirectories("*", new EnumerationOptions { RecurseSubdirectories = false, AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = false }))
{
if (!Guid.TryParse(dataSourceFolder.Name, out _))
continue;
var store = new DataSourceTranscriptStore(dataDirectory, dataSourceFolder.Name);
foreach (var file in store.EnumerateOwnFiles().Where(file => TranscriptFileNamePattern().IsMatch(file.Name)))
{
token.ThrowIfCancellationRequested();
var targetPath = Path.Join(targetDirectory, FOLDER_NAME, dataSourceFolder.Name, file.Name);
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
await using var source = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, FileOptions.Asynchronous | FileOptions.SequentialScan);
await using var target = new FileStream(targetPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 65_536, FileOptions.Asynchronous);
await source.CopyToAsync(target, token);
copied++;
}
}
return copied;
}
/// <summary>
/// The files this store wrote: transcripts and the temporary files of interrupted writes. Links are left out.
/// </summary>
private IEnumerable<FileInfo> EnumerateOwnFiles()
{
var directory = new DirectoryInfo(this.directoryPath);
if (!directory.Exists || directory.Attributes.HasFlag(FileAttributes.ReparsePoint))
return [];
return directory
.EnumerateFiles("*", new EnumerationOptions { RecurseSubdirectories = false, AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = true })
.Where(file => TranscriptFileNamePattern().IsMatch(file.Name) || TemporaryFileNamePattern().IsMatch(file.Name))
.ToList();
}
private static string NormalizeId(string dataSourceId) => Guid.TryParse(dataSourceId, out var id)
? id.ToString("D")
: throw new ArgumentException("The id of a data source with media transcripts has to be a GUID.", nameof(dataSourceId));
[GeneratedRegex("^[0-9a-f]{64}$")]
private static partial Regex KeyPattern();
[GeneratedRegex(@"^[0-9a-f]{64}\.md$")]
private static partial Regex TranscriptFileNamePattern();
[GeneratedRegex(@"^[0-9a-f]{64}\.md\.[0-9a-f]{32}\.tmp$")]
private static partial Regex TemporaryFileNamePattern();
}
@@ -0,0 +1,19 @@
namespace AIStudio.Tools.Media;
/// <summary>
/// Where the persistent transcript of an audio or video file lives, cf. PersistentMediaTranscript.
/// </summary>
/// <remarks>
/// A location only names the place. Reading and writing the transcript is left to
/// PersistentMediaTranscript, so every location keeps the same rules against paying twice.
/// </remarks>
internal interface IMediaTranscriptLocation
{
/// <summary>
/// Finds the place of the transcript of a media file.
/// </summary>
/// <param name="mediaPath">The audio or video file.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The place of the transcript, whether or not a transcript is there yet.</returns>
Task<MediaTranscriptSlot> ResolveAsync(string mediaPath, CancellationToken token);
}
@@ -0,0 +1,107 @@
namespace AIStudio.Tools.Media;
/// <summary>
/// Keeps the transcript of a media file next to it, as '&lt;media file&gt;.transcript.md'.
/// </summary>
/// <remarks>
/// The Batch Processing assistant writes its results into the folder the user chose, and its
/// transcripts belong there as well, where people find and reuse them. Data sources must not write
/// into the folders of the user and keep their transcripts below the data directory instead.
/// </remarks>
internal sealed class MediaSidecarTranscripts : IMediaTranscriptLocation
{
public const string FILE_SUFFIX = ".transcript.md";
public static readonly MediaSidecarTranscripts INSTANCE = new();
/// <inheritdoc />
public Task<MediaTranscriptSlot> ResolveAsync(string mediaPath, CancellationToken token)
{
var path = GetFilePath(mediaPath);
return Task.FromResult(new MediaTranscriptSlot(path, path));
}
/// <summary>
/// The path of the transcript next to a media file.
/// </summary>
/// <param name="mediaPath">The audio or video file.</param>
/// <returns>The path of its transcript.</returns>
public static string GetFilePath(string mediaPath) => mediaPath + FILE_SUFFIX;
/// <summary>
/// Whether a file is a transcript next to a media file, or a temporary file left while one was stored.
/// </summary>
/// <param name="path">The path of the file.</param>
/// <returns>True for transcripts and their temporary files.</returns>
public static bool IsArtifactPath(string path)
{
var fileName = Path.GetFileName(path);
if (fileName.EndsWith(FILE_SUFFIX, StringComparison.OrdinalIgnoreCase))
return true;
var suffixIndex = fileName.LastIndexOf(FILE_SUFFIX + ".", StringComparison.OrdinalIgnoreCase);
return suffixIndex >= 0 && fileName.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Whether a file is the transcript of a media file which the selection of a folder data source selects as well.
/// </summary>
/// <remarks>
/// Such a transcript holds the same text the data source gets from transcribing the media file,
/// so indexing it as a document of its own would only duplicate that text. A transcript whose
/// media file is gone, or not selected, stays a document like every other Markdown file.
/// </remarks>
/// <param name="path">The path of the file.</param>
/// <param name="selection">The files the folder data source selects.</param>
/// <returns>True when the data source indexes the media file of this transcript.</returns>
public static bool IsCoveredArtifact(string path, FolderFileSelection selection)
{
if (!TryGetMediaPath(path, out var mediaPath))
return false;
return PersistentMediaTranscript.IsMediaPath(mediaPath) && File.Exists(mediaPath) && selection.Selects(mediaPath);
}
/// <summary>
/// Whether a media file has a transcript next to it, so no transcription provider is needed for it.
/// </summary>
/// <param name="mediaPath">The audio or video file.</param>
/// <returns>True when a transcript which is not empty exists.</returns>
public static bool HasReusableTranscript(string mediaPath)
{
var transcriptFilePath = GetFilePath(mediaPath);
try
{
return File.Exists(transcriptFilePath) && new FileInfo(transcriptFilePath).Length > 0;
}
catch
{
// The concrete read error is reported when the affected file is
// processed. Here we only decide whether a provider is required.
return File.Exists(transcriptFilePath);
}
}
/// <summary>
/// The media file a transcript or one of its temporary files belongs to.
/// </summary>
private static bool TryGetMediaPath(string path, out string mediaPath)
{
var suffixIndex = path.LastIndexOf(FILE_SUFFIX, StringComparison.OrdinalIgnoreCase);
if (suffixIndex <= 0)
{
mediaPath = string.Empty;
return false;
}
var remainder = path[(suffixIndex + FILE_SUFFIX.Length)..];
if (remainder.Length > 0 && !(remainder[0] is '.' && remainder.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)))
{
mediaPath = string.Empty;
return false;
}
mediaPath = path[..suffixIndex];
return true;
}
}
@@ -0,0 +1,8 @@
namespace AIStudio.Tools.Media;
/// <summary>
/// The place of the persistent transcript of one audio or video file.
/// </summary>
/// <param name="Path">The absolute path of the Markdown transcript.</param>
/// <param name="Key">What identifies the transcript at its location, e.g. the hash of the media content.</param>
internal sealed record MediaTranscriptSlot(string Path, string Key);
@@ -9,7 +9,8 @@ namespace AIStudio.Tools.Media;
/// <param name="Text">Transcript text for a successful operation.</param>
/// <param name="UserMessage">Localized message suitable for display after a warning or failure.</param>
/// <param name="ErrorCode">Optional stable runtime failure category.</param>
public sealed record MediaTranscriptionResult(MediaTranscriptionResultStatus Status, string Text, string UserMessage, MediaJobErrorCode? ErrorCode = null)
/// <param name="FileRejected">Whether the transcription provider refused the media file itself, so that it would refuse the unchanged file again.</param>
public sealed record MediaTranscriptionResult(MediaTranscriptionResultStatus Status, string Text, string UserMessage, MediaJobErrorCode? ErrorCode = null, bool FileRejected = false)
{
/// <summary>Creates a successful result.</summary>
/// <param name="text">Provider transcript.</param>
@@ -20,6 +21,10 @@ public sealed record MediaTranscriptionResult(MediaTranscriptionResultStatus Sta
/// <param name="errorCode">Optional runtime error category.</param>
public static MediaTranscriptionResult Failed(string userMessage, MediaJobErrorCode? errorCode = null) => new(MediaTranscriptionResultStatus.FAILED, string.Empty, userMessage, errorCode);
/// <summary>Creates a failed result for a media file which the transcription provider refused.</summary>
/// <param name="userMessage">Localized visible message.</param>
public static MediaTranscriptionResult Rejected(string userMessage) => new(MediaTranscriptionResultStatus.FAILED, string.Empty, userMessage, FileRejected: true);
/// <summary>Creates a warning result for media without an audible signal.</summary>
/// <param name="userMessage">Localized visible warning.</param>
public static MediaTranscriptionResult NoAudibleSignal(string userMessage) => new(
@@ -0,0 +1,213 @@
using System.Text;
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Media;
/// <summary>
/// Reads or creates the persistent Markdown transcript of an audio or video file.
/// </summary>
/// <remarks>
/// Every transcript which AI Studio keeps comes from here, so the rules which keep a paid
/// transcription from being paid twice hold for every caller: an existing transcript is used as it
/// is, a transcript which arrived is stored even when the caller cancelled meanwhile, and nothing is
/// stored for a file which changed during its transcription or for a transcription without text.
/// Where a transcript lives is up to the location, e.g. next to the media file or below the data
/// directory.
/// </remarks>
internal static class PersistentMediaTranscript
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PersistentMediaTranscript).Namespace, nameof(PersistentMediaTranscript));
/// <summary>
/// Whether a file is an audio or video file, which holds no text until it is transcribed.
/// </summary>
/// <param name="path">The path of the file.</param>
/// <returns>True for audio and video files.</returns>
public static bool IsMediaPath(string path) => FileTypes.IsAllowedPath(path, FileTypes.AUDIO, FileTypes.VIDEO);
/// <summary>
/// Returns where the transcript of a media file is, transcribing the file only when its location holds no transcript yet.
/// </summary>
/// <param name="location">Where the transcript lives.</param>
/// <param name="mediaPath">The audio or video file.</param>
/// <param name="provider">The transcription provider the caller allows, or null when no provider may hear the file.</param>
/// <param name="transcribe">Transcribes the media file with the given provider, usually MediaTranscriptionService.TranscribeAsync.</param>
/// <param name="logger">The logger of the caller.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>Where the transcript is, or why there is none.</returns>
public static async Task<PersistentMediaTranscriptResult> ReadOrCreateAsync(IMediaTranscriptLocation location, string mediaPath, TranscriptionProvider? provider, Func<string, TranscriptionProvider, CancellationToken, Task<MediaTranscriptionResult>> transcribe, ILogger logger, CancellationToken token)
{
//
// The stamp comes first: a location may read the whole file to find the place of its
// transcript, and a change from then on must keep a transcript out of that place.
//
FileStamp sourceBefore;
MediaTranscriptSlot slot;
try
{
sourceBefore = FileStamp.Read(mediaPath);
slot = await location.ResolveAsync(mediaPath, token);
}
catch (OperationCanceledException)
{
return PersistentMediaTranscriptResult.Cancelled();
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not inspect the media file '{MediaFilePath}' before its transcription.", mediaPath);
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.SOURCE_UNREADABLE, null, TB("The media file could not be read."));
}
try
{
if (await HoldsTextAsync(slot.Path, token))
{
logger.LogInformation("Reusing the transcript '{TranscriptFilePath}' of the media file '{MediaFilePath}'.", slot.Path, mediaPath);
return PersistentMediaTranscriptResult.Ready(slot, reused: true);
}
}
catch (OperationCanceledException)
{
return PersistentMediaTranscriptResult.Cancelled();
}
catch (Exception exception)
{
//
// Transcribing again would mean paying for a transcript which exists, and storing it
// would most likely fail for the same reason:
//
logger.LogWarning(exception, "Could not read the transcript '{TranscriptFilePath}' of the media file '{MediaFilePath}'.", slot.Path, mediaPath);
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.TRANSCRIPT_UNREADABLE, slot, string.Format(TB("Was not able to read the existing transcript: {0}"), exception.Message));
}
if (provider is null)
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.NO_PROVIDER, slot, TB("No usable transcription provider is configured."));
MediaTranscriptionResult transcription;
try
{
transcription = await transcribe(mediaPath, provider, token);
}
catch (OperationCanceledException)
{
return PersistentMediaTranscriptResult.Cancelled();
}
switch (transcription.Status)
{
case MediaTranscriptionResultStatus.CANCELLED:
return PersistentMediaTranscriptResult.Cancelled();
case MediaTranscriptionResultStatus.NO_AUDIBLE_SIGNAL:
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.NO_AUDIBLE_SIGNAL, slot, transcription.UserMessage);
case MediaTranscriptionResultStatus.FAILED when transcription.FileRejected:
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.REJECTED, slot, transcription.UserMessage);
case MediaTranscriptionResultStatus.FAILED:
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.TRANSCRIPTION_FAILED, slot, transcription.UserMessage, transcription.ErrorCode);
}
if (string.IsNullOrWhiteSpace(transcription.Text))
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.EMPTY, slot, TB("The transcription provider returned an empty transcript."));
if (!sourceBefore.StillMatches(mediaPath))
return SourceChanged(slot);
var temporaryPath = $"{slot.Path}.{Guid.NewGuid():N}.tmp";
try
{
var directory = Path.GetDirectoryName(slot.Path);
if (!string.IsNullOrWhiteSpace(directory))
Directory.CreateDirectory(directory);
//
// The transcription is paid for at this point. Storing it is quick, so it is completed
// even when the caller cancelled meanwhile, and the next attempt can use it:
//
await File.WriteAllTextAsync(temporaryPath, transcription.Text, new UTF8Encoding(false), CancellationToken.None);
if (!sourceBefore.StillMatches(mediaPath))
return SourceChanged(slot);
File.Move(temporaryPath, slot.Path, true);
logger.LogInformation("Stored the transcript '{TranscriptFilePath}' of the media file '{MediaFilePath}'.", slot.Path, mediaPath);
return PersistentMediaTranscriptResult.Ready(slot, reused: false);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not store the transcript '{TranscriptFilePath}' of the media file '{MediaFilePath}'.", slot.Path, mediaPath);
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.STORE_FAILED, slot, string.Format(TB("Was not able to store the transcript: {0}"), exception.Message));
}
finally
{
try
{
if (File.Exists(temporaryPath))
File.Delete(temporaryPath);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not remove the temporary transcript '{TempFilePath}'.", temporaryPath);
}
}
}
private static PersistentMediaTranscriptResult SourceChanged(MediaTranscriptSlot slot) => PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.SOURCE_CHANGED, slot, TB("The media file changed while it was being transcribed, so its transcript was not kept."));
/// <summary>
/// Whether a transcript exists and holds more than white space.
/// </summary>
/// <remarks>
/// Reads only as far as the first character which is not white space, so a large file is
/// never loaded as a whole.
/// </remarks>
private static async Task<bool> HoldsTextAsync(string path, CancellationToken token)
{
if (!File.Exists(path))
return false;
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var buffer = new char[1024];
int read;
while ((read = await reader.ReadAsync(buffer.AsMemory(), token)) > 0)
{
for (var index = 0; index < read; index++)
if (!char.IsWhiteSpace(buffer[index]))
return true;
}
return false;
}
/// <summary>
/// The size and write time of a media file, to notice a change during its transcription.
/// </summary>
private readonly record struct FileStamp(long Length, DateTime LastWriteTimeUtc)
{
public static FileStamp Read(string path)
{
var file = new FileInfo(path);
file.Refresh();
if (!file.Exists)
throw new FileNotFoundException("The media file does not exist.", path);
return new(file.Length, file.LastWriteTimeUtc);
}
public bool StillMatches(string path)
{
try
{
return Read(path) == this;
}
catch
{
return false;
}
}
}
}
@@ -0,0 +1,26 @@
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Media;
/// <summary>
/// The outcome of a request for a persistent transcript, cf. PersistentMediaTranscript.
/// </summary>
/// <remarks>
/// The transcript itself is not part of it: whoever needs the text reads the file at
/// TranscriptPath, so a transcript reaches the AI by the same path no matter whether it was just
/// made or stored long ago.
/// </remarks>
/// <param name="Status">How the request ended.</param>
/// <param name="TranscriptPath">The path of the transcript; empty when the place could not be determined.</param>
/// <param name="Key">What identifies the transcript at its location; empty when the place could not be determined.</param>
/// <param name="Reused">Whether an existing transcript was used instead of a new transcription.</param>
/// <param name="UserMessage">A localized explanation for every status but READY and CANCELLED.</param>
/// <param name="ErrorCode">Why the media pipeline failed, for TRANSCRIPTION_FAILED, when it is known.</param>
internal sealed record PersistentMediaTranscriptResult(PersistentMediaTranscriptStatus Status, string TranscriptPath, string Key, bool Reused, string UserMessage, MediaJobErrorCode? ErrorCode = null)
{
public static PersistentMediaTranscriptResult Ready(MediaTranscriptSlot slot, bool reused) => new(PersistentMediaTranscriptStatus.READY, slot.Path, slot.Key, reused, string.Empty);
public static PersistentMediaTranscriptResult Cancelled() => new(PersistentMediaTranscriptStatus.CANCELLED, string.Empty, string.Empty, false, string.Empty);
public static PersistentMediaTranscriptResult Unavailable(PersistentMediaTranscriptStatus status, MediaTranscriptSlot? slot, string userMessage, MediaJobErrorCode? errorCode = null) => new(status, slot?.Path ?? string.Empty, slot?.Key ?? string.Empty, false, userMessage, errorCode);
}
@@ -0,0 +1,63 @@
namespace AIStudio.Tools.Media;
/// <summary>
/// How the request for a persistent transcript ended, cf. PersistentMediaTranscript.
/// </summary>
public enum PersistentMediaTranscriptStatus
{
/// <summary>
/// The transcript is stored at its place and holds text.
/// </summary>
READY,
/// <summary>
/// There is no transcript yet, and the caller allows no transcription provider.
/// </summary>
NO_PROVIDER,
/// <summary>
/// The media file could not be read before it was transcribed.
/// </summary>
SOURCE_UNREADABLE,
/// <summary>
/// A transcript exists, but it could not be read.
/// </summary>
TRANSCRIPT_UNREADABLE,
/// <summary>
/// The transcription failed. The error code of the media pipeline tells why, when it is known.
/// </summary>
TRANSCRIPTION_FAILED,
/// <summary>
/// The transcription provider refused the media file itself, e.g., because it is too large
/// or damaged. It would refuse the unchanged file again.
/// </summary>
REJECTED,
/// <summary>
/// The media file holds no audible signal, so there was nothing to transcribe.
/// </summary>
NO_AUDIBLE_SIGNAL,
/// <summary>
/// The transcription provider returned no text. Nothing was stored.
/// </summary>
EMPTY,
/// <summary>
/// The media file changed while it was transcribed. Nothing was stored.
/// </summary>
SOURCE_CHANGED,
/// <summary>
/// The transcript could not be stored at its place.
/// </summary>
STORE_FAILED,
/// <summary>
/// The caller cancelled before a transcript was available.
/// </summary>
CANCELLED,
}