Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 111 additions & 25 deletions src/ui/Features/Video/TextToSpeech/Engines/VoiceFileRename.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Voices;
using Nikse.SubtitleEdit.Logic.Config;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

Expand All @@ -16,6 +17,13 @@ namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines;
/// </summary>
public static class VoiceFileRename
{
private sealed class RenameMove(string source, string target, string temp)
{
public string Source { get; } = source;
public string Target { get; } = target;
public string Temp { get; } = temp;
}

/// <summary>
/// The reference recording <paramref name="voice"/> clones from, or null when the voice is
/// not a renamable file-backed clone: an engine preset, the "Default" speaker, the per-line
Expand Down Expand Up @@ -136,46 +144,124 @@ public static bool Delete(Voice voice, out string error)
return oldFileName;
}

var sameFileDifferentCase = string.Equals(oldBaseName, newBaseName, StringComparison.OrdinalIgnoreCase);
if (!sameFileDifferentCase && File.Exists(newFileName))
{
error = $"A voice named '{newName}' already exists";
return null;
}

var staged = new List<RenameMove>();
var published = new List<RenameMove>();
try
{
// Sidecars first (a rename that fails half-way is still a usable voice); WAV last.
foreach (var sidecar in Directory.GetFiles(folder, oldBaseName + ".*"))
{
if (string.Equals(sidecar, oldFileName, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(Path.GetFileNameWithoutExtension(sidecar), oldBaseName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var sourceFiles = Directory.GetFiles(folder, oldBaseName + ".*")
.Where(file => string.Equals(
Path.GetFileNameWithoutExtension(file),
oldBaseName,
StringComparison.OrdinalIgnoreCase))
.ToList();

var target = Path.Combine(folder, newBaseName + Path.GetExtension(sidecar));
File.Move(sidecar, target, overwrite: sameFileDifferentCase);
if (!sourceFiles.Any(file => string.Equals(file, oldFileName, StringComparison.OrdinalIgnoreCase)))
{
sourceFiles.Add(oldFileName);
}

File.Move(oldFileName, newFileName, overwrite: sameFileDifferentCase);
var moves = sourceFiles
.Select(source => new RenameMove(
source,
Path.Combine(folder, newBaseName + Path.GetExtension(source)),
Path.Combine(folder, $".se-voice-rename-{Guid.NewGuid():N}.tmp")))
.ToList();

// The prepared copy is keyed on the reference's file name; the next synthesis makes
// a fresh one under the new name, so the old one would only be an orphan.
var prepared = CloneReferenceTail.GetPreparedFileName(oldFileName);
foreach (var stale in new[] { prepared, prepared + ".stamp" }.Where(File.Exists))
// Stage every source first. Besides making rollback possible, this makes a case-only
// rename portable: on a case-insensitive file system the destination stops existing
// once the source has been staged, while on a case-sensitive file system a distinct
// destination with the other casing remains and is detected below.
foreach (var move in moves)
{
File.Delete(stale);
File.Move(move.Source, move.Temp);
staged.Add(move);
}

Se.WriteToolsLog($"TTS voice renamed: '{oldFileName}' -> '{newFileName}'");
return newFileName;
foreach (var move in moves)
{
if (File.Exists(move.Target))
{
throw new IOException($"A voice file named '{Path.GetFileName(move.Target)}' already exists");
}
}

foreach (var move in moves)
{
File.Move(move.Temp, move.Target);
published.Add(move);
}
}
catch (Exception ex)
{
RollbackRename(staged, published);
Se.LogError(ex, $"Renaming TTS voice '{oldFileName}' to '{newFileName}' failed");
error = ex.Message;
return null;
}

// The prepared copy is keyed on the reference's file name; the next synthesis makes a
// fresh one under the new name. Cache cleanup is best-effort and must not roll back an
// otherwise successful rename.
var prepared = CloneReferenceTail.GetPreparedFileName(oldFileName);
foreach (var stale in new[] { prepared, prepared + ".stamp" }.Where(File.Exists))
{
try
{
File.Delete(stale);
}
catch (Exception ex)
{
Se.LogError(ex, $"Removing stale TTS voice cache '{stale}' failed");
}
}

Se.WriteToolsLog($"TTS voice renamed: '{oldFileName}' -> '{newFileName}'");
return newFileName;
}

private static void RollbackRename(List<RenameMove> staged, List<RenameMove> published)
{
// Published case-only paths can alias their original source on case-insensitive file
// systems. Move them through a unique temporary path so restoring the original casing is
// reliable without ever overwriting another voice.
for (var i = published.Count - 1; i >= 0; i--)
{
var move = published[i];
if (!File.Exists(move.Target))
{
continue;
}

var rollbackTemp = Path.Combine(
Path.GetDirectoryName(move.Source) ?? string.Empty,
$".se-voice-rename-rollback-{Guid.NewGuid():N}.tmp");
try
{
File.Move(move.Target, rollbackTemp);
File.Move(rollbackTemp, move.Source);
}
catch (Exception ex)
{
Se.LogError(ex, $"Rolling back TTS voice rename '{move.Target}' -> '{move.Source}' failed");
}
}

for (var i = staged.Count - 1; i >= 0; i--)
{
var move = staged[i];
if (!File.Exists(move.Temp))
{
continue;
}

try
{
File.Move(move.Temp, move.Source);
}
catch (Exception ex)
{
Se.LogError(ex, $"Restoring staged TTS voice file '{move.Source}' failed");
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,68 @@ public void Rename_RefusesExistingNameAndLeavesFilesAlone()
Assert.True(File.Exists(Path.Combine(_folder, "B.wav")));
}

[Fact]
public void Rename_SidecarCollision_RollsBackAllStagedFiles()
{
var voice = MakeVoice("A");
File.WriteAllText(Path.Combine(_folder, "A.txt"), "transcript");
File.WriteAllText(Path.Combine(_folder, "A.json"), "metadata");
File.WriteAllText(Path.Combine(_folder, "B.json"), "existing");

var result = VoiceFileRename.Rename(voice, "B", out var error);

Assert.Null(result);
Assert.NotEqual(string.Empty, error);
Assert.True(File.Exists(Path.Combine(_folder, "A.wav")));
Assert.Equal("transcript", File.ReadAllText(Path.Combine(_folder, "A.txt")));
Assert.Equal("metadata", File.ReadAllText(Path.Combine(_folder, "A.json")));
Assert.Equal("existing", File.ReadAllText(Path.Combine(_folder, "B.json")));
Assert.False(File.Exists(Path.Combine(_folder, "B.wav")));
Assert.False(File.Exists(Path.Combine(_folder, "B.txt")));
Assert.Empty(Directory.GetFiles(_folder, ".se-voice-rename-*.tmp"));
}

[Fact]
public void Rename_CaseOnlyRename_SucceedsWithoutOverwrite()
{
var voice = MakeVoice("CaseVoice");
File.WriteAllText(Path.Combine(_folder, "CaseVoice.txt"), "transcript");

var result = VoiceFileRename.Rename(voice, "caseVoice", out var error);

Assert.Equal(string.Empty, error);
Assert.Equal(Path.Combine(_folder, "caseVoice.wav"), result);
Assert.Contains(
Directory.GetFiles(_folder),
file => string.Equals(Path.GetFileName(file), "caseVoice.wav", StringComparison.Ordinal));
Assert.Contains(
Directory.GetFiles(_folder),
file => string.Equals(Path.GetFileName(file), "caseVoice.txt", StringComparison.Ordinal));
}

[Fact]
public void Rename_CaseOnlyCollision_OnCaseSensitiveFileSystem_PreservesBothVoices()
{
var upper = MakeVoice("CaseVoice");
MakeVoice("caseVoice");

if (Directory.GetFiles(_folder, "*.wav").Length < 2)
{
Assert.Skip("The current file system is case-insensitive.");
}

File.WriteAllBytes(Path.Combine(_folder, "CaseVoice.wav"), new byte[] { 1, 2, 3 });
File.WriteAllBytes(Path.Combine(_folder, "caseVoice.wav"), new byte[] { 4, 5, 6 });

var result = VoiceFileRename.Rename(upper, "caseVoice", out var error);

Assert.Null(result);
Assert.NotEqual(string.Empty, error);
Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(Path.Combine(_folder, "CaseVoice.wav")));
Assert.Equal(new byte[] { 4, 5, 6 }, File.ReadAllBytes(Path.Combine(_folder, "caseVoice.wav")));
Assert.Empty(Directory.GetFiles(_folder, ".se-voice-rename-*.tmp"));
}

[Fact]
public void Rename_RefusesEmptyAndInvalidNames()
{
Expand Down
Loading