From 22e7ea87db4cddf76db79ed104d50a7b869efe1e Mon Sep 17 00:00:00 2001 From: qwqcode Date: Thu, 14 Nov 2024 22:07:43 +0800 Subject: [PATCH] feat: support automatic subtitle synchronization - Implement SubSyncService to manage FFsubsync binary operations - Add methods for bootstrapping, executing, and shutting down the service - Integrate download functionality for FFsubsync binary - Update SettingsWindow to include SubSync settings and download options - Enhance UI with status indicators for SubSync binary installation --- SubRenamer/App.axaml | 4 +- SubRenamer/App.axaml.cs | 33 +++- SubRenamer/Assets/Lang/en-US.axaml | 19 +- SubRenamer/Assets/Lang/ja-JP.axaml | 19 +- SubRenamer/Assets/Lang/zh-Hans.axaml | 19 +- SubRenamer/Assets/Lang/zh-Hant.axaml | 19 +- SubRenamer/Helper/DownloadHelper.cs | 57 ++++++ SubRenamer/Helper/ExternalProgram.cs | 216 +++++++++++++++++++++ SubRenamer/Helper/JsonHelper.cs | 1 + SubRenamer/Model/IFilesService.cs | 2 + SubRenamer/Model/IRenameService.cs | 4 +- SubRenamer/Model/ISubSyncService.cs | 31 +++ SubRenamer/Services/FilesService.cs | 6 +- SubRenamer/Services/RenameService.cs | 36 ++-- SubRenamer/Services/SubSyncService.cs | 180 +++++++++++++++++ SubRenamer/SubRenamer.csproj | 21 +- SubRenamer/ViewModels/MainViewModel.cs | 75 ++++++- SubRenamer/ViewModels/SettingsViewModel.cs | 39 ++++ SubRenamer/Views/MainWindow.axaml | 28 ++- SubRenamer/Views/MainWindow.axaml.cs | 6 +- SubRenamer/Views/SettingsWindow.axaml | 26 ++- 21 files changed, 796 insertions(+), 45 deletions(-) create mode 100644 SubRenamer/Helper/DownloadHelper.cs create mode 100644 SubRenamer/Helper/ExternalProgram.cs create mode 100644 SubRenamer/Model/ISubSyncService.cs create mode 100644 SubRenamer/Services/SubSyncService.cs diff --git a/SubRenamer/App.axaml b/SubRenamer/App.axaml index 8d951a9..e6d1a02 100644 --- a/SubRenamer/App.axaml +++ b/SubRenamer/App.axaml @@ -13,11 +13,11 @@ + - - + diff --git a/SubRenamer/App.axaml.cs b/SubRenamer/App.axaml.cs index c1e2771..c206f78 100644 --- a/SubRenamer/App.axaml.cs +++ b/SubRenamer/App.axaml.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.IO; +using System.Linq; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; @@ -61,6 +62,7 @@ public override void OnFrameworkInitializationCompleted() services.AddSingleton(x => new ClipboardService(desktop.MainWindow)); services.AddSingleton(x => new ImportService(desktop.MainWindow)); services.AddSingleton(x => new RenameService(desktop.MainWindow)); + services.AddSingleton(x => new SubSyncService(desktop.MainWindow)); services.AddSingleton(x => new WindowService(desktop.MainWindow, OnSetTopmost)); Services = services.BuildServiceProvider(); @@ -126,7 +128,7 @@ private void MenuSetting_OnClick(object? sender, EventArgs e) Current?.Services?.GetService()?.OpenSettings(); } - private static void _afterInitTasks(MainViewModel? mainWindowStore) + private static void _afterInitTasks(MainViewModel store) { IssueReporter.CheckCrashAndShowDialog(); @@ -139,10 +141,10 @@ private static void _afterInitTasks(MainViewModel? mainWindowStore) try { var updateSrc = await Updater.GetUpdatesAsync(); - if (updateSrc != null && mainWindowStore != null) + if (updateSrc != null) { - mainWindowStore.CurrVersionText += " " + Application.Current.GetResource("App.Strings.MenuUpdateAlert"); - mainWindowStore.CurrVersionBtnLink = updateSrc; + store.CurrVersionText += " " + Application.Current.GetResource("App.Strings.MenuUpdateAlert"); + store.CurrVersionBtnLink = updateSrc; } } catch (Exception e) @@ -165,6 +167,29 @@ private static void _afterInitTasks(MainViewModel? mainWindowStore) Console.WriteLine(e); } }); + + Task.Run(async () => + { + var subSyncService = Current?.Services?.GetService()!; + subSyncService.OnBootstrapped += () => + { + store.SubSyncAvailable = true; + }; + subSyncService.OnShutdown += () => + { + store.SubSyncAvailable = false; + store.SubSyncEnabled = false; + }; + try + { + await subSyncService.Bootstrap(); + } + catch (Exception) + { + store.SubSyncAvailable = false; + store.SubSyncEnabled = false; + } + }); } } } diff --git a/SubRenamer/Assets/Lang/en-US.axaml b/SubRenamer/Assets/Lang/en-US.axaml index dce4cf8..863e83a 100644 --- a/SubRenamer/Assets/Lang/en-US.axaml +++ b/SubRenamer/Assets/Lang/en-US.axaml @@ -11,6 +11,7 @@ Rules Settings Preview + Sync Rename Settings... Quit SubRenamer @@ -32,6 +33,8 @@ Reveal Video in Folder Reveal Subtitle in Folder Copy Rename Commands to Clipboard + Perform Subtitle Timeline Synchronization + Exit Preview Mode Match Video Subtitle @@ -110,7 +113,13 @@ It can be modified to: "[Steins;Gate][$$]*.sc.ass" Video Format Extension e.g., mkv Check for Program Updates - |´・ω・)ノ Hi! This is an open-source program + Automatic Subtitles Sync: + Installed + Not installed + Support FFsubsync + FFmpeg automatic subtitles timeline synchronization, additional download is required, and the ffsubsync_bin file in the current program directory is read by default + Download + View Readme + |´・ω・)ノ Hi! SubRenamer is an open-source program You can find the source code on GitHub , please consider giving it a star 🌟, it would help us a lot! @@ -123,4 +132,12 @@ It can be modified to: "[Steins;Gate][$$]*.sc.ass" Select and Import Files Import Folder Save File + All tasks have been completed. + Task execution failed. + Duration + FFsubsync automatic timeline synchronization + Downloading FFsubsync... + FFsubsync download successful + Cancel + Done \ No newline at end of file diff --git a/SubRenamer/Assets/Lang/ja-JP.axaml b/SubRenamer/Assets/Lang/ja-JP.axaml index 03f43ec..a7b93be 100644 --- a/SubRenamer/Assets/Lang/ja-JP.axaml +++ b/SubRenamer/Assets/Lang/ja-JP.axaml @@ -11,6 +11,7 @@ ルール 設定 プレビュー + 同期 リネーム 設定... SubRenamerを終了 @@ -32,6 +33,8 @@ フォルダでビデオファイルを見つける フォルダで字幕ファイルを見つける リネームコマンドをクリップボードにコピー + 字幕タイムライン同期を実行する + レビューモードを終了 マッチ ビデオ 字幕 @@ -110,7 +113,13 @@ ビデオフォーマット拡張 例: mkv プログラムの更新チェック - |´・ω・)ノ こんにちは!これはオープンソースプログラムです + 自動タイムライン同期: + インストール済み + インストールされていません + FFsubsync + FFmpeg 自動軸調整をサポートし、追加のダウンロードが必要で、デフォルトで現在のプログラム ディレクトリにある ffsubsync_bin ファイルを読み取ります + ダウンロード + 手順を表示 + |´・ω・)ノ こんにちは! SubRenamer はオープン ソース プログラムです。 あなたは GitHub でソースコードを見つけることができます。Star 🌟を付けていただけると嬉しいです! @@ -123,4 +132,12 @@ ファイルを選択してインポート フォルダをインポート ファイルを保存 + すべてのタスクが完了しました。 + タスクの実行に失敗しました。 + 処理時間 + FFsubsync の自動タイムライン同期 + FFsubsync をダウンロードしています... + FFsubsync のダウンロードに成功しました + キャンセル + 完了 diff --git a/SubRenamer/Assets/Lang/zh-Hans.axaml b/SubRenamer/Assets/Lang/zh-Hans.axaml index 39ea242..cf25b69 100644 --- a/SubRenamer/Assets/Lang/zh-Hans.axaml +++ b/SubRenamer/Assets/Lang/zh-Hans.axaml @@ -11,6 +11,7 @@ 规则 设置 预览 + 调轴 一键改名 设置... 退出 SubRenamer @@ -32,6 +33,8 @@ 在文件夹中找到视频文件 在文件夹中找到字幕文件 复制改名命令至剪切板 + 执行字幕自动调轴程序 + 退出预览模式 匹配 视频 字幕 @@ -110,7 +113,13 @@ 视频格式扩充 例如:mkv 程序升级检查 - |´・ω・)ノ 嗨!这是开源程序 + 自动调轴程序: + 已安装 + 未安装 + 支持 FFsubsync + FFmpeg 自动调轴,需额外下载,默认读取当前程序目录的 ffsubsync_bin 文件 + 下载 + 查看说明 + |´・ω・)ノ 嗨!SubRenamer 是开源程序 你可以在 GitHub 找到源代码,请考虑点个 Star 🌟这会对我们很有帮助! @@ -123,4 +132,12 @@ 选择并导入文件 导入文件夹 保存文件 + 所有任务已完成。 + 任务执行失败。 + 执行耗时: + FFsubsync 自动调轴程序 + 正在下载 FFsubsync... + FFsubsync 下载成功 + 取消 + 完成 \ No newline at end of file diff --git a/SubRenamer/Assets/Lang/zh-Hant.axaml b/SubRenamer/Assets/Lang/zh-Hant.axaml index 8dc4a68..2db6da5 100644 --- a/SubRenamer/Assets/Lang/zh-Hant.axaml +++ b/SubRenamer/Assets/Lang/zh-Hant.axaml @@ -11,6 +11,7 @@ 規則 設定 預覽 + 調軸 一鍵改名 設定... 退出 SubRenamer @@ -32,6 +33,8 @@ 在資料夾中找到影片檔案 在資料夾中找到字幕檔案 複製改名命令至剪貼簿 + 執行字幕自動調軸程式 + 退出預覽模式 匹配 影片 字幕 @@ -110,7 +113,13 @@ 影片格式擴充 例如:mkv 程式升級檢查 - |´・ω・)ノ 嗨!這是開源程式 + 自動調軸程式: + 已安裝 + 未安裝 + 支援 FFsubsync + FFmpeg 自動調軸,需額外下載,預設讀取目前程式目錄的 ffsubsync_bin 檔案 + 下載 + 查看說明 + |´・ω・)ノ 嗨!SubRenamer 是開源程式 你可以在 GitHub 找到源碼,請考慮點個 Star 🌟這會對我們很有幫助! @@ -123,4 +132,12 @@ 選擇並匯入檔案 匯入資料夾 保存檔案 + 所有任務已完成。 + 任務執行失敗。 + 執行耗時: + FFsubsync 自動調軸程式 + 正在下載 FFsubsync... + FFsubsync 下載成功 + 取消 + 完成 diff --git a/SubRenamer/Helper/DownloadHelper.cs b/SubRenamer/Helper/DownloadHelper.cs new file mode 100644 index 0000000..da78ad9 --- /dev/null +++ b/SubRenamer/Helper/DownloadHelper.cs @@ -0,0 +1,57 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace SubRenamer.Helper; + +public static class DownloadHelper +{ + public static async Task DownloadFileAsync(string url, string path, Action? onProgress = null, CancellationToken cancellationToken = default) + { + using var httpClient = new HttpClient(); + using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + var total = response.Content.Headers.ContentLength; + + await using var content = await response.Content.ReadAsStreamAsync(cancellationToken); + var totalRead = 0L; + var buffer = new byte[8192]; + var isMoreToRead = true; + + await using var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true); + do + { + var bytesRead = await content.ReadAsync(buffer, cancellationToken); + if (bytesRead == 0) + { + isMoreToRead = false; + continue; + } + + await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); + totalRead += bytesRead; + + if (total.HasValue) + { + string totalSize = HumanReadableSize(total.Value); + string downloadedSize = HumanReadableSize(totalRead); + Console.WriteLine($"Downloaded {totalRead * 100 / total.Value}%, {downloadedSize}/{totalSize}"); + onProgress?.Invoke((int)(totalRead * 100 / total.Value), downloadedSize, totalSize); + } + } while (isMoreToRead); + } + + public static string HumanReadableSize(long size) + { + return size switch + { + < 1024 => $"{size} B", + < 1024 * 1024 => $"{size / 1024} KB", + < 1024 * 1024 * 1024 => $"{size / 1024 / 1024} MB", + _ => $"{size / 1024 / 1024 / 1024} GB" + }; + } +} \ No newline at end of file diff --git a/SubRenamer/Helper/ExternalProgram.cs b/SubRenamer/Helper/ExternalProgram.cs new file mode 100644 index 0000000..e376e7e --- /dev/null +++ b/SubRenamer/Helper/ExternalProgram.cs @@ -0,0 +1,216 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Avalonia.Threading; +using SubRenamer.ViewModels; +using SubRenamer.Views; + +namespace SubRenamer.Helper; + +public class ExternalProgram(string filename, string arguments) +{ + private ProgramProcess? _process; + private TerminalWin? _terminalWin; + + public event Action? OnLoaded; + + public async Task StartServer() + { + _process = CreateProcess(filename, arguments); + var (isReady, data) = await _process.WaitForStatus("ready", true); + if (!isReady) throw new Exception($"Server initialization failed, Data: {data}"); + _terminalWin = new TerminalWin(); + var handler = new DataReceivedEventHandler((sender, args) => + { + Dispatcher.UIThread.Post(() => + { + if (args.Data == null || args.Data.StartsWith(ServerResult.ServerJsonPrefix)) return; + _terminalWin?.Log(args.Data); + }); + }); + _process.OutputDataReceived += handler; + _process.ErrorDataReceived += handler; + OnLoaded?.Invoke(); + } + + public async Task<(bool isOK, string data)> Send(string text, string expectedStatus, bool fastExit = true) + { + if (_process == null) throw new Exception("Server not started"); + await _process.Send(text); + return await _process.WaitForStatus(expectedStatus, fastExit); + } + + public void Log(string text) + { + Dispatcher.UIThread.Post(() => _terminalWin?.Log(text)); + } + + public void SetAllowTerminalClose(bool allowClose) + { + _terminalWin?.SetAllowClose(allowClose); + } + + public void Dispose() + { + _process?.Dispose(); + } + + private class ProgramProcess + { + private Process _process; + + public event DataReceivedEventHandler? OutputDataReceived; + public event DataReceivedEventHandler? ErrorDataReceived; + + public ProgramProcess(Process process) + { + _process = process; + process.OutputDataReceived += OutputDataReceivedEventHandler; + process.ErrorDataReceived += ErrorDataReceivedEventHandler; + + // Begin asynchronous read + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + } + + private ServerResult? _lastResult; + private string? _lastDataRaw; + + private void OutputDataReceivedEventHandler(object sender, DataReceivedEventArgs e) + { + // trigger all OutputDataReceived events + OutputDataReceived?.Invoke(sender, e); + Console.WriteLine("[OutputDataReceived] " + e.Data); + + // parse server result + if (e.Data != null && e.Data.StartsWith(ServerResult.ServerJsonPrefix)) + { + var result = ParseResult(e.Data.Substring(ServerResult.ServerJsonPrefix.Length)); + _lastResult = result; + _lastDataRaw = e.Data; + } + } + + private void ErrorDataReceivedEventHandler(object sender, DataReceivedEventArgs e) + { + ErrorDataReceived?.Invoke(sender, e); + Console.WriteLine("[ErrorDataReceived] " + e.Data); + + _lastResult = new ServerResult + { + Status = "error", + Message = e.Data ?? "Unknown error" + }; + _lastDataRaw = e.Data ?? "Unknown error"; + } + + public async Task<(bool isOK, string data)> WaitForStatus(string status, bool fastExit = true, int timeoutMs = 0) + { + var timer = new Stopwatch(); + timer.Start(); + + while (_lastResult is null || (!fastExit && _lastResult.Status != status)) + { + await Task.Delay(100); + if (timeoutMs != 0 && timer.ElapsedMilliseconds < timeoutMs) + { + _lastResult = null; + _lastDataRaw = null; + return (false, "Timeout waiting for server"); + } + } + + var result = (_lastResult?.Status == status, _lastDataRaw ?? ""); + _lastResult = null; + _lastDataRaw = null; + return result; + } + + private ServerResult ParseResult(string json) + { + using var jsonStream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + return JsonHelper.ParseJsonSync(jsonStream) ?? new ServerResult(); + } + + public async Task Send(string text) + { + await _process.StandardInput.WriteLineAsync(text); + } + + public void Dispose() + { + _process.Kill(); + _process.Dispose(); + } + } + + private static ProgramProcess CreateProcess(string filename, string args) + { + // Initialize the process + var processStartInfo = new ProcessStartInfo + { + FileName = filename, + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + UseShellExecute = false, // To redirect output + CreateNoWindow = true + }; + + // Create the process + var proc = Process.Start(processStartInfo)!; + + return new ProgramProcess(proc); + } + + private class TerminalWin + { + private TerminalViewModel? _terminalViewModel; + private TerminalWindow? _terminalWindow; + private bool _allowClose = false; + + public void Log(string? text) + { + if (_terminalWindow == null) Init(); + _terminalViewModel?.WriteLine(text ?? ""); + } + + public void SetAllowClose(bool allowClose) + { + _allowClose = allowClose; + } + + private void Init() + { + _allowClose = false; + _terminalViewModel = new TerminalViewModel(); + _terminalWindow = new TerminalWindow + { + DataContext = _terminalViewModel + }; + _terminalWindow.Closing += (sender, args) => + { + if (!_allowClose) args.Cancel = true; + }; + _terminalWindow.Closed += (sender, args) => + { + _terminalWindow = null; + _terminalViewModel = null; + _allowClose = false; + }; + _terminalWindow.Show(); + } + } + + public class ServerResult + { + public const string ServerJsonPrefix = "[SERVER] "; + public string Status { get; set; } = ""; + public string Message { get; set; } = ""; + public string Command { get; set; } = ""; + } +} \ No newline at end of file diff --git a/SubRenamer/Helper/JsonHelper.cs b/SubRenamer/Helper/JsonHelper.cs index c911f91..dc2fc11 100644 --- a/SubRenamer/Helper/JsonHelper.cs +++ b/SubRenamer/Helper/JsonHelper.cs @@ -15,6 +15,7 @@ namespace SubRenamer.Helper; // https://learn.microsoft.com/zh-cn/dotnet/standard/serialization/system-text-json/source-generation?pivots=dotnet-8-0 [JsonSourceGenerationOptions(WriteIndented = true)] [JsonSerializable(typeof(Config))] +[JsonSerializable(typeof(ExternalProgram.ServerResult))] [JsonSerializable(typeof(GitHubRelease))] [JsonSerializable(typeof(GitHubReleaseAsset))] internal partial class SourceGenerationContext : JsonSerializerContext diff --git a/SubRenamer/Model/IFilesService.cs b/SubRenamer/Model/IFilesService.cs index b6969e8..802c612 100644 --- a/SubRenamer/Model/IFilesService.cs +++ b/SubRenamer/Model/IFilesService.cs @@ -7,6 +7,8 @@ namespace SubRenamer.Model; public interface IFilesService { public Task> OpenFilesAsync(); + + public Task> OpenFilesAsync(FilePickerFileType[] fileTypes); public Task OpenSingleFileAsync(); diff --git a/SubRenamer/Model/IRenameService.cs b/SubRenamer/Model/IRenameService.cs index 315ed24..ba7a55e 100644 --- a/SubRenamer/Model/IRenameService.cs +++ b/SubRenamer/Model/IRenameService.cs @@ -1,11 +1,13 @@ +using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Threading.Tasks; namespace SubRenamer.Model; public interface IRenameService { void UpdateRenameTaskList(IReadOnlyList matchList, Collection destList); - void ExecuteRename(IReadOnlyList taskList); + Task ExecuteRename(IReadOnlyList taskList); string GenerateRenameCommands(IReadOnlyList list); } \ No newline at end of file diff --git a/SubRenamer/Model/ISubSyncService.cs b/SubRenamer/Model/ISubSyncService.cs new file mode 100644 index 0000000..5890f06 --- /dev/null +++ b/SubRenamer/Model/ISubSyncService.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Threading.Tasks; + +namespace SubRenamer.Model; + +public interface ISubSyncService +{ + Task Bootstrap(); + + Task Shutdown(); + + event Action? OnBootstrapped; + + event Action? OnShutdown; + + bool GetIsAvailable(); + + bool GetIsBootstrapped(); + + string RetrieveExePath(); + + string? GetExePath(); + + Task ExecuteSubSync(IReadOnlyList taskList); + + Task ExecuteSubSync(IReadOnlyList<(string video, string subtitle)> taskList); + + Task DownloadFFsubsyncBin(); +} \ No newline at end of file diff --git a/SubRenamer/Services/FilesService.cs b/SubRenamer/Services/FilesService.cs index cf81d4f..6890207 100644 --- a/SubRenamer/Services/FilesService.cs +++ b/SubRenamer/Services/FilesService.cs @@ -23,14 +23,16 @@ public FilesService(Window target) { Patterns = GetVideoExtensions().Concat(GetSubtitleExtensions()).Select(x => $"*.{x}").ToArray(), }; + + public Task> OpenFilesAsync() => OpenFilesAsync([]); - public async Task> OpenFilesAsync() + public async Task> OpenFilesAsync(FilePickerFileType[] fileTypes) { var files = await _target.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions() { Title = Application.Current.GetResource("App.Strings.OpenFileDialogTitle"), AllowMultiple = true, - FileTypeFilter = new []{ VideosAndSubtitles }, + FileTypeFilter = fileTypes, }); return files; diff --git a/SubRenamer/Services/RenameService.cs b/SubRenamer/Services/RenameService.cs index 0bc181d..0a8f0df 100644 --- a/SubRenamer/Services/RenameService.cs +++ b/SubRenamer/Services/RenameService.cs @@ -4,6 +4,7 @@ using System.Collections.ObjectModel; using System.IO; using System.Linq; +using System.Threading.Tasks; using Avalonia.Controls; using SubRenamer.Helper; using SubRenamer.Model; @@ -13,7 +14,7 @@ namespace SubRenamer.Services; public class RenameService(Window target) : IRenameService { private readonly Window _target = target; - + public void UpdateRenameTaskList(IReadOnlyList matchList, Collection destList) { destList.Clear(); @@ -40,14 +41,17 @@ public void UpdateRenameTaskList(IReadOnlyList matchList, Collection< var subSplit = Path.GetFileNameWithoutExtension(item.Subtitle).Split('.'); if (subSplit.Length > 1) subSuffix = "." + subSplit[^1]; } - if (hasCustomLangExt) { + + if (hasCustomLangExt) + { // Custom appended suffix subSuffix += "." + customLangExt.TrimStart('.'); } // Splice new subtitle file path var videoFolder = Path.GetDirectoryName(item.Video) ?? ""; - var subFilename = Path.GetFileNameWithoutExtension(item.Video) + subSuffix + Path.GetExtension(item.Subtitle); + var subFilename = Path.GetFileNameWithoutExtension(item.Video) + subSuffix + + Path.GetExtension(item.Subtitle); var altered = Path.Combine(videoFolder, subFilename); // No need to alter @@ -65,18 +69,19 @@ public void UpdateRenameTaskList(IReadOnlyList matchList, Collection< }); } } - - public void ExecuteRename(IReadOnlyList taskList) + + public Task ExecuteRename(IReadOnlyList taskList) { var backupEnabled = Config.Get().Backup; - + // Record files that have been backed up, // avoid duplicate backups when mapping is one-to-many (video-subtitle) var filesHadBackup = new Dictionary(); - + foreach (var task in taskList) { - RenameTaskStatus?[] skipStatus = [RenameTaskStatus.Altered, RenameTaskStatus.NoNeed]; + RenameTaskStatus?[] skipStatus = + [RenameTaskStatus.Altered, RenameTaskStatus.NoNeed]; if (skipStatus.Contains(task.Status)) continue; try @@ -84,13 +89,13 @@ public void ExecuteRename(IReadOnlyList taskList) // Whether the origin and alter files are in the same folder // If they are, rename in-place; otherwise, copy the file var isSameFolder = Path.GetDirectoryName(task.Origin) == Path.GetDirectoryName(task.Alter); - + if (isSameFolder) { // Backup (only rename in-place) if (backupEnabled && filesHadBackup.TryAdd(task.Origin, true)) FileHelper.BackupFile(task.Origin); - + // Rename in-place (like mv in linux) FileHelper.RenameFile(task.Origin, task.Alter); } @@ -109,17 +114,22 @@ public void ExecuteRename(IReadOnlyList taskList) task.Status = RenameTaskStatus.Failed; } } + + return Task.CompletedTask; } public string GenerateRenameCommands(IReadOnlyList list) { var command = ""; - + foreach (var item in list) { var subtitle = !string.IsNullOrEmpty(item.Subtitle) ? item.Subtitle : "?"; - var video = !string.IsNullOrEmpty(item.Video) ? item.Video : "?"; - command += $"mv \"{subtitle.Replace("\"", "\\\"")}\" \"{video.Replace("\"", "\\\"")}\"\n"; + var alterSubtitle = !string.IsNullOrEmpty(item.Video) + ? Path.GetDirectoryName(item.Video) + Path.DirectorySeparatorChar + + Path.GetFileNameWithoutExtension(item.Video) + Path.GetExtension(subtitle) + : "?"; + command += $"mv \"{subtitle.Replace("\"", "\\\"")}\" \"{alterSubtitle.Replace("\"", "\\\"")}\"\n"; } return command.Trim(); diff --git a/SubRenamer/Services/SubSyncService.cs b/SubRenamer/Services/SubSyncService.cs new file mode 100644 index 0000000..b6fb0d9 --- /dev/null +++ b/SubRenamer/Services/SubSyncService.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using SubRenamer.Helper; +using SubRenamer.Model; + +namespace SubRenamer.Services; + +public class SubSyncService(Window target) : ISubSyncService +{ + public class BinaryNotFoundException() : Exception("FFsubsync binary not found"); + + private readonly Window _target = target; + private ExternalProgram? _externalProgram; + private bool _isBootstrapped = false; + private string? _exePath = ""; + public event Action? OnBootstrapped; + public event Action? OnShutdown; + + public bool GetIsAvailable() => _externalProgram != null; + public bool GetIsBootstrapped() => _isBootstrapped; + public string? GetExePath() => _exePath; + + public string RetrieveExePath() + { + var pathsForCheck = new[] + { + Path.Combine(AppContext.BaseDirectory, "ffsubsync_bin"), + Path.Combine(AppContext.BaseDirectory, "ffsubsync_bin.exe"), + Path.Combine(Config.ConfigDir, "ffsubsync_bin"), + Path.Combine(Config.ConfigDir, "ffsubsync_bin.exe"), + }; + return pathsForCheck.FirstOrDefault(File.Exists, ""); + } + + public async Task Bootstrap() + { + var exePath = RetrieveExePath(); + if (string.IsNullOrEmpty(exePath)) + { + Console.WriteLine("FFsubsync binary not found"); + throw new BinaryNotFoundException(); + } + + _exePath = exePath; + + try + { + _externalProgram = new ExternalProgram(exePath, "--server"); + _externalProgram.OnLoaded += () => _isBootstrapped = true; + _externalProgram.OnLoaded += () => OnBootstrapped?.Invoke(); + Console.WriteLine("Bootstrapping SubSync server..."); + await _externalProgram.StartServer(); + } + catch (Exception e) + { + MessageBoxHelper.ShowError($"Failed to launch SubSync server:\n\n{e.Message}\n\n{e.StackTrace}"); + await Shutdown(); + throw; + } + } + + public Task Shutdown() + { + if (_externalProgram == null) return Task.CompletedTask; + _isBootstrapped = false; + _externalProgram?.Dispose(); + _externalProgram = null; + _exePath = null; + OnShutdown?.Invoke(); + return Task.CompletedTask; + } + + public async Task WaitForLoaded() + { + if (!_isBootstrapped) + { + var stopwatch = Stopwatch.StartNew(); + while (!_isBootstrapped) + { + if (stopwatch.ElapsedMilliseconds > 20 * 1000) + { + throw new Exception("Server initialization timeout"); + } + + await Task.Delay(100); + } + } + } + + public async Task ExecuteSubSync(IReadOnlyList taskList) + { + await ExecuteSubSync(taskList.Where(x => x.Status != RenameTaskStatus.Failed) + .Select(x => (x.MatchItem?.Video ?? "", x.Alter)).ToList()); + } + + public async Task ExecuteSubSync(IReadOnlyList<(string video, string subtitle)> taskList) + { + if (_externalProgram == null) throw new Exception("External program not initialized"); + await WaitForLoaded(); + + foreach (var item in taskList) + { + await AddPostTaskQueue(item.video, item.subtitle); + } + + var timeDuring = Stopwatch.StartNew(); + var (ready, data) = await _externalProgram.Send("start", "ready", false); + timeDuring.Stop(); + _externalProgram.Log((ready + ? $"\ud83d\ude09 {Application.Current.GetResource("App.Strings.SubSyncTasksComplete")}" + : $"\ud83e\udd72 {Application.Current.GetResource("App.Strings.SubSyncTasksFail")} Data: {data}") + + $" [{Application.Current.GetResource("App.Strings.SubSyncTasksDuration")}{timeDuring.ElapsedMilliseconds}ms]\n"); + await Task.Delay(300); + _externalProgram.SetAllowTerminalClose(true); + } + + private async Task AddPostTaskQueue(string video, string subtitle) + { + if (_externalProgram == null) throw new Exception("External program not initialized"); + + // var postTask = Config.Get().SubSyncCommand; + var postTask = "\"{video}\" -i \"{subtitle}\" --overwrite-input"; + if (string.IsNullOrEmpty(postTask)) return; + + var command = postTask + .Replace("{subtitle}", subtitle) + .Replace("{video}", video); + + var (added, data) = await _externalProgram.Send("add:" + command, "added"); + if (!added) _externalProgram.Log($"Add post task to queue failed, Data: {data}"); + } + + public async Task DownloadFFsubsyncBin() + { + var path = Path.Combine(Config.ConfigDir, "ffsubsync_bin"); + var url = + $"https://github.com/qwqcode/ffsubsync-bin/releases/latest/download/ffsubsync_bin_{SystemInfo.GetOSArchPair().Replace("windows", "win")}"; + var dialog = MessageBoxHelper.ShowProgress(_target, + Application.Current.GetResource("App.Strings.SubSyncBinDownloadTitle") ?? "", + Application.Current.GetResource("App.Strings.SubSyncBinDownloadDesc") ?? ""); + + var tokenSource = new CancellationTokenSource(); + dialog.OnAbort += () => tokenSource.Cancel(); + + try + { + await DownloadHelper.DownloadFileAsync(url, path, (progress, downloadedSize, totalSize) => + { + dialog.Update(progress, progress >= 100); + dialog.Desc = $"{Application.Current.GetResource("App.Strings.SubSyncBinDownloadDesc")} [{downloadedSize}/{totalSize}]"; + }, + tokenSource.Token); + + // add execute permission for unix + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + File.SetUnixFileMode(path, UnixFileMode.UserExecute | UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + catch (TaskCanceledException) + { + throw new TaskCanceledException("Download canceled"); + } + catch (Exception e) + { + MessageBoxHelper.ShowError($"Failed to download ffsubsync binary: {e.Message}"); + throw; + } + + dialog.Desc = $"{Application.Current.GetResource("App.Strings.SubSyncBinDownloadDone")}"; + } +} \ No newline at end of file diff --git a/SubRenamer/SubRenamer.csproj b/SubRenamer/SubRenamer.csproj index 2e4d347..fd4572f 100644 --- a/SubRenamer/SubRenamer.csproj +++ b/SubRenamer/SubRenamer.csproj @@ -1,7 +1,7 @@ SubRenamer - 2.2.0 + 2.3.0 WinExe net8.0 Assets\icon.ico @@ -51,17 +51,18 @@ - - - + + + - - - - + + + + - - + + + diff --git a/SubRenamer/ViewModels/MainViewModel.cs b/SubRenamer/ViewModels/MainViewModel.cs index 30bd62b..9bfee1a 100644 --- a/SubRenamer/ViewModels/MainViewModel.cs +++ b/SubRenamer/ViewModels/MainViewModel.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; @@ -5,6 +6,7 @@ using System.Threading.Tasks; using Avalonia; using Avalonia.Platform.Storage; +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DynamicData; @@ -20,6 +22,7 @@ namespace SubRenamer.ViewModels; public partial class MainViewModel : ViewModelBase { + [ObservableProperty] private bool _allowExecute = true; [ObservableProperty] private ObservableCollection _matchList = []; [ObservableProperty] private Collection _selectedItems = []; [ObservableProperty] private ObservableCollection _renameTasks = []; @@ -30,6 +33,7 @@ public partial class MainViewModel : ViewModelBase private static IDialogService GetDialogService() => App.Current!.Services!.GetService()!; private static IFilesService GetFilesService() => App.Current!.Services!.GetService()!; private static IRenameService GetRenameService() => App.Current!.Services!.GetService()!; + private static ISubSyncService GetSubSyncService() => App.Current!.Services!.GetService()!; private static IImportService GetImportService() => App.Current!.Services!.GetService()!; #endregion @@ -72,7 +76,7 @@ private void EditItem() => _ = SelectedItems.FirstOrDefault(item => */ [RelayCommand] private async Task OpenFile() => - await Import(await GetFilesService().OpenFilesAsync()); + await Import(await GetFilesService().OpenFilesAsync([ FilesService.VideosAndSubtitles ])); /** * Open a folder @@ -126,13 +130,42 @@ partial void OnShowRenameTasksChanged(bool value) * Perform Rename Task */ [RelayCommand] - private void PerformRename() + private void Run() { ShowRenameTasks = true; - GetRenameService().ExecuteRename(RenameTasks); + Task.Run(async () => + { + AllowExecute = false; + + try + { + // Execute Rename + await GetRenameService().ExecuteRename(RenameTasks); + + // Execute SubSync + if (SubSyncAvailable && SubSyncEnabled) + { + await GetSubSyncService().ExecuteSubSync(RenameTasks); + } + } + catch (Exception e) + { + MessageBoxHelper.ShowError($"Failed to execute: {e.Message}\n\n{e.StackTrace}"); + } + + AllowExecute = true; + }); } #endregion + + #region SubSync + [ObservableProperty] private bool _subSyncAvailable = true; + [ObservableProperty] private bool _subSyncEnabled = true; + + // partial void OnSubSyncEnabledChanged(bool value) + // => AllowExecute = !value || SubSyncServerLoaded; + #endregion #region Match /** @@ -186,6 +219,36 @@ private void CopyCommands() GetRenameService().GenerateRenameCommands(MatchList)); } + /** + * Perform subtitle sync + */ + [RelayCommand] + private void PerformSubSyncSelected() + { + if (SelectedItems.Count == 0) return; + var list = SelectedItems.Select(x => x.Status switch + { + MatchItemStatus.Altered => (x.Video, + RenameTasks.FirstOrDefault(y => y.MatchItem == x)?.Alter ?? ""), + _ => (x.Video, x.Subtitle), + }).Where(x => x.Item1 != "" && x.Item2 != "").ToList(); + if (list.Count == 0) return; + + Task.Run(async () => + { + AllowExecute = false; + try + { + await GetSubSyncService().ExecuteSubSync(list); + } + catch (Exception e) + { + MessageBoxHelper.ShowError($"Failed to execute: {e.Message}\n\n{e.StackTrace}"); + } + AllowExecute = true; + }); + } + /** * Reveal file in folder */ @@ -201,6 +264,9 @@ private void RevealFileInFolder(string type) => }); return false; }); + + [RelayCommand] + private void ExitPreviewMode() => ShowRenameTasks = false; #endregion #region MenuBar @@ -219,6 +285,9 @@ public void SyncCurrentStatusText() => MatchMode.Regex => Application.Current.GetResource("App.Strings.RulesRegexMatch") ?? "Regex", _ => "" }; + + public void SyncSubSyncStatus() => + SubSyncAvailable = GetSubSyncService().GetIsAvailable(); /** * Open version link diff --git a/SubRenamer/ViewModels/SettingsViewModel.cs b/SubRenamer/ViewModels/SettingsViewModel.cs index ac64f3b..c1e4f10 100644 --- a/SubRenamer/ViewModels/SettingsViewModel.cs +++ b/SubRenamer/ViewModels/SettingsViewModel.cs @@ -1,11 +1,15 @@ using System; +using System.IO; using System.Linq; using Avalonia; using Avalonia.Markup.Xaml.Styling; +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using SubRenamer.Helper; using DynamicData; +using Microsoft.Extensions.DependencyInjection; +using SubRenamer.Model; namespace SubRenamer.ViewModels; @@ -19,11 +23,17 @@ public partial class SettingsViewModel : ViewModelBase private string _customLangExt = Config.Get().CustomLangExt; private string _videoExtAppend = Config.Get().VideoExtAppend; private string _subtitleExtAppend = Config.Get().SubtitleExtAppend; + private bool _subSyncExeDownloaded = false; /// Whether enabled the custom extension appending to classify the video and subtitle files private bool _fileClsExtAppendEnabled = !string.IsNullOrEmpty(Config.Get().VideoExtAppend) || !string.IsNullOrEmpty(Config.Get().SubtitleExtAppend); + public SettingsViewModel() + { + _subSyncExeDownloaded = !string.IsNullOrEmpty(GetSubSyncService().RetrieveExePath()); + } + public bool BackupEnabled { get => _backupEnabled; @@ -119,8 +129,37 @@ public bool FileClsExtAppendEnabled } } + public bool SubSyncExeDownloaded + { + get => _subSyncExeDownloaded; + set => SetProperty(ref _subSyncExeDownloaded, value); + } + [RelayCommand] private void OpenLink(string url) => BrowserHelper.OpenBrowserAsync(url); + + private ISubSyncService GetSubSyncService() => App.Current!.Services!.GetService()!; + + [RelayCommand] + private void DownloadSubSyncExe() + { + Dispatcher.UIThread.Post(async () => + { + try + { + await GetSubSyncService().Shutdown(); + await GetSubSyncService().DownloadFFsubsyncBin(); + Console.Write("FFsubsync binary downloaded"); + SubSyncExeDownloaded = true; + + await GetSubSyncService().Bootstrap(); + } + catch (Exception) + { + SubSyncExeDownloaded = false; + } + }); + } #region Language public static string[] LanguageNames { get; } = I18NHelper.LanguageNames; diff --git a/SubRenamer/Views/MainWindow.axaml b/SubRenamer/Views/MainWindow.axaml index 0095bd8..7000a77 100644 --- a/SubRenamer/Views/MainWindow.axaml +++ b/SubRenamer/Views/MainWindow.axaml @@ -14,6 +14,7 @@ x:Class="SubRenamer.Views.MainWindow" Icon="/Assets/icon.ico" Title="{DynamicResource App.Strings.AppTitle}" + xmlns:li="using:LoadingIndicators.Avalonia" DragDrop.AllowDrop="True"> @@ -143,6 +144,7 @@ + @@ -162,6 +164,12 @@ BorderBrush="{StaticResource SystemControlTransientBorderBrush}" IsVisible="{Binding ShowRenameTasks}"> + + + + + + @@ -208,10 +216,24 @@ diff --git a/SubRenamer/Views/MainWindow.axaml.cs b/SubRenamer/Views/MainWindow.axaml.cs index aa1128a..dc9538c 100644 --- a/SubRenamer/Views/MainWindow.axaml.cs +++ b/SubRenamer/Views/MainWindow.axaml.cs @@ -42,7 +42,11 @@ private async void OnDrop(object? sender, DragEventArgs e) private void OnActivated(object? sender, EventArgs args) { - if (DataContext is MainViewModel store) store.SyncCurrentStatusText(); + if (DataContext is MainViewModel store) + { + store.SyncCurrentStatusText(); + store.SyncSubSyncStatus(); + } } private void DataGrid_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) diff --git a/SubRenamer/Views/SettingsWindow.axaml b/SubRenamer/Views/SettingsWindow.axaml index ebea720..4315122 100644 --- a/SubRenamer/Views/SettingsWindow.axaml +++ b/SubRenamer/Views/SettingsWindow.axaml @@ -75,8 +75,30 @@ - - + + + + + + + + + + + + + + + +