From 1ee47d2a8444e0c25be087e0fa929bf401a9f5ac Mon Sep 17 00:00:00 2001 From: UnknownException <25252012+UnknownException@users.noreply.github.com> Date: Sun, 2 Mar 2025 21:41:11 +0100 Subject: [PATCH 01/25] Modified release-windows for automated releases Zips the files into 'CUETools.CTDB.EACPlugin' and 'CUETools' and generates SHA256 hashes for the zip-containers. Added CHANGELOG.md to the root of the project --- .github/workflows/release-windows.yml | 26 ++++++++++++++++++--- CHANGELOG.md | 32 ++++++++++++++++++++++++++ packaging.ps1 | 33 +++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 packaging.ps1 diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index fd3284e8..561eff74 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -53,7 +53,27 @@ jobs: - name: Collect files run: | collect_files.bat - - uses: actions/upload-artifact@v4 + - name: Package files + run: pwsh -File ./packaging.ps1 + - name: Get CUETools Version + id: get_version + run: | + for /f "tokens=7 delims= " %%a in ('find "CUEToolsVersion =" .\CUETools.Processor\CUESheet.cs') do set PRODUCTVER=%%a + set PRODUCTVER=%PRODUCTVER:"=% + set PRODUCTVER=%PRODUCTVER:;=% + echo CUETools version: %PRODUCTVER% + echo CUETOOLS_VERSION=%PRODUCTVER%>>%GITHUB_ENV% + shell: cmd + - name: Add artifacts to Release + uses: softprops/action-gh-release@v2 with: - name: deploy - path: bin/Release/CUETools_*/ + name: CUETools ${{ env.CUETOOLS_VERSION }} + body_path: CHANGELOG.md + draft: false + prerelease: false + tag_name: ${{ github.ref_name }} + files: | + CUETools_${{ env.CUETOOLS_VERSION }}.zip + CUETools_${{ env.CUETOOLS_VERSION }}.zip.sha256 + CUETools.CTDB.EACPlugin.${{ env.CUETOOLS_VERSION }}.zip + CUETools.CTDB.EACPlugin.${{ env.CUETOOLS_VERSION }}.zip.sha256 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..7dfa4c0d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +Release date: 2024-06-28 + +**Version 2.2.6 Prerequisites** +- Microsoft .NET Framework 4.7 - preinstalled in Windows 10 (since version 1703) +- Microsoft Visual C++ Redistributable for Visual Studio 2015, 2017, 2019 and 2022 - needed for some plugins + +Further information concerning installation: +http://cue.tools/wiki/CUETools_Download + +**CUETools 2.2.6 Changelog** + +- Update MAC_SDK from 10.37 to 10.74 +- EACPlugin: Allow coverart search to be stopped +- Fix C2 mode for further drives: + LG GH24NSD5, LITEON DH-20A4P +- Preserve encoding of localized EAC log files +- Add setting for UTF-8-BOM + settings.txt: WriteUTF8BOM + The setting is enabled by default, to preserve previous behavior +- CUETools, Correct filenames: Support UTF-8 +- CUERipper: Fix incorrect TOC entry of first track +- CUERipper: Add setting to force ReadCDCommand + settings.txt: 0 (ReadCdBEh), 1 (ReadCdD8h), 2 (Unknown/AutoDetect) + Default: 2 +- Add WavPack 5.7.0 encoder multithreading support +- Update WavPack from 5.6.0 to 5.7.0 +- Avoid short HTOA files +- CUERipper: Detect too large album art earlier + +**Links to ffmpeg dlls (from [v2.2.5](https://github.com/gchudov/cuetools.net/releases/tag/v2.2.5)):** +- [ffmpeg_6.1_dlls_win32.zip](https://github.com/gchudov/cuetools.net/releases/download/v2.2.5/ffmpeg_6.1_dlls_win32.zip) +- [ffmpeg_6.1_dlls_x64.zip](https://github.com/gchudov/cuetools.net/releases/download/v2.2.5/ffmpeg_6.1_dlls_x64.zip) \ No newline at end of file diff --git a/packaging.ps1 b/packaging.ps1 new file mode 100644 index 00000000..2d0be6d6 --- /dev/null +++ b/packaging.ps1 @@ -0,0 +1,33 @@ +$ErrorActionPreference = "Stop" + +$cueToolsDirectories = Get-ChildItem -Path "bin/Release/CUETools_*" -Directory | + Where-Object { $_.Name -match "CUETools_(\d+\.\d+\.\d+)" } | + Sort-Object { [version]($matches[1]) } -Descending + +if (-not $cueToolsDirectories) { + Write-Error "No CUETools_* directories found!" +} + +$cueToolsDir = $cueToolsDirectories[0] +$version = $cueToolsDir.Name -replace "CUETools_", "" + +Write-Output "Detected CUETools version: $version" + +$eacZip = "CUETools.CTDB.EACPlugin.$version.zip" +$cuetoolsZip = "CUETools_$version.zip" + +Write-Output "Creating EAC Zip" +Compress-Archive -Path bin/Release/$($cueToolsDir.Name)/interop/ -DestinationPath $eacZip -Force + +Write-Output "Creating CUETools Zip" +Compress-Archive -Path bin/Release/$($cueToolsDir.Name)/ -DestinationPath $cuetoolsZip -Force + +# Generate a hashfile using the same format as previous releases +Write-Output "Generating SHA256 hashes..." +$eacHash = (Get-FileHash $eacZip -Algorithm SHA256).Hash.ToLower() +"$eacHash *$eacZip" | Out-File -Encoding ASCII "$eacZip.sha256" + +$cuetoolsHash = (Get-FileHash $cuetoolsZip -Algorithm SHA256).Hash.ToLower() +"$cuetoolsHash *$cuetoolsZip" | Out-File -Encoding ASCII "$cuetoolsZip.sha256" + +Write-Output "Packaging complete." \ No newline at end of file From a64bbc00cf8495d58b4fd76e508b3b24c5cb961e Mon Sep 17 00:00:00 2001 From: UnknownException <25252012+UnknownException@users.noreply.github.com> Date: Sat, 8 Mar 2025 12:17:39 +0100 Subject: [PATCH 02/25] Initial implementation for Linux compatibility Added basic Linux support by porting libraries to .NET Standard 2.0. BwgBurn's SCSI commands, originally relying on DeviceIoControl in WinDev.cs, are handled by LinDev.cs using IOCTL when running on Linux. Windows structures are translated at runtime to their Linux counterparts, limited to what's actually used by BwgBurn. --- Bwg.Logging/Bwg.Logging.csproj | 2 +- Bwg.Scsi/Bwg.Scsi.csproj | 6 +- Bwg.Scsi/Command.cs | 15 + Bwg.Scsi/Device.cs | 63 ++-- Bwg.Scsi/DeviceManager.cs | 6 +- Bwg.Scsi/ISysDev.cs | 15 + Bwg.Scsi/LinDev.cs | 210 ++++++++++++ Bwg.Scsi/WinDev.cs | 55 +-- CUERipper/CUERipper.csproj | 2 +- CUETools.Codecs/CUEToolsCodecsConfig.cs | 14 +- CUETools.Interop/CUETools.Interop.csproj | 8 + CUETools.Interop/Linux.cs | 155 +++++++++ CUETools.Processor/CUEProcessorPlugins.cs | 3 +- CUETools.Processor/CUESheet.cs | 11 + .../CUETools.ConsoleRipper.csproj | 8 +- CUETools.Ripper.Console/Program.cs | 11 +- .../CUETools.Ripper.SCSI.csproj | 2 +- CUETools.Ripper.SCSI/SCSIDrive.cs | 324 +++++++++--------- CUETools.Ripper/CUETools.Ripper.csproj | 3 + CUETools.Ripper/Ripper.cs | 38 +- CUETools.sln | 41 ++- .../CUETools.TestCodecs.csproj | 3 +- .../CUETools.TestProcessor.csproj | 3 +- CUETools/CUETools.csproj | 2 +- Freedb/AssemblyInfo.cs | 58 ---- Freedb/Freedb.csproj | 171 +-------- 26 files changed, 745 insertions(+), 484 deletions(-) create mode 100644 Bwg.Scsi/ISysDev.cs create mode 100644 Bwg.Scsi/LinDev.cs create mode 100644 CUETools.Interop/CUETools.Interop.csproj create mode 100644 CUETools.Interop/Linux.cs delete mode 100644 Freedb/AssemblyInfo.cs diff --git a/Bwg.Logging/Bwg.Logging.csproj b/Bwg.Logging/Bwg.Logging.csproj index 0527ed5f..e1c6b61d 100644 --- a/Bwg.Logging/Bwg.Logging.csproj +++ b/Bwg.Logging/Bwg.Logging.csproj @@ -1,7 +1,7 @@  - net47;net20 + net47;net20;netstandard2.0 0.0.7.1 Bwg.Logging Bwg.Logging diff --git a/Bwg.Scsi/Bwg.Scsi.csproj b/Bwg.Scsi/Bwg.Scsi.csproj index 87a3b9b7..a2814add 100644 --- a/Bwg.Scsi/Bwg.Scsi.csproj +++ b/Bwg.Scsi/Bwg.Scsi.csproj @@ -1,7 +1,7 @@  - net47;net20 + net47;net20;netstandard2.0 0.0.7.1 Bwg.Scsi Bwg.Scsi @@ -27,5 +27,7 @@ - + + + diff --git a/Bwg.Scsi/Command.cs b/Bwg.Scsi/Command.cs index 4126724e..7f44552b 100644 --- a/Bwg.Scsi/Command.cs +++ b/Bwg.Scsi/Command.cs @@ -78,7 +78,22 @@ public Command(ScsiCommandCode code, byte cdbsize, int bufsize, CmdDirection dir { m_delete_buffer = true; m_buffer = Marshal.AllocHGlobal(bufsize); +#if NETSTANDARD2_0 + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + RtlZeroMemory(m_buffer, bufsize); + } + else + { + for(int i = 0; i < bufsize; ++i) + { + Marshal.WriteByte(m_buffer, i, 0x00); + } + } +#else RtlZeroMemory(m_buffer, bufsize); + +#endif } m_dir = dir; diff --git a/Bwg.Scsi/Device.cs b/Bwg.Scsi/Device.cs index e2eec450..49a278d2 100644 --- a/Bwg.Scsi/Device.cs +++ b/Bwg.Scsi/Device.cs @@ -43,11 +43,11 @@ namespace Bwg.Scsi /// /// /// - public unsafe class Device : WinDev + public sealed unsafe class Device : IDisposable { #region Structures for DeviceIoControl [StructLayout(LayoutKind.Explicit)] - struct SCSI_PASS_THROUGH_DIRECT32 + internal struct SCSI_PASS_THROUGH_DIRECT32 { [FieldOffset(0)]public ushort Length; [FieldOffset(2)]public byte ScsiStatus; @@ -66,7 +66,7 @@ struct SCSI_PASS_THROUGH_DIRECT32 } ; [StructLayout(LayoutKind.Explicit)] - struct SCSI_PASS_THROUGH_DIRECT64 + internal struct SCSI_PASS_THROUGH_DIRECT64 { [FieldOffset(0)]public ushort Length; [FieldOffset(2)]public byte ScsiStatus; @@ -85,7 +85,7 @@ struct SCSI_PASS_THROUGH_DIRECT64 } ; [StructLayout(LayoutKind.Explicit)] - struct IO_SCSI_CAPABILITIES + internal struct IO_SCSI_CAPABILITIES { [FieldOffset(0)]public uint Length; [FieldOffset(4)]public uint MaximumTransferLength; @@ -98,7 +98,7 @@ struct IO_SCSI_CAPABILITIES } ; [StructLayout(LayoutKind.Explicit)] - private struct PREVENT_MEDIA_REMOVAL + internal struct PREVENT_MEDIA_REMOVAL { [FieldOffset(0)] public uint PreventMediaRemoval; }; @@ -117,9 +117,9 @@ private struct PREVENT_MEDIA_REMOVAL private int m_MaximumTransferLength; #endregion - #region private static data structures - static ushort m_scsi_request_size_32 = 44; - static ushort m_scsi_request_size_64 = 56; + #region private constant data structure sizes + internal const ushort m_scsi_request_size_32 = 44; + internal const ushort m_scsi_request_size_64 = 56; #endregion #region public constants @@ -131,9 +131,9 @@ private struct PREVENT_MEDIA_REMOVAL #endregion #region Private constants - private const uint IOCTL_SCSI_PASS_THROUGH_DIRECT = 0x4d014; - private const uint IOCTL_SCSI_GET_CAPABILITIES = 0x41010; - private const uint IOCTL_STORAGE_MEDIA_REMOVAL = 0x2D4804; + internal const uint IOCTL_SCSI_PASS_THROUGH_DIRECT = 0x4d014; + internal const uint IOCTL_SCSI_GET_CAPABILITIES = 0x41010; + internal const uint IOCTL_STORAGE_MEDIA_REMOVAL = 0x2D4804; private const uint ERROR_NOT_SUPPORTED = 50 ; #endregion @@ -816,10 +816,10 @@ private CommandStatus SendCommand64(Command cmd) // Send through ioctl field IntPtr pt = new IntPtr(&f); - if (!Control(IOCTL_SCSI_PASS_THROUGH_DIRECT, pt, total, pt, total, ref ret, IntPtr.Zero)) + if (!_sysDev.Control(IOCTL_SCSI_PASS_THROUGH_DIRECT, pt, total, pt, total, ref ret, IntPtr.Zero)) { string str ; - str = "IOCTL_SCSI_PASS_THROUGH_DIRECT failed - " + Win32ErrorToString(LastError); + str = "IOCTL_SCSI_PASS_THROUGH_DIRECT failed - " + _sysDev.ErrorCodeToString(_sysDev.LastError); m_logger.LogMessage(new UserMessage(UserMessage.Category.Error, 0, str)); return CommandStatus.IoctlFailed; } @@ -867,10 +867,10 @@ private CommandStatus SendCommand32(Command cmd) // Send through ioctl field IntPtr pt = new IntPtr(&f); - if (!Control(IOCTL_SCSI_PASS_THROUGH_DIRECT, pt, total, pt, total, ref ret, IntPtr.Zero)) + if (!_sysDev.Control(IOCTL_SCSI_PASS_THROUGH_DIRECT, pt, total, pt, total, ref ret, IntPtr.Zero)) { string str ; - str = "IOCTL_SCSI_PASS_THROUGH_DIRECT failed - " + Win32ErrorToString(LastError); + str = "IOCTL_SCSI_PASS_THROUGH_DIRECT failed - " + _sysDev.ErrorCodeToString(_sysDev.LastError); m_logger.LogMessage(new UserMessage(UserMessage.Category.Error, 0, str)); return CommandStatus.IoctlFailed; } @@ -902,7 +902,7 @@ void QueryBufferSize() uint ret = 0; IntPtr pt = new IntPtr(&f); uint total = (uint)Marshal.SizeOf(f); - if (Control(IOCTL_SCSI_GET_CAPABILITIES, pt, total, pt, total, ref ret, IntPtr.Zero)) + if (_sysDev.Control(IOCTL_SCSI_GET_CAPABILITIES, pt, total, pt, total, ref ret, IntPtr.Zero)) { if (f.MaximumTransferLength > Int32.MaxValue) m_MaximumTransferLength = Int32.MaxValue; @@ -927,6 +927,8 @@ void QueryBufferSize() static global::System.Resources.ResourceManager messages; + private readonly ISysDev _sysDev; + #region constructor /// /// @@ -942,7 +944,21 @@ public Device(Logger l) else m_ossize = 64; + // m_logger.LogMessage(new UserMessage(UserMessage.Category.Info, 0, "Operating System Size: " + m_ossize.ToString())); +#if NETSTANDARD2_0 + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + _sysDev = new WinDev(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + _sysDev = new LinDev(); + } + else throw new NotSupportedException("This OS is currently not supported, feel free to add an implementation!"); +#else + _sysDev = new WinDev(); +#endif } static Device() @@ -950,7 +966,7 @@ static Device() messages = new global::System.Resources.ResourceManager("Bwg.Scsi.Messages", typeof(Device).Assembly); } - #endregion +#endregion #region Public Functions @@ -971,9 +987,9 @@ public static bool IsLongWriteInProgress(byte asc, byte ascq) /// /// name of the SCSI device /// true if the open succeeded - public override bool Open(string name) + public bool Open(string name) { - if (!base.Open(name)) + if (!_sysDev.Open(name)) return false; QueryBufferSize(); @@ -986,9 +1002,9 @@ public override bool Open(string name) /// /// /// - public override bool Open(char letter) + public bool Open(char letter) { - if (!base.Open(letter)) + if (!_sysDev.Open(letter)) return false ; QueryBufferSize(); @@ -3713,15 +3729,16 @@ public bool DisableEjectDisc(bool bDisable) { uint uiReturnedBytes = 0; IntPtr pt = new IntPtr(&pmr); - bResult = Control(IOCTL_STORAGE_MEDIA_REMOVAL, pt, uiPmrSize, pt, uiPmrSize, ref uiReturnedBytes, IntPtr.Zero); + bResult = _sysDev.Control(IOCTL_STORAGE_MEDIA_REMOVAL, pt, uiPmrSize, pt, uiPmrSize, ref uiReturnedBytes, IntPtr.Zero); if (!bResult) { - string str = "IOCTL_STORAGE_MEDIA_REMOVAL failed - " + Win32ErrorToString(LastError); + string str = "IOCTL_STORAGE_MEDIA_REMOVAL failed - " + _sysDev.ErrorCodeToString(_sysDev.LastError); m_logger.LogMessage(new UserMessage(UserMessage.Category.Error, 0, str)); } } return bResult; } + public void Dispose() => _sysDev.Dispose(); } } diff --git a/Bwg.Scsi/DeviceManager.cs b/Bwg.Scsi/DeviceManager.cs index a244a7ec..aa200d05 100644 --- a/Bwg.Scsi/DeviceManager.cs +++ b/Bwg.Scsi/DeviceManager.cs @@ -108,6 +108,8 @@ public void ScanForDevices() Device dev = new Device(m_logger) ; if (!dev.Open(name)) { + dev.Dispose(); + m_logger.LogMessage(new UserMessage(UserMessage.Category.Debug, dlev, " ... device open failed")); continue ; } @@ -117,6 +119,8 @@ public void ScanForDevices() DeviceInfo info = DeviceInfo.CreateDevice(name, letter); if (!info.ExtractInfo(dev)) { + dev.Dispose(); + m_logger.LogMessage(new UserMessage(UserMessage.Category.Debug, dlev, " ... cannot extract inquiry information from the drive")); string str = "The drive '" + letter + "' (" + name + ") is a CD/DVD driver, but is not a valid MMC device."; @@ -128,7 +132,7 @@ public void ScanForDevices() m_logger.LogMessage(new UserMessage(UserMessage.Category.Debug, dlev, " ... device added to device list")); m_devices_found.Add(info) ; - dev.Close() ; + dev.Dispose(); } if (AfterScan != null) diff --git a/Bwg.Scsi/ISysDev.cs b/Bwg.Scsi/ISysDev.cs new file mode 100644 index 00000000..6a58b09b --- /dev/null +++ b/Bwg.Scsi/ISysDev.cs @@ -0,0 +1,15 @@ +using System; + +namespace Bwg.Scsi +{ + internal interface ISysDev : IDisposable + { + int LastError { get; } + + void Close(); + bool Control(uint code, IntPtr inbuf, uint insize, IntPtr outbuf, uint outsize, ref uint ret, IntPtr overlapped); + bool Open(string name); + bool Open(char letter); + string ErrorCodeToString(int error); + } +} diff --git a/Bwg.Scsi/LinDev.cs b/Bwg.Scsi/LinDev.cs new file mode 100644 index 00000000..bb95be0f --- /dev/null +++ b/Bwg.Scsi/LinDev.cs @@ -0,0 +1,210 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +/* + * Warning: This is a highly experimental reimplementation of WinDev.cs for Linux. + * The code in this file requires thorough review and improvement. + */ +#if NETSTANDARD2_0 +using CUETools.Interop; +using System; +using System.Runtime.InteropServices; + +namespace Bwg.Scsi +{ + internal unsafe class LinDev : ISysDev + { + private string _name; + public string Name + { + get + { + CheckOpen(); + return _name; + } + } + + private int _fd = -1; + public bool IsOpen => _fd != -1; + + public LinDev() + { + _fd = -1; + } + + public int LastError { get; private set; } + + public void Close() + { + if (IsOpen) + { + Linux.close(_fd); + _fd = -1; + } + } + + public bool Open(string name) + => throw new NotImplementedException($"{System.Reflection.MethodBase.GetCurrentMethod().Name} is not implemented."); + + public bool Open(char number) + { + string name = $"{Linux.CDROM_DEVICE_PATH}{number}"; + _fd = Linux.open(name, Linux.O_RDONLY); + + if (_fd == -1) + { + LastError = Marshal.GetLastWin32Error(); + return false; + } + + _name = name; + return true; + } + + protected void CheckOpen() + { + if (!IsOpen) throw new Exception("device is not open"); + } + + public bool Control(uint code, IntPtr inbuf, uint insize, IntPtr outbuf, uint outsize, ref uint ret, IntPtr overlapped) + { + CheckOpen(); + ret = 0; + + if (inbuf != outbuf) throw new NotImplementedException("Unexpected state"); + + switch (code) + { + case Device.IOCTL_SCSI_GET_CAPABILITIES: + { + var caps = (Device.IO_SCSI_CAPABILITIES*)outbuf; + + uint maxTransferLength = 0; + var result = Linux.ioctl(_fd, Linux.SG_GET_RESERVED_SIZE, new IntPtr(&maxTransferLength)); + if (result < 0) + { + LastError = Marshal.GetLastWin32Error(); + return false; + } + + caps->MaximumTransferLength = maxTransferLength; + return true; + } + case Device.IOCTL_SCSI_PASS_THROUGH_DIRECT: + { + var linScsi = new Linux.SG_IO_HDR + { + interface_id = 'S', + dxfer_direction = Linux.SG_DXFER_FROM_DEV + }; + + var length = *(ushort*)outbuf; + if (length == Device.m_scsi_request_size_32) + { + var winScsi = (Device.SCSI_PASS_THROUGH_DIRECT32*)outbuf; + linScsi.cmdp = new IntPtr(winScsi->CdbData); + linScsi.cmd_len = winScsi->CdbLength; + linScsi.dxfer_len = winScsi->DataTransferLength; + linScsi.dxferp = winScsi->DataBuffer; + linScsi.mx_sb_len = winScsi->SenseInfoLength; + linScsi.sbp = new IntPtr(winScsi->SenseInfo); + linScsi.timeout = winScsi->TimeOutValue * 1000; + + var result = Linux.ioctl(_fd, Linux.SG_IO, new IntPtr(&linScsi)); + if (result < 0) + { + LastError = Marshal.GetLastWin32Error(); + return false; + } + + winScsi->ScsiStatus = linScsi.status; + return true; + } + else if (length == Device.m_scsi_request_size_64) + { + var winScsi = (Device.SCSI_PASS_THROUGH_DIRECT64*)outbuf; + linScsi.cmdp = new IntPtr(winScsi->CdbData); + linScsi.cmd_len = winScsi->CdbLength; + linScsi.dxfer_len = winScsi->DataTransferLength; + linScsi.dxferp = winScsi->DataBuffer; + linScsi.mx_sb_len = winScsi->SenseInfoLength; + linScsi.sbp = new IntPtr(winScsi->SenseInfo); + linScsi.timeout = winScsi->TimeOutValue * 1000; + + var result = Linux.ioctl(_fd, Linux.SG_IO, new IntPtr(&linScsi)); + if (result < 0) + { + LastError = Marshal.GetLastWin32Error(); + return false; + } + + winScsi->ScsiStatus = linScsi.status; + return true; + } + + return false; + } + case Device.IOCTL_STORAGE_MEDIA_REMOVAL: + { + var mediaRemoval = (Device.PREVENT_MEDIA_REMOVAL*)outbuf; + bool shouldLock = mediaRemoval->PreventMediaRemoval == 1; + + var result = Linux.ioctl(_fd, Linux.CDROM_LOCKDOOR, new IntPtr(&shouldLock)); + if (result < 0) + { + LastError = Marshal.GetLastWin32Error(); + return false; + } + + return true; + } + default: + throw new NotImplementedException($"Unknown SCSI instruction {code}"); + } + } + + public string ErrorCodeToString(int error) + => Linux.GetErrorString(error); + + private bool _disposed; + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + // TODO: dispose managed state (managed objects) + } + + if (IsOpen) Close(); + + _disposed = true; + } + } + + ~LinDev() => Dispose(disposing: false); + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} + +#endif // NETSTANDARD2_0 \ No newline at end of file diff --git a/Bwg.Scsi/WinDev.cs b/Bwg.Scsi/WinDev.cs index 1c0be7d4..f7867476 100644 --- a/Bwg.Scsi/WinDev.cs +++ b/Bwg.Scsi/WinDev.cs @@ -22,8 +22,6 @@ // using System; -using System.Collections.Generic; -using System.Text; using System.Runtime.InteropServices; namespace Bwg.Scsi @@ -31,7 +29,7 @@ namespace Bwg.Scsi /// /// /// - public unsafe class WinDev : IDisposable + public unsafe class WinDev : ISysDev { // // External functions required to interface to th @@ -63,7 +61,7 @@ public string Name private string m_name; private IntPtr m_handle; - private uint m_last_error; + private int m_last_error; /// /// @@ -75,7 +73,7 @@ public WinDev() /// /// /// - public uint LastError + public int LastError { get { @@ -83,15 +81,6 @@ public uint LastError } } - /// - /// - /// - public void Dispose() - { - if (IsOpen) - Close(); - } - /// /// /// @@ -105,7 +94,7 @@ public void Close() /// /// /// - virtual public bool Open(string name) + public bool Open(string name) { string dname = name; @@ -117,7 +106,7 @@ virtual public bool Open(string name) m_handle = CreateFile(dname, acc, 0x01, (IntPtr)0, 3, 0x00000080, (uint)0); if (m_handle.ToInt32() == -1) { - m_last_error = (uint)Marshal.GetLastWin32Error(); + m_last_error = Marshal.GetLastWin32Error(); return false; } @@ -130,7 +119,7 @@ virtual public bool Open(string name) /// /// /// - virtual public bool Open(char letter) + public bool Open(char letter) { string dname = "\\\\.\\" + letter + ":"; return Open(dname); @@ -156,14 +145,14 @@ protected void CheckOpen() /// /// /// - protected bool Control(uint code, IntPtr inbuf, uint insize, IntPtr outbuf, uint outsize, ref uint ret, IntPtr overlapped) + public bool Control(uint code, IntPtr inbuf, uint insize, IntPtr outbuf, uint outsize, ref uint ret, IntPtr overlapped) { bool b; CheckOpen() ; b = DeviceIoControl(m_handle, code, inbuf, insize, outbuf, outsize, ref ret, overlapped); if (!b) - m_last_error = (uint)Marshal.GetLastWin32Error(); + m_last_error = Marshal.GetLastWin32Error(); return b; } @@ -173,10 +162,10 @@ protected bool Control(uint code, IntPtr inbuf, uint insize, IntPtr outbuf, uint /// /// the error code to convert /// the string for the error code - static public string Win32ErrorToString(uint error) + public string ErrorCodeToString(int error) { IntPtr buffer = Marshal.AllocHGlobal(1024) ; - uint ret = FormatMessage(0x1000, IntPtr.Zero, error, 0, buffer, 1024, IntPtr.Zero); + uint ret = FormatMessage(0x1000, IntPtr.Zero, (uint)error, 0, buffer, 1024, IntPtr.Zero); if (ret == 0) return "cannot find win32 error string for this code (" + error.ToString() + ")"; @@ -186,5 +175,29 @@ static public string Win32ErrorToString(uint error) return str; } + + private bool _disposed; + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + // TODO: dispose managed state (managed objects) + } + + if (IsOpen) Close(); + + _disposed = true; + } + } + + ~WinDev() => Dispose(disposing: false); + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } } } diff --git a/CUERipper/CUERipper.csproj b/CUERipper/CUERipper.csproj index 0fe5a3cf..1b0f4105 100644 --- a/CUERipper/CUERipper.csproj +++ b/CUERipper/CUERipper.csproj @@ -182,7 +182,7 @@ CUETools.Ripper - {5ADCFD6D-BFEA-4B10-BB45-9083BBB56AF4} + {4d42a992-952b-41f0-a594-c98359865b0a} Freedb diff --git a/CUETools.Codecs/CUEToolsCodecsConfig.cs b/CUETools.Codecs/CUEToolsCodecsConfig.cs index ec9ee391..2502bb01 100644 --- a/CUETools.Codecs/CUEToolsCodecsConfig.cs +++ b/CUETools.Codecs/CUEToolsCodecsConfig.cs @@ -1,12 +1,7 @@ using Newtonsoft.Json; using System; using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Text; -using System.Threading; -using System.Xml; -using System.Xml.Serialization; +using System.Runtime.InteropServices; namespace CUETools.Codecs { @@ -50,7 +45,12 @@ public void Init(List src_encoders, List encoders.Add(item.Clone())); src_decoders.ForEach(item => decoders.Add(item.Clone())); - if (Type.GetType("Mono.Runtime", false) == null) + bool isRunningOnWindows = Type.GetType("Mono.Runtime", false) == null; +#if NETSTANDARD2_0 + isRunningOnWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); +#endif + + if (isRunningOnWindows) { encoders.Add(new CommandLine.EncoderSettings("flac.exe", "flac", true, "0 1 2 3 4 5 6 7 8", "5", "flac.exe", "-%M -P %P - -o %O")); encoders.Add(new CommandLine.EncoderSettings("flake.exe", "flac", true, "0 1 2 3 4 5 6 7 8 9 10 11 12", "8", "flake.exe", "-%M - -o %O -p %P")); diff --git a/CUETools.Interop/CUETools.Interop.csproj b/CUETools.Interop/CUETools.Interop.csproj new file mode 100644 index 00000000..7f22bfd6 --- /dev/null +++ b/CUETools.Interop/CUETools.Interop.csproj @@ -0,0 +1,8 @@ + + + + netstandard2.0 + true + + + diff --git a/CUETools.Interop/Linux.cs b/CUETools.Interop/Linux.cs new file mode 100644 index 00000000..cf31a4d2 --- /dev/null +++ b/CUETools.Interop/Linux.cs @@ -0,0 +1,155 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +/* + * Warning: This is a highly experimental implementation for Linux. + * The code in this file requires thorough review and improvement. + * + * 64-bit is the only supported architecture at this time. + * Backporting to 32-bit is possible, but not planned. + */ +using System.Runtime.InteropServices; +using System; + +namespace CUETools.Interop +{ + public class Linux + { + #region Constants + + public const uint CDROM_DRIVE_STATUS = 0x5326; + public const int CDROM_LOCKDOOR = 0x5329; + + public const int CDS_DISC_OK = 4; + public const int O_RDONLY = 0; + + public const int SG_DXFER_FROM_DEV = -3; + + public const int SG_GET_RESERVED_SIZE = 0x2272; + public const int SG_IO = 0x2285; + + public const string CDROM_DEVICE_PATH = "/dev/sr"; + + #endregion + + #region Structs + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + struct Stat + { + public ulong st_dev; + public ulong st_ino; + public ulong st_nlink; + public uint st_mode; + public uint st_uid; + public uint st_gid; + public int padding0; + public ulong st_rdev; + public long st_size; + public long st_blksize; + public long st_blocks; + public long st_atime; + public long st_atimensec; + public long st_mtime; + public long st_mtimensec; + public long st_ctime; + public long st_ctimensec; + public long reserved0; + public long reserved1; + public long reserved2; + } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct SG_IO_HDR + { + public int interface_id; + public int dxfer_direction; + public byte cmd_len; + public byte mx_sb_len; + public ushort iovec_count; + public uint dxfer_len; + public IntPtr dxferp; + public IntPtr cmdp; + public IntPtr sbp; + public uint timeout; + public uint flags; + public int pack_id; + public IntPtr usr_ptr; + public byte status; + public byte masked_status; + public byte msg_status; + public byte sb_len_wr; + public ushort host_status; + public ushort driver_status; + public int resid; + public uint duration; + public uint info; + } + + #endregion + + #region Functions + + private const string LIBC = "libc"; + + [DllImport(LIBC, SetLastError = true, CharSet = CharSet.Ansi)] + public static extern IntPtr strerror(int errnum); + + [DllImport(LIBC, SetLastError = true, CharSet = CharSet.Ansi)] + public static extern int open(string pathname, int flags); + + [DllImport(LIBC, SetLastError = true)] + public static extern int close(int fd); + + [DllImport(LIBC, SetLastError = true)] + public static extern int ioctl(int fd, uint request); + + [DllImport(LIBC, SetLastError = true)] + public static extern int ioctl(int fd, uint request, IntPtr arg); + + [DllImport(LIBC, SetLastError = true, CharSet = CharSet.Ansi)] + public static extern int lstat(string path, IntPtr stat); + + #endregion + + #region Helper Functions + + public static int GetErrorCode() + => Marshal.GetLastWin32Error(); + + public static string GetErrorString(int errorCode) + => Marshal.PtrToStringAnsi(strerror(errorCode)); + + public static string GetErrorString() + => GetErrorString(GetErrorCode()); + + public static unsafe bool PathExists(string path) + { + if (sizeof(void*) != 8) throw Non64BitException(); + + Stat stat; + var pStat = new IntPtr(&stat); + return lstat(path, pStat) == 0; + } + + public static PlatformNotSupportedException Non64BitException() + => new PlatformNotSupportedException("This application only supports 64-bit systems on Linux."); + + #endregion + } +} diff --git a/CUETools.Processor/CUEProcessorPlugins.cs b/CUETools.Processor/CUEProcessorPlugins.cs index 3512a026..b69ae7b5 100644 --- a/CUETools.Processor/CUEProcessorPlugins.cs +++ b/CUETools.Processor/CUEProcessorPlugins.cs @@ -60,8 +60,7 @@ private static void AddPluginDirectory(string plugins_path) private static void AddPlugin(string plugin_path) { - AssemblyName name = AssemblyName.GetAssemblyName(plugin_path); - Assembly assembly = Assembly.Load(name); + Assembly assembly = Assembly.LoadFrom(plugin_path); System.Diagnostics.Trace.WriteLine("Loaded " + assembly.FullName); foreach (Type type in assembly.GetExportedTypes()) { diff --git a/CUETools.Processor/CUESheet.cs b/CUETools.Processor/CUESheet.cs index d44c91cd..20754d70 100644 --- a/CUETools.Processor/CUESheet.cs +++ b/CUETools.Processor/CUESheet.cs @@ -17,6 +17,10 @@ using CUETools.Compression; using CUETools.Ripper; using Freedb; +#if NETSTANDARD2_0 +using System.Runtime.InteropServices; +using CUETools.Interop; +#endif namespace CUETools.Processor { @@ -2107,6 +2111,13 @@ public string GetCommonMiscTag(string tagName) private static bool IsCDROM(string pathIn) { +#if NETSTANDARD2_0 + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return pathIn.StartsWith(Linux.CDROM_DEVICE_PATH); + } +#endif + return pathIn.Length == 3 && pathIn.Substring(1) == ":\\" && new DriveInfo(pathIn).DriveType == DriveType.CDRom; } diff --git a/CUETools.Ripper.Console/CUETools.ConsoleRipper.csproj b/CUETools.Ripper.Console/CUETools.ConsoleRipper.csproj index 0fcc017a..97f7a1ec 100644 --- a/CUETools.Ripper.Console/CUETools.ConsoleRipper.csproj +++ b/CUETools.Ripper.Console/CUETools.ConsoleRipper.csproj @@ -1,7 +1,7 @@  - net47;net20 + net47;net8.0 2.2.6.0 CUETools.Ripper.Console CUETools.Ripper.Console @@ -17,12 +17,6 @@ - - - False - - - diff --git a/CUETools.Ripper.Console/Program.cs b/CUETools.Ripper.Console/Program.cs index a032ec37..154ea621 100644 --- a/CUETools.Ripper.Console/Program.cs +++ b/CUETools.Ripper.Console/Program.cs @@ -70,16 +70,19 @@ class Program { static void Usage() { - string drives = ""; char[] drivesAvailable = CDDrivesList.DrivesAvailable(); - for (int i = 0; i < drivesAvailable.Length; i++) - drives += string.Format("{0}: ", drivesAvailable[i]); + if (drivesAvailable.Length == 0) + { + Console.WriteLine("Warning: No CD drive detected."); + Console.WriteLine(); + } + Console.WriteLine("Usage : CUERipper.exe "); Console.WriteLine(); Console.WriteLine("-S, --secure secure mode, read each block twice (default);"); Console.WriteLine("-B, --burst burst (1 pass) mode;"); Console.WriteLine("-P, --paranoid maximum level of error correction;"); - Console.WriteLine("-D, --drive use a specific CD drive, e.g. {0};", drives); + Console.WriteLine("-D, --drive use a specific CD drive, e.g. {0};", string.Join(", ", drivesAvailable)); Console.WriteLine("-O, --offset use specific drive read offset;"); Console.WriteLine("-C, --c2mode use specific C2ErrorMode, 0 (None), 1 (Mode294), 2 (Mode296), 3 (Auto);"); Console.WriteLine("-T, --test detect read command;"); diff --git a/CUETools.Ripper.SCSI/CUETools.Ripper.SCSI.csproj b/CUETools.Ripper.SCSI/CUETools.Ripper.SCSI.csproj index b2203068..8c8fbf10 100644 --- a/CUETools.Ripper.SCSI/CUETools.Ripper.SCSI.csproj +++ b/CUETools.Ripper.SCSI/CUETools.Ripper.SCSI.csproj @@ -1,7 +1,7 @@  - net47;net20 + net47;net20;netstandard2.0 2.2.6.0 CUETools.Ripper.SCSI CUETools.Ripper.SCSI diff --git a/CUETools.Ripper.SCSI/SCSIDrive.cs b/CUETools.Ripper.SCSI/SCSIDrive.cs index 20f3801f..1289d70f 100644 --- a/CUETools.Ripper.SCSI/SCSIDrive.cs +++ b/CUETools.Ripper.SCSI/SCSIDrive.cs @@ -62,7 +62,7 @@ public class CDDriveReader : ICDRipper public byte[,] C2Count; public long[] byte2long; BitArray m_failedSectors; - byte[] m_retryCount; + byte[] m_retryCount; AudioBuffer currentData = new AudioBuffer(AudioPCMConfig.RedBook, MSECTORS * 588); short[] _valueScore = new short[256]; bool _debugMessages = false; @@ -72,15 +72,15 @@ public class CDDriveReader : ICDRipper Device.C2ErrorMode _c2ErrorMode = Device.C2ErrorMode.Mode296; string _autodetectResult; byte[] _readBuffer = new byte[NSECTORS * CB_AUDIO]; - byte[] _subchannelBuffer = new byte[NSECTORS * CB_AUDIO]; + byte[] _subchannelBuffer = new byte[NSECTORS * CB_AUDIO]; bool _qChannelInBCD = true; private ReadProgressArgs progressArgs = new ReadProgressArgs(); public event EventHandler ReadProgress; - public IAudioDecoderSettings Settings => null; + public IAudioDecoderSettings Settings => null; - public CDImageLayout TOC + public CDImageLayout TOC { get { @@ -88,27 +88,27 @@ public CDImageLayout TOC } } - public byte[] RetryCount - { - get - { - return m_retryCount; - } - } - - public BitArray FailedSectors - { - get - { - if (m_failedSectors == null) - { - m_failedSectors = new BitArray((int)_toc.AudioLength); - for (int i = 0; i < m_failedSectors.Length; i++) - m_failedSectors[i] = m_retryCount[i] == (16 << _correctionQuality) + 1; - } - return m_failedSectors; - } - } + public byte[] RetryCount + { + get + { + return m_retryCount; + } + } + + public BitArray FailedSectors + { + get + { + if (m_failedSectors == null) + { + m_failedSectors = new BitArray((int)_toc.AudioLength); + for (int i = 0; i < m_failedSectors.Length; i++) + m_failedSectors[i] = m_retryCount[i] == (16 << _correctionQuality) + 1; + } + return m_failedSectors; + } + } public int Timeout { @@ -173,7 +173,7 @@ public string CurrentReadCommand get { return _readCDCommand == ReadCDCommand.Unknown ? "unknown" : - string.Format("{0}, {1:X2}h, {2}{3}, {4} blocks at a time", + string.Format("{0}, {1:X2}h, {2}{3}, {4} blocks at a time", (_readCDCommand == ReadCDCommand.ReadCdBEh ? "BEh" : "D8h"), (_mainChannelMode == Device.MainChannelSelection.UserData ? 0x10 : 0xF8) + (_c2ErrorMode == Device.C2ErrorMode.None ? 0 : _c2ErrorMode == Device.C2ErrorMode.Mode294 ? 2 : 4), @@ -276,22 +276,23 @@ public bool Open(char Drive) _toc2 = null; _toc = new CDImageLayout(); for (int iTrack = 0; iTrack < toc.Count - 1; iTrack++) - _toc.AddTrack(new CDTrack((uint)iTrack + 1, + _toc.AddTrack(new CDTrack((uint)iTrack + 1, toc[iTrack].StartSector, - toc[iTrack + 1].StartSector - toc[iTrack].StartSector - - ((toc[iTrack + 1].Control < 4 || iTrack + 1 == toc.Count - 1) ? 0U : 152U * 75U), + toc[iTrack + 1].StartSector - toc[iTrack].StartSector - + ((toc[iTrack + 1].Control < 4 || iTrack + 1 == toc.Count - 1) ? 0U : 152U * 75U), toc[iTrack].Control < 4, - (toc[iTrack].Control & 1) == 1)); + (toc[iTrack].Control & 1) == 1)); if (_toc.AudioLength > 0) { if (_toc[1].IsAudio) _toc[1][0].Start = 0; Position = 0; - } else + } + else throw new ReadCDException(Resource1.NoAudio); - UserData = new long[MSECTORS, 2, 4 * 588]; - C2Count = new byte[MSECTORS, 294]; + UserData = new long[MSECTORS, 2, 4 * 588]; + C2Count = new byte[MSECTORS, 294]; return true; } @@ -300,8 +301,7 @@ public void Close() { UserData = null; C2Count = null; - if (m_device != null) - m_device.Close(); + m_device?.Dispose(); m_device = null; _toc = null; _toc2 = null; @@ -351,7 +351,7 @@ private unsafe void LocateLastSector(int sec0, int sec1, int iTrack, int iIndex, lsector = fsector - 1; } - fixed (byte * data = _subchannelBuffer) + fixed (byte* data = _subchannelBuffer) for (int sector = fsector; sector <= lsector; sector++) { Device.CommandStatus st = Device.CommandStatus.Success; @@ -482,27 +482,27 @@ private unsafe void TestGaps() if (_readCDCommand == ReadCDCommand.ReadCdBEh) { - // PLEXTOR PX-W1210A always returns data, even if asked only for subchannel. - // So we fill the buffer with magic data, give extra space for command so it won't hang the drive, - // request subchannel data and check if magic data was overwritten. - bool overwritten = false; - for (int i = 0; i < 16; i++) - { - _subchannelBuffer[m_max_sectors * (588 * 4 + 16) - 16 + i] = (byte)(13 + i); - } - fixed (byte* data = _subchannelBuffer) - { - st = m_device.ReadCDAndSubChannel(Device.MainChannelSelection.None, Device.SubChannelMode.QOnly, Device.C2ErrorMode.None, 1, false, (uint)sector + _toc[_toc.FirstAudio][0].Start, (uint)m_max_sectors, (IntPtr)((void*)data), _timeout); - } - for (int i = 0; i < 16; i++) - { - if (_subchannelBuffer[m_max_sectors * (588 * 4 + 16) - 16 + i] != (byte)(13 + i)) - overwritten = true; - } - if (overwritten) - st = Device.CommandStatus.NotSupported; - //else - // st = m_device.ReadSubChannel(2, (uint)sector + _toc[_toc.FirstAudio][0].Start, (uint)m_max_sectors, ref _subchannelBuffer, _timeout); + // PLEXTOR PX-W1210A always returns data, even if asked only for subchannel. + // So we fill the buffer with magic data, give extra space for command so it won't hang the drive, + // request subchannel data and check if magic data was overwritten. + bool overwritten = false; + for (int i = 0; i < 16; i++) + { + _subchannelBuffer[m_max_sectors * (588 * 4 + 16) - 16 + i] = (byte)(13 + i); + } + fixed (byte* data = _subchannelBuffer) + { + st = m_device.ReadCDAndSubChannel(Device.MainChannelSelection.None, Device.SubChannelMode.QOnly, Device.C2ErrorMode.None, 1, false, (uint)sector + _toc[_toc.FirstAudio][0].Start, (uint)m_max_sectors, (IntPtr)((void*)data), _timeout); + } + for (int i = 0; i < 16; i++) + { + if (_subchannelBuffer[m_max_sectors * (588 * 4 + 16) - 16 + i] != (byte)(13 + i)) + overwritten = true; + } + if (overwritten) + st = Device.CommandStatus.NotSupported; + //else + // st = m_device.ReadSubChannel(2, (uint)sector + _toc[_toc.FirstAudio][0].Start, (uint)m_max_sectors, ref _subchannelBuffer, _timeout); if (st == Device.CommandStatus.Success) { int[] goodsecs = new int[2]; @@ -592,77 +592,65 @@ public bool GapsDetected } } - private void DoEjectDisk(Device.StartState action) - { - EventStatusNotification status; - m_device.GetEventStatusNotification(true, Device.NotificationClass.Media, out status); - if (status.Valid && status.EventAvailable && status.EventData.Length > 1) - { - // 0 - tray closed no disc - // 1 - tray open - // 2 - tray closed, disc - action = status.EventData[1] == 1 ? Device.StartState.LoadDisk : Device.StartState.EjectDisk; - } - m_device.StartStopUnit(true, Device.PowerControl.NoChange, action); - } - - public void EjectDisk() - { - if (m_device != null) - { - DoEjectDisk(Device.StartState.EjectDisk); - } - else - { - try - { - m_device = new Device(m_logger); - if (m_device.Open(m_device_letter)) - { - try - { - DoEjectDisk(Device.StartState.LoadDisk); - } - finally - { - m_device.Close(); - } - } - } - finally - { - m_device = null; - } - } - } - - public void DisableEjectDisc(bool bDisable) - { - if (m_device != null) - m_device.DisableEjectDisc(bDisable); - else - { - try - { - m_device = new Device(m_logger); - if (m_device.Open(m_device_letter)) - { - try - { - m_device.DisableEjectDisc(bDisable); - } - finally - { - m_device.Close(); - } - } - } - finally - { - m_device = null; - } - } - } + private void DoEjectDisk(Device.StartState action) + { + EventStatusNotification status; + m_device.GetEventStatusNotification(true, Device.NotificationClass.Media, out status); + if (status.Valid && status.EventAvailable && status.EventData.Length > 1) + { + // 0 - tray closed no disc + // 1 - tray open + // 2 - tray closed, disc + action = status.EventData[1] == 1 ? Device.StartState.LoadDisk : Device.StartState.EjectDisk; + } + m_device.StartStopUnit(true, Device.PowerControl.NoChange, action); + } + + public void EjectDisk() + { + if (m_device != null) + { + DoEjectDisk(Device.StartState.EjectDisk); + } + else + { + try + { + m_device = new Device(m_logger); + if (m_device.Open(m_device_letter)) + { + DoEjectDisk(Device.StartState.LoadDisk); + } + } + finally + { + m_device.Dispose(); + m_device = null; + } + } + } + + public void DisableEjectDisc(bool bDisable) + { + if (m_device != null) + m_device.DisableEjectDisc(bDisable); + else + { + try + { + m_device = new Device(m_logger); + if (m_device.Open(m_device_letter)) + { + m_device.DisableEjectDisc(bDisable); + } + } + finally + { + m_device.Dispose(); + m_device = null; + } + } + } bool gapsDetected = false; @@ -881,7 +869,7 @@ public unsafe bool TestReadCommand() _mainChannelMode = mainmode[m]; if (_forceReadCommand != ReadCDCommand.Unknown && _readCDCommand != _forceReadCommand) continue; - if (_readCDCommand == ReadCDCommand.ReadCdD8h && (_c2ErrorMode != Device.C2ErrorMode.None || _mainChannelMode != Device.MainChannelSelection.UserData)) + if (_readCDCommand == ReadCDCommand.ReadCdD8h && (_c2ErrorMode != Device.C2ErrorMode.None || _mainChannelMode != Device.MainChannelSelection.UserData)) continue; Array.Clear(_readBuffer, 0, _readBuffer.Length); // fill with something nasty instead? DateTime tm = DateTime.Now; @@ -901,7 +889,7 @@ public unsafe bool TestReadCommand() TimeSpan delay = DateTime.Now - tm; _autodetectResult += string.Format("{0}: {1} ({2}ms)\n", CurrentReadCommand, (st == Device.CommandStatus.DeviceFailed ? Device.LookupSenseError(m_device.GetSenseAsc(), m_device.GetSenseAscq()) : st.ToString()), delay.TotalMilliseconds); found = st == Device.CommandStatus.Success; - + //sector += m_max_sectors; } //if (found) @@ -915,16 +903,16 @@ public unsafe bool TestReadCommand() // break; // } // } - if (found) - { - TestGaps(); - _autodetectResult += "Chosen " + CurrentReadCommand + "\n"; - } - else - { - _gapDetection = GapDetectionMethod.None; - _readCDCommand = ReadCDCommand.Unknown; - } + if (found) + { + TestGaps(); + _autodetectResult += "Chosen " + CurrentReadCommand + "\n"; + } + else + { + _gapDetection = GapDetectionMethod.None; + _readCDCommand = ReadCDCommand.Unknown; + } _currentStart = -1; _currentEnd = -1; @@ -986,7 +974,7 @@ private unsafe void ReorganiseSectors(int sector, int Sectors2Read) } for (int pos = 0; pos < 294; pos++) { - int c2d = sectorPtr[4 * 588 + pos + offs]; + int c2d = sectorPtr[4 * 588 + pos + offs]; int c2 = ((-c2d) >> 31) & 1; c2CountPtr[pos] += (byte)c2; int sample = pos << 3; @@ -1026,7 +1014,7 @@ private unsafe Device.CommandStatus FetchSectors(int sector, int Sectors2Read, b { if (_debugMessages) { - int size = (4 * 588 + (_c2ErrorMode == Device.C2ErrorMode.Mode294 ? 294 : _c2ErrorMode == Device.C2ErrorMode.Mode296 ? 296 : 0)) + int size = (4 * 588 + (_c2ErrorMode == Device.C2ErrorMode.Mode294 ? 294 : _c2ErrorMode == Device.C2ErrorMode.Mode296 ? 296 : 0)) * (int)Sectors2Read; AudioSamples.MemSet(data, 0xff, size); } @@ -1054,9 +1042,9 @@ private unsafe Device.CommandStatus FetchSectors(int sector, int Sectors2Read, b { if (FetchSectors(sector + iSector, 1, false) != Device.CommandStatus.Success) { - iErrors ++; + iErrors++; for (int i = 0; i < 294; i++) - C2Count[sector + iSector - _currentStart, i] ++; + C2Count[sector + iSector - _currentStart, i]++; if (_debugMessages) System.Console.WriteLine("\nSector lost"); } @@ -1101,14 +1089,14 @@ private unsafe void CorrectSectors(int pass, int sector, int Sectors2Read) } currentData.Bytes[pos * 4 * 588 + iPar] = (byte)bestValue; } - - if (pass > _correctionQuality || fError) - { - int olderr = pass > _correctionQuality && m_retryCount[sector + iSector] == pass + 1 ? 1 : 0; - int newerr = fError ? 1 : 0; - _currentErrorsCount += newerr - olderr; - if (fError) m_retryCount[sector + iSector] = (byte)(pass + 2); - } + + if (pass > _correctionQuality || fError) + { + int olderr = pass > _correctionQuality && m_retryCount[sector + iSector] == pass + 1 ? 1 : 0; + int newerr = fError ? 1 : 0; + _currentErrorsCount += newerr - olderr; + if (fError) m_retryCount[sector + iSector] = (byte)(pass + 2); + } } } @@ -1142,7 +1130,7 @@ public unsafe void PrefetchSector(int iSector) _currentErrorsCount = 0; - //Device.CommandStatus st = m_device.SetCdSpeed(Device.RotationalControl.CLVandNonPureCav, (ushort)(176 * 4), 65535); + //Device.CommandStatus st = m_device.SetCdSpeed(Device.RotationalControl.CLVandNonPureCav, (ushort)(176 * 4), 65535); //if (st != Device.CommandStatus.Success) // System.Console.WriteLine("SetCdSpeed: {0}", (st == Device.CommandStatus.DeviceFailed ? Device.LookupSenseError(m_device.GetSenseAsc(), m_device.GetSenseAscq()) : st.ToString())); @@ -1151,7 +1139,7 @@ public unsafe void PrefetchSector(int iSector) int max_scans = 16 << _correctionQuality; for (int pass = 0; pass < max_scans; pass++) { -// dbg_pass = pass; + // dbg_pass = pass; DateTime PassTime = DateTime.Now, LastFetch = DateTime.Now; for (int sector = _currentStart; sector < _currentEnd; sector += m_max_sectors) @@ -1163,13 +1151,13 @@ public unsafe void PrefetchSector(int iSector) //if (msToSleep > 0) Thread.Sleep(msToSleep); LastFetch = DateTime.Now; - if (pass == 0) + if (pass == 0) ClearSectors(sector, Sectors2Read); FetchSectors(sector, Sectors2Read, true); //TimeSpan delay1 = DateTime.Now - LastFetch; //DateTime LastFetched = DateTime.Now; if (pass >= _correctionQuality) - CorrectSectors(pass, sector, Sectors2Read); + CorrectSectors(pass, sector, Sectors2Read); //TimeSpan delay2 = DateTime.Now - LastFetched; //if (sector == _currentStart) //System.Console.WriteLine("\n{0},{1}", delay1.TotalMilliseconds, delay2.TotalMilliseconds); @@ -1220,7 +1208,7 @@ public unsafe int Read(AudioBuffer buff, int maxLength) buff.Swap(currentData); _currentStart = -1; _currentEnd = -1; - } + } else fixed (byte* dest = buff.Bytes, src = ¤tData.Bytes[(_sampleOffset - _currentStart * 588) * 4]) AudioSamples.MemCpy(dest, src, buff.ByteLength); @@ -1228,9 +1216,9 @@ public unsafe int Read(AudioBuffer buff, int maxLength) return buff.Length; } - public TimeSpan Duration => TimeSpan.FromSeconds((double)Length / PCM.SampleRate); + public TimeSpan Duration => TimeSpan.FromSeconds((double)Length / PCM.SampleRate); - public long Length + public long Length { get { @@ -1266,7 +1254,7 @@ public string ARName get { return m_inqury_result == null || !m_inqury_result.Valid ? null : - m_inqury_result.VendorIdentification.TrimEnd(' ', '\0').PadRight(8, ' ') + " - " + + m_inqury_result.VendorIdentification.TrimEnd(' ', '\0').PadRight(8, ' ') + " - " + m_inqury_result.ProductIdentification.TrimEnd(' ', '\0'); } } @@ -1291,10 +1279,10 @@ public long Position throw new ReadCDException(Resource1.NoAudio); _currentStart = -1; _currentEnd = -1; - m_retryCount = new byte[(int)_toc.AudioLength]; - for (int i = 0; i < m_retryCount.Length; i++) - m_retryCount[i] = (byte)(_correctionQuality + 1); - m_failedSectors = null; + m_retryCount = new byte[(int)_toc.AudioLength]; + for (int i = 0; i < m_retryCount.Length; i++) + m_retryCount[i] = (byte)(_correctionQuality + 1); + m_failedSectors = null; _sampleOffset = (int)value + _driveOffset; } } @@ -1343,10 +1331,10 @@ public int CorrectionQuality if (value < 0 || value > 3) throw new Exception("invalid CorrectionQuality"); _correctionQuality = value; - for (int i = 0; i < m_retryCount.Length; i++) - m_retryCount[i] = (byte)(_correctionQuality + 1); - } - } + for (int i = 0; i < m_retryCount.Length; i++) + m_retryCount[i] = (byte)(_correctionQuality + 1); + } + } public string RipperVersion { diff --git a/CUETools.Ripper/CUETools.Ripper.csproj b/CUETools.Ripper/CUETools.Ripper.csproj index e28e6b3e..ffcfa135 100644 --- a/CUETools.Ripper/CUETools.Ripper.csproj +++ b/CUETools.Ripper/CUETools.Ripper.csproj @@ -27,4 +27,7 @@ + + + diff --git a/CUETools.Ripper/Ripper.cs b/CUETools.Ripper/Ripper.cs index 92ec21b9..0edd1f5e 100644 --- a/CUETools.Ripper/Ripper.cs +++ b/CUETools.Ripper/Ripper.cs @@ -4,7 +4,10 @@ using System.Collections.Generic; using CUETools.CDImage; using CUETools.Codecs; -using System.Text; +#if NETSTANDARD2_0 +using System.Runtime.InteropServices; +using CUETools.Interop; +#endif namespace CUETools.Ripper { @@ -33,13 +36,34 @@ public interface ICDRipper : IAudioSource, IDisposable public class CDDrivesList { - public static char[] DrivesAvailable() + public static char[] DrivesAvailable() { - List result = new List(); - foreach (DriveInfo info in DriveInfo.GetDrives()) - if (info.DriveType == DriveType.CDRom) - result.Add(info.Name[0]); - return result.ToArray(); +#if NETSTANDARD2_0 + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + // DriveInfo doesn't return CD drives on linux... + // For now we'll use a quirky workaround! + var driveList = new List(); + for (int i = 0; i < 10; ++i) + { + var path = $"{Linux.CDROM_DEVICE_PATH}{i}"; + + if (!Linux.PathExists(path)) break; + driveList.Add($"{i}"[0]); + } + + return driveList.ToArray(); + } +#endif + + List result = new List(); + foreach (DriveInfo info in DriveInfo.GetDrives()) + { + if (info.DriveType == DriveType.CDRom) + result.Add(info.Name[0]); + } + + return result.ToArray(); } } diff --git a/CUETools.sln b/CUETools.sln index 32216adf..859ac8fa 100644 --- a/CUETools.sln +++ b/CUETools.sln @@ -91,8 +91,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CUETools.AccurateRip", "CUE EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CUERipper", "CUERipper\CUERipper.csproj", "{39B43BBB-BAFC-4D85-9BEA-3BCB7EFED89C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Freedb", "Freedb\Freedb.csproj", "{5ADCFD6D-BFEA-4B10-BB45-9083BBB56AF4}" -EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "TTALib", "ttalib-1.1\TTALib.vcxproj", "{B3DF599C-1C8F-451D-91E4-DD766210DA1F}" EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "CUETools.Codecs.TTA", "CUETools.Codecs.TTA\CUETools.Codecs.TTA.vcxproj", "{1D1E99BC-6D22-41C0-BD94-FF4DD5EC725B}" @@ -199,6 +197,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CUETools.Codecs.ffmpeg", "C EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CUETools.ChaptersToCue", "CUETools.ChaptersToCue\CUETools.ChaptersToCue.csproj", "{6ADBBF4B-AD3A-4782-A694-18662196780B}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Freedb", "Freedb\Freedb.csproj", "{4D42A992-952B-41F0-A594-C98359865B0A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CUETools.Interop", "CUETools.Interop\CUETools.Interop.csproj", "{B5B719B2-A2A6-4BC5-80C2-6CE79658E318}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -345,14 +347,6 @@ Global {39B43BBB-BAFC-4D85-9BEA-3BCB7EFED89C}.Release|Any CPU.Build.0 = Release|Any CPU {39B43BBB-BAFC-4D85-9BEA-3BCB7EFED89C}.Release|Win32.ActiveCfg = Release|Any CPU {39B43BBB-BAFC-4D85-9BEA-3BCB7EFED89C}.Release|x64.ActiveCfg = Release|Any CPU - {5ADCFD6D-BFEA-4B10-BB45-9083BBB56AF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5ADCFD6D-BFEA-4B10-BB45-9083BBB56AF4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5ADCFD6D-BFEA-4B10-BB45-9083BBB56AF4}.Debug|Win32.ActiveCfg = Debug|Any CPU - {5ADCFD6D-BFEA-4B10-BB45-9083BBB56AF4}.Debug|x64.ActiveCfg = Debug|Any CPU - {5ADCFD6D-BFEA-4B10-BB45-9083BBB56AF4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5ADCFD6D-BFEA-4B10-BB45-9083BBB56AF4}.Release|Any CPU.Build.0 = Release|Any CPU - {5ADCFD6D-BFEA-4B10-BB45-9083BBB56AF4}.Release|Win32.ActiveCfg = Release|Any CPU - {5ADCFD6D-BFEA-4B10-BB45-9083BBB56AF4}.Release|x64.ActiveCfg = Release|Any CPU {B3DF599C-1C8F-451D-91E4-DD766210DA1F}.Debug|Any CPU.ActiveCfg = Debug|Win32 {B3DF599C-1C8F-451D-91E4-DD766210DA1F}.Debug|Win32.ActiveCfg = Debug|Win32 {B3DF599C-1C8F-451D-91E4-DD766210DA1F}.Debug|Win32.Build.0 = Debug|Win32 @@ -773,6 +767,30 @@ Global {6ADBBF4B-AD3A-4782-A694-18662196780B}.Release|Win32.Build.0 = Release|Any CPU {6ADBBF4B-AD3A-4782-A694-18662196780B}.Release|x64.ActiveCfg = Release|Any CPU {6ADBBF4B-AD3A-4782-A694-18662196780B}.Release|x64.Build.0 = Release|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|Win32.ActiveCfg = Debug|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|Win32.Build.0 = Debug|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|x64.ActiveCfg = Debug|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|x64.Build.0 = Debug|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Release|Any CPU.Build.0 = Release|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Release|Win32.ActiveCfg = Release|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Release|Win32.Build.0 = Release|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Release|x64.ActiveCfg = Release|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Release|x64.Build.0 = Release|Any CPU + {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Debug|Win32.ActiveCfg = Debug|Any CPU + {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Debug|Win32.Build.0 = Debug|Any CPU + {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Debug|x64.ActiveCfg = Debug|Any CPU + {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Debug|x64.Build.0 = Debug|Any CPU + {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Release|Any CPU.Build.0 = Release|Any CPU + {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Release|Win32.ActiveCfg = Release|Any CPU + {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Release|Win32.Build.0 = Release|Any CPU + {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Release|x64.ActiveCfg = Release|Any CPU + {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -796,7 +814,6 @@ Global {8CF07381-BEA2-4AFC-B3DD-9B2F21C65A3A} = {90FD290C-5D65-42A6-AC9C-928730432116} {9253A314-1821-42BF-B02F-2BF986B1765D} = {4B59E09C-A51F-4B80-91BE-987904DCEF7D} {39B43BBB-BAFC-4D85-9BEA-3BCB7EFED89C} = {90FD290C-5D65-42A6-AC9C-928730432116} - {5ADCFD6D-BFEA-4B10-BB45-9083BBB56AF4} = {7E402406-7E51-4F0D-8209-60824C1CD6E8} {B3DF599C-1C8F-451D-91E4-DD766210DA1F} = {8B179853-B7D6-479C-B8B2-6CBCE835D040} {1D1E99BC-6D22-41C0-BD94-FF4DD5EC725B} = {39A17A65-E893-44B8-A312-DDCDD990D9D1} {115CC5B0-0385-41CD-8A23-6A7EA4C51926} = {4B59E09C-A51F-4B80-91BE-987904DCEF7D} @@ -835,6 +852,8 @@ Global {8FE405A0-E837-4088-88AD-B63652E34790} = {93B7AE1D-DEF6-4A04-A222-5CDE09DF262D} {A2C09014-C430-4E58-A323-306CCDF313C5} = {93B7AE1D-DEF6-4A04-A222-5CDE09DF262D} {6ADBBF4B-AD3A-4782-A694-18662196780B} = {4B59E09C-A51F-4B80-91BE-987904DCEF7D} + {4D42A992-952B-41F0-A594-C98359865B0A} = {7E402406-7E51-4F0D-8209-60824C1CD6E8} + {B5B719B2-A2A6-4BC5-80C2-6CE79658E318} = {86BBE3FC-E4E5-4190-B675-C6745EAF4E64} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C634D169-5814-4203-94B6-6A11371DDA95} diff --git a/CUETools/CUETools.TestCodecs/CUETools.TestCodecs.csproj b/CUETools/CUETools.TestCodecs/CUETools.TestCodecs.csproj index b86e414f..fc70f4e6 100644 --- a/CUETools/CUETools.TestCodecs/CUETools.TestCodecs.csproj +++ b/CUETools/CUETools.TestCodecs/CUETools.TestCodecs.csproj @@ -134,9 +134,8 @@ False - {5ADCFD6D-BFEA-4B10-BB45-9083BBB56AF4} + {4d42a992-952b-41f0-a594-c98359865b0a} Freedb - False diff --git a/CUETools/CUETools.TestProcessor/CUETools.TestProcessor.csproj b/CUETools/CUETools.TestProcessor/CUETools.TestProcessor.csproj index 058f176c..d9daf83f 100644 --- a/CUETools/CUETools.TestProcessor/CUETools.TestProcessor.csproj +++ b/CUETools/CUETools.TestProcessor/CUETools.TestProcessor.csproj @@ -113,9 +113,8 @@ False - {5ADCFD6D-BFEA-4B10-BB45-9083BBB56AF4} + {4d42a992-952b-41f0-a594-c98359865b0a} Freedb - False diff --git a/CUETools/CUETools.csproj b/CUETools/CUETools.csproj index ae92622f..b782aa2d 100644 --- a/CUETools/CUETools.csproj +++ b/CUETools/CUETools.csproj @@ -285,7 +285,7 @@ CUETools.Processor - {5ADCFD6D-BFEA-4B10-BB45-9083BBB56AF4} + {4d42a992-952b-41f0-a594-c98359865b0a} Freedb diff --git a/Freedb/AssemblyInfo.cs b/Freedb/AssemblyInfo.cs deleted file mode 100644 index 7a154724..00000000 --- a/Freedb/AssemblyInfo.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; - -// -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -// -[assembly: AssemblyTitle("")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("")] -[assembly: AssemblyCopyright("")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Revision and Build Numbers -// by using the '*' as shown below: - -[assembly: AssemblyVersion("1.0.0.1")] - -// -// In order to sign your assembly you must specify a key to use. Refer to the -// Microsoft .NET Framework documentation for more information on assembly signing. -// -// Use the attributes below to control which key is used for signing. -// -// Notes: -// (*) If no key is specified, the assembly is not signed. -// (*) KeyName refers to a key that has been installed in the Crypto Service -// Provider (CSP) on your machine. KeyFile refers to a file which contains -// a key. -// (*) If the KeyFile and the KeyName values are both specified, the -// following processing occurs: -// (1) If the KeyName can be found in the CSP, that key is used. -// (2) If the KeyName does not exist and the KeyFile does exist, the key -// in the KeyFile is installed into the CSP and used. -// (*) In order to create a KeyFile, you can use the sn.exe (Strong Name) utility. -// When specifying the KeyFile, the location of the KeyFile should be -// relative to the project output directory which is -// %Project Directory%\obj\. For example, if your KeyFile is -// located in the project directory, you would specify the AssemblyKeyFile -// attribute as [assembly: AssemblyKeyFile("..\\..\\mykey.snk")] -// (*) Delay Signing is an advanced option - see the Microsoft .NET Framework -// documentation for more information on this. -// -[assembly: AssemblyDelaySign(false)] -[assembly: AssemblyKeyFile("")] -[assembly: AssemblyKeyName("")] diff --git a/Freedb/Freedb.csproj b/Freedb/Freedb.csproj index 91ef4fac..5e4b75c4 100644 --- a/Freedb/Freedb.csproj +++ b/Freedb/Freedb.csproj @@ -1,167 +1,8 @@ - - + + - Local - 9.0.30729 - 2.0 - {5ADCFD6D-BFEA-4B10-BB45-9083BBB56AF4} - Debug - AnyCPU - - - - - Freedb - - - JScript - Grid - IE50 - false - Library - Freedb - OnBuildSuccess - - - - - - - 3.5 - v4.7 - publish\ - true - Disk - false - Foreground - 7 - Days - false - false - true - 0 - 1.0.0.%2a - false - false - true + net47;netstandard2.0 + ..\bin\$(Configuration)\ - - ..\bin\Debug\net47\ - false - 285212672 - false - - - DEBUG;TRACE - - - true - 4096 - false - - - false - false - false - false - 4 - full - prompt - AllRules.ruleset - - - ..\bin\Release\net47\ - false - 285212672 - false - - - - - - - true - 4096 - false - - - true - false - false - false - 4 - pdbonly - prompt - AllRules.ruleset - - - - System - - - System.Data - - - System.Drawing - - - System.Windows.Forms - - - System.XML - - - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - - - False - .NET Framework 3.5 SP1 Client Profile - false - - - False - .NET Framework 3.5 SP1 - true - - - False - Windows Installer 3.1 - true - - - - - - - - - - \ No newline at end of file + + From 90d9530f71118879b21101db17eb819d30eeda9c Mon Sep 17 00:00:00 2001 From: UnknownException <25252012+UnknownException@users.noreply.github.com> Date: Sat, 8 Mar 2025 18:18:52 +0100 Subject: [PATCH 03/25] CUERipper UI reimplemented in Avalonia Reimplemented most features of the original CUERipper in an Avalonia-based application, keeping the logic as close as possible to the original without introducing breaking changes. Added new functionalities: * Cover selector * Output path management * Multi-encoding * Automatic ripping * Repair functionality (same as in CUETools) * Minimal native Linux support * Track progress * In-app updater --- .../CUERipper.Avalonia.Tests.csproj | 28 + .../Extensions/EnumerableExtensionsTests.cs | 85 +++ .../ObservableCollectionExtensionsTests.cs | 41 ++ CUERipper.Avalonia/App.axaml | 16 + CUERipper.Avalonia/App.axaml.cs | 187 +++++ .../Assets/album-placeholder.bmp | Bin 0 -> 1216 bytes CUERipper.Avalonia/Assets/cue2.ico | Bin 0 -> 23558 bytes CUERipper.Avalonia/Assets/discogs.png | Bin 0 -> 424 bytes CUERipper.Avalonia/Assets/freedb16.png | Bin 0 -> 394 bytes CUERipper.Avalonia/Assets/musicbrainz.ico | Bin 0 -> 1406 bytes .../Assets/noto-emoji/32/emoji_u1f195.png | Bin 0 -> 740 bytes .../Assets/noto-emoji/32/emoji_u1f4bf.png | Bin 0 -> 1667 bytes .../Assets/noto-emoji/32/emoji_u1f4c4.png | Bin 0 -> 466 bytes .../Assets/noto-emoji/32/emoji_u1f50d.png | Bin 0 -> 1331 bytes .../Assets/noto-emoji/32/emoji_u1f529.png | Bin 0 -> 1105 bytes .../Assets/noto-emoji/32/emoji_u1f9e9.png | Bin 0 -> 1339 bytes .../Assets/noto-emoji/32/emoji_u23cf.png | Bin 0 -> 575 bytes .../Assets/noto-emoji/32/emoji_u2699.png | Bin 0 -> 930 bytes .../Assets/noto-emoji/32/emoji_u2716.png | Bin 0 -> 939 bytes .../Assets/noto-emoji/32/emoji_u274c.png | Bin 0 -> 778 bytes .../Assets/noto-emoji/32/emoji_u2795.png | Bin 0 -> 467 bytes .../Assets/noto-emoji/32/emoji_u2796.png | Bin 0 -> 291 bytes CUERipper.Avalonia/Assets/noto-emoji/LICENSE | 93 +++ .../Assets/noto-emoji/README.md | 1 + CUERipper.Avalonia/CUERipper.Avalonia.csproj | 61 ++ .../CancellationTokenSourceExtensions.cs | 11 + .../CompilerFeatureRequiredAttribute.cs | 41 ++ .../Compatibility/IEnumerableExtensions.cs | 13 + .../Compatibility/IsExternalInit.cs | 25 + CUERipper.Avalonia/Compatibility/MathClamp.cs | 247 +++++++ CUERipper.Avalonia/Compatibility/OS.cs | 15 + .../Compatibility/RequiredMemberAttribute.cs | 22 + .../Abstractions/ICUEConfigFacade.cs | 86 +++ .../Configuration/CUEConfigFacade.cs | 196 ++++++ CUERipper.Avalonia/Constants.cs | 58 ++ .../Events/DirectoryConflictEventArgs.cs | 34 + .../Events/DriveChangedEventArgs.cs | 33 + .../Events/GenericProgressEventArgs.cs | 14 + .../Events/RipperFinishedEventArgs.cs | 36 + .../Exceptions/CUEToolsCoreException.cs | 33 + .../NotInAvaloniaDesignModeException.cs | 31 + .../Exceptions/NotInitializedException.cs | 29 + .../Exceptions/UnexpectedParentException.cs | 30 + .../Exceptions/ViewModelMismatchException.cs | 27 + .../Extensions/AlbumMetadataExtensions.cs | 72 ++ .../Extensions/BitmapExtensions.cs | 43 ++ .../Extensions/EnumerableExtensions.cs | 50 ++ .../Extensions/MD5Extensions.cs | 54 ++ .../ObservableCollectionExtensions.cs | 37 + .../Extensions/WindowExtensions.cs | 33 + CUERipper.Avalonia/Language.cs | 26 + CUERipper.Avalonia/Models/AlbumMetadata.cs | 24 + CUERipper.Avalonia/Models/AlbumRelease.cs | 24 + .../Models/EncodingConfiguration.cs | 26 + .../Models/Github/GithubAsset.cs | 38 + .../Models/Github/GithubRelease.cs | 39 ++ .../Models/Github/GithubReleaseUser.cs | 28 + .../Models/Github/GithubUser.cs | 28 + CUERipper.Avalonia/Models/MetaSource.cs | 41 ++ CUERipper.Avalonia/Models/RipSettings.cs | 32 + CUERipper.Avalonia/Models/TrackModel.cs | 46 ++ CUERipper.Avalonia/Models/UpdateMetadata.cs | 46 ++ CUERipper.Avalonia/Program.cs | 45 ++ .../Resources/Language.nl-NL.resx | 267 +++++++ CUERipper.Avalonia/Resources/Language.resx | 267 +++++++ .../Services/Abstractions/ICUEMetaService.cs | 41 ++ .../Abstractions/ICUERipperService.cs | 72 ++ .../Abstractions/IDriveNotificationService.cs | 29 + .../Services/Abstractions/IIconService.cs | 48 ++ .../Services/Abstractions/IUpdateService.cs | 15 + CUERipper.Avalonia/Services/CUEMetaService.cs | 238 +++++++ .../Services/CUERipperService.cs | 663 ++++++++++++++++++ CUERipper.Avalonia/Services/IconService.cs | 103 +++ .../Services/LinuxDriveNotificationService.cs | 157 +++++ .../Services/NullDriveNotificationService.cs | 113 +++ CUERipper.Avalonia/Services/UpdateService.cs | 322 +++++++++ .../WindowsDriveNotificationService.cs | 269 +++++++ CUERipper.Avalonia/Utilities/Accessor.cs | 84 +++ .../Utilities/InterruptibleJob.cs | 112 +++ CUERipper.Avalonia/ViewLocator.cs | 53 ++ .../ViewModels/Bindings/EditableFieldProxy.cs | 46 ++ .../Abstractions/IOptionProxy.cs | 35 + .../OptionProxies/Abstractions/OptionProxy.cs | 95 +++ .../Bindings/OptionProxies/BoolOptionProxy.cs | 40 ++ .../Bindings/OptionProxies/EnumOptionProxy.cs | 41 ++ .../Bindings/OptionProxies/IntOptionProxy.cs | 37 + .../OptionProxies/OptionProxyFactory.cs | 73 ++ .../OptionProxies/StringOptionProxy.cs | 38 + .../EncoderOptionsDialogViewModel.cs | 28 + .../ViewModels/MainWindowViewModel.cs | 282 ++++++++ .../ViewModels/MessageBoxViewModel.cs | 37 + .../ViewModels/OptionsDialogViewModel.cs | 31 + .../ViewModels/PathFormatWindowViewModel.cs | 86 +++ .../UserControls/CoverViewAlbumViewModel.cs | 57 ++ .../UserControls/CoverViewerViewModel.cs | 34 + .../DriveSettingSectionViewModel.cs | 126 ++++ .../UserControls/EncodingSectionViewModel.cs | 233 ++++++ .../ViewModels/ViewModelBase.cs | 26 + .../Views/EncoderOptionsDialog.axaml | 52 ++ .../Views/EncoderOptionsDialog.axaml.cs | 121 ++++ CUERipper.Avalonia/Views/MainWindow.axaml | 297 ++++++++ CUERipper.Avalonia/Views/MainWindow.axaml.cs | 657 +++++++++++++++++ CUERipper.Avalonia/Views/MessageBox.axaml | 41 ++ CUERipper.Avalonia/Views/MessageBox.axaml.cs | 131 ++++ CUERipper.Avalonia/Views/OptionsDialog.axaml | 182 +++++ .../Views/OptionsDialog.axaml.cs | 115 +++ .../Views/PathFormatDialog.axaml | 63 ++ .../Views/PathFormatDialog.axaml.cs | 126 ++++ .../Views/RepairSelectionDialog.axaml | 35 + .../Views/RepairSelectionDialog.axaml.cs | 94 +++ CUERipper.Avalonia/Views/UpdateDialog.axaml | 52 ++ .../Views/UpdateDialog.axaml.cs | 137 ++++ .../Views/UserControls/CoverViewer.axaml | 36 + .../Views/UserControls/CoverViewer.axaml.cs | 190 +++++ .../UserControls/DriveSettingSection.axaml | 59 ++ .../UserControls/DriveSettingSection.axaml.cs | 69 ++ .../Views/UserControls/EncodingSection.axaml | 69 ++ .../UserControls/EncodingSection.axaml.cs | 91 +++ CUERipper.Avalonia/app.manifest | 18 + CUERipper/CUERipper.csproj | 1 - .../CUERipperConfig.cs | 242 ++++--- CUETools.Ripper.SCSI/SCSIDrive.cs | 30 +- CUETools.Ripper/Exceptions/ReadCDException.cs | 10 + CUETools.Ripper/Exceptions/SCSIException.cs | 9 + CUETools.Ripper/Exceptions/TOCException.cs | 7 + CUETools.Updater/CUETools.Updater.csproj | 10 + CUETools.Updater/Program.cs | 341 +++++++++ CUETools.sln | 73 +- collect_files.bat | 167 +++++ collect_files_lite.bat | 140 ++++ 130 files changed, 9946 insertions(+), 142 deletions(-) create mode 100644 CUERipper.Avalonia.Tests/CUERipper.Avalonia.Tests.csproj create mode 100644 CUERipper.Avalonia.Tests/Extensions/EnumerableExtensionsTests.cs create mode 100644 CUERipper.Avalonia.Tests/Extensions/ObservableCollectionExtensionsTests.cs create mode 100644 CUERipper.Avalonia/App.axaml create mode 100644 CUERipper.Avalonia/App.axaml.cs create mode 100644 CUERipper.Avalonia/Assets/album-placeholder.bmp create mode 100644 CUERipper.Avalonia/Assets/cue2.ico create mode 100644 CUERipper.Avalonia/Assets/discogs.png create mode 100644 CUERipper.Avalonia/Assets/freedb16.png create mode 100644 CUERipper.Avalonia/Assets/musicbrainz.ico create mode 100644 CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f195.png create mode 100644 CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f4bf.png create mode 100644 CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f4c4.png create mode 100644 CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f50d.png create mode 100644 CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f529.png create mode 100644 CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f9e9.png create mode 100644 CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u23cf.png create mode 100644 CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u2699.png create mode 100644 CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u2716.png create mode 100644 CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u274c.png create mode 100644 CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u2795.png create mode 100644 CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u2796.png create mode 100644 CUERipper.Avalonia/Assets/noto-emoji/LICENSE create mode 100644 CUERipper.Avalonia/Assets/noto-emoji/README.md create mode 100644 CUERipper.Avalonia/CUERipper.Avalonia.csproj create mode 100644 CUERipper.Avalonia/Compatibility/CancellationTokenSourceExtensions.cs create mode 100644 CUERipper.Avalonia/Compatibility/CompilerFeatureRequiredAttribute.cs create mode 100644 CUERipper.Avalonia/Compatibility/IEnumerableExtensions.cs create mode 100644 CUERipper.Avalonia/Compatibility/IsExternalInit.cs create mode 100644 CUERipper.Avalonia/Compatibility/MathClamp.cs create mode 100644 CUERipper.Avalonia/Compatibility/OS.cs create mode 100644 CUERipper.Avalonia/Compatibility/RequiredMemberAttribute.cs create mode 100644 CUERipper.Avalonia/Configuration/Abstractions/ICUEConfigFacade.cs create mode 100644 CUERipper.Avalonia/Configuration/CUEConfigFacade.cs create mode 100644 CUERipper.Avalonia/Constants.cs create mode 100644 CUERipper.Avalonia/Events/DirectoryConflictEventArgs.cs create mode 100644 CUERipper.Avalonia/Events/DriveChangedEventArgs.cs create mode 100644 CUERipper.Avalonia/Events/GenericProgressEventArgs.cs create mode 100644 CUERipper.Avalonia/Events/RipperFinishedEventArgs.cs create mode 100644 CUERipper.Avalonia/Exceptions/CUEToolsCoreException.cs create mode 100644 CUERipper.Avalonia/Exceptions/NotInAvaloniaDesignModeException.cs create mode 100644 CUERipper.Avalonia/Exceptions/NotInitializedException.cs create mode 100644 CUERipper.Avalonia/Exceptions/UnexpectedParentException.cs create mode 100644 CUERipper.Avalonia/Exceptions/ViewModelMismatchException.cs create mode 100644 CUERipper.Avalonia/Extensions/AlbumMetadataExtensions.cs create mode 100644 CUERipper.Avalonia/Extensions/BitmapExtensions.cs create mode 100644 CUERipper.Avalonia/Extensions/EnumerableExtensions.cs create mode 100644 CUERipper.Avalonia/Extensions/MD5Extensions.cs create mode 100644 CUERipper.Avalonia/Extensions/ObservableCollectionExtensions.cs create mode 100644 CUERipper.Avalonia/Extensions/WindowExtensions.cs create mode 100644 CUERipper.Avalonia/Language.cs create mode 100644 CUERipper.Avalonia/Models/AlbumMetadata.cs create mode 100644 CUERipper.Avalonia/Models/AlbumRelease.cs create mode 100644 CUERipper.Avalonia/Models/EncodingConfiguration.cs create mode 100644 CUERipper.Avalonia/Models/Github/GithubAsset.cs create mode 100644 CUERipper.Avalonia/Models/Github/GithubRelease.cs create mode 100644 CUERipper.Avalonia/Models/Github/GithubReleaseUser.cs create mode 100644 CUERipper.Avalonia/Models/Github/GithubUser.cs create mode 100644 CUERipper.Avalonia/Models/MetaSource.cs create mode 100644 CUERipper.Avalonia/Models/RipSettings.cs create mode 100644 CUERipper.Avalonia/Models/TrackModel.cs create mode 100644 CUERipper.Avalonia/Models/UpdateMetadata.cs create mode 100644 CUERipper.Avalonia/Program.cs create mode 100644 CUERipper.Avalonia/Resources/Language.nl-NL.resx create mode 100644 CUERipper.Avalonia/Resources/Language.resx create mode 100644 CUERipper.Avalonia/Services/Abstractions/ICUEMetaService.cs create mode 100644 CUERipper.Avalonia/Services/Abstractions/ICUERipperService.cs create mode 100644 CUERipper.Avalonia/Services/Abstractions/IDriveNotificationService.cs create mode 100644 CUERipper.Avalonia/Services/Abstractions/IIconService.cs create mode 100644 CUERipper.Avalonia/Services/Abstractions/IUpdateService.cs create mode 100644 CUERipper.Avalonia/Services/CUEMetaService.cs create mode 100644 CUERipper.Avalonia/Services/CUERipperService.cs create mode 100644 CUERipper.Avalonia/Services/IconService.cs create mode 100644 CUERipper.Avalonia/Services/LinuxDriveNotificationService.cs create mode 100644 CUERipper.Avalonia/Services/NullDriveNotificationService.cs create mode 100644 CUERipper.Avalonia/Services/UpdateService.cs create mode 100644 CUERipper.Avalonia/Services/WindowsDriveNotificationService.cs create mode 100644 CUERipper.Avalonia/Utilities/Accessor.cs create mode 100644 CUERipper.Avalonia/Utilities/InterruptibleJob.cs create mode 100644 CUERipper.Avalonia/ViewLocator.cs create mode 100644 CUERipper.Avalonia/ViewModels/Bindings/EditableFieldProxy.cs create mode 100644 CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/Abstractions/IOptionProxy.cs create mode 100644 CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/Abstractions/OptionProxy.cs create mode 100644 CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/BoolOptionProxy.cs create mode 100644 CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/EnumOptionProxy.cs create mode 100644 CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/IntOptionProxy.cs create mode 100644 CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/OptionProxyFactory.cs create mode 100644 CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/StringOptionProxy.cs create mode 100644 CUERipper.Avalonia/ViewModels/EncoderOptionsDialogViewModel.cs create mode 100644 CUERipper.Avalonia/ViewModels/MainWindowViewModel.cs create mode 100644 CUERipper.Avalonia/ViewModels/MessageBoxViewModel.cs create mode 100644 CUERipper.Avalonia/ViewModels/OptionsDialogViewModel.cs create mode 100644 CUERipper.Avalonia/ViewModels/PathFormatWindowViewModel.cs create mode 100644 CUERipper.Avalonia/ViewModels/UserControls/CoverViewAlbumViewModel.cs create mode 100644 CUERipper.Avalonia/ViewModels/UserControls/CoverViewerViewModel.cs create mode 100644 CUERipper.Avalonia/ViewModels/UserControls/DriveSettingSectionViewModel.cs create mode 100644 CUERipper.Avalonia/ViewModels/UserControls/EncodingSectionViewModel.cs create mode 100644 CUERipper.Avalonia/ViewModels/ViewModelBase.cs create mode 100644 CUERipper.Avalonia/Views/EncoderOptionsDialog.axaml create mode 100644 CUERipper.Avalonia/Views/EncoderOptionsDialog.axaml.cs create mode 100644 CUERipper.Avalonia/Views/MainWindow.axaml create mode 100644 CUERipper.Avalonia/Views/MainWindow.axaml.cs create mode 100644 CUERipper.Avalonia/Views/MessageBox.axaml create mode 100644 CUERipper.Avalonia/Views/MessageBox.axaml.cs create mode 100644 CUERipper.Avalonia/Views/OptionsDialog.axaml create mode 100644 CUERipper.Avalonia/Views/OptionsDialog.axaml.cs create mode 100644 CUERipper.Avalonia/Views/PathFormatDialog.axaml create mode 100644 CUERipper.Avalonia/Views/PathFormatDialog.axaml.cs create mode 100644 CUERipper.Avalonia/Views/RepairSelectionDialog.axaml create mode 100644 CUERipper.Avalonia/Views/RepairSelectionDialog.axaml.cs create mode 100644 CUERipper.Avalonia/Views/UpdateDialog.axaml create mode 100644 CUERipper.Avalonia/Views/UpdateDialog.axaml.cs create mode 100644 CUERipper.Avalonia/Views/UserControls/CoverViewer.axaml create mode 100644 CUERipper.Avalonia/Views/UserControls/CoverViewer.axaml.cs create mode 100644 CUERipper.Avalonia/Views/UserControls/DriveSettingSection.axaml create mode 100644 CUERipper.Avalonia/Views/UserControls/DriveSettingSection.axaml.cs create mode 100644 CUERipper.Avalonia/Views/UserControls/EncodingSection.axaml create mode 100644 CUERipper.Avalonia/Views/UserControls/EncodingSection.axaml.cs create mode 100644 CUERipper.Avalonia/app.manifest rename {CUERipper => CUETools.Processor}/CUERipperConfig.cs (79%) create mode 100644 CUETools.Ripper/Exceptions/ReadCDException.cs create mode 100644 CUETools.Ripper/Exceptions/SCSIException.cs create mode 100644 CUETools.Ripper/Exceptions/TOCException.cs create mode 100644 CUETools.Updater/CUETools.Updater.csproj create mode 100644 CUETools.Updater/Program.cs create mode 100644 collect_files_lite.bat diff --git a/CUERipper.Avalonia.Tests/CUERipper.Avalonia.Tests.csproj b/CUERipper.Avalonia.Tests/CUERipper.Avalonia.Tests.csproj new file mode 100644 index 00000000..f63dad0e --- /dev/null +++ b/CUERipper.Avalonia.Tests/CUERipper.Avalonia.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/CUERipper.Avalonia.Tests/Extensions/EnumerableExtensionsTests.cs b/CUERipper.Avalonia.Tests/Extensions/EnumerableExtensionsTests.cs new file mode 100644 index 00000000..1e1f786c --- /dev/null +++ b/CUERipper.Avalonia.Tests/Extensions/EnumerableExtensionsTests.cs @@ -0,0 +1,85 @@ +using CUERipper.Avalonia.Extensions; + +namespace CUERipper.Avalonia.Tests.Extensions +{ + public class EnumerableExtensionsTests + { + [Fact] + public void None_WhenNull_ShouldReturnTrue() + { + // Arrange + IEnumerable? strings = null; + + // Act + var result = strings.None(); + + // Assert + Assert.True(result); + } + + [Fact] + public void None_WhenEmpty_ShouldReturnTrue() + { + // Arrange + IEnumerable strings = []; + + // Act + var result = strings.None(); + + // Assert + Assert.True(result); + } + + [Fact] + public void NonePredicate_WhenNull_ShouldReturnTrue() + { + // Arrange + IEnumerable? strings = null; + + // Act + var result = strings.None(x => x == "ABC"); + + // Assert + Assert.True(result); + } + + [Fact] + public void NonePredicate_WhenEmpty_ShouldReturnTrue() + { + // Arrange + IEnumerable? strings = []; + + // Act + var result = strings.None(x => x == "ABC"); + + // Assert + Assert.True(result); + } + + [Fact] + public void PrependIf_WhenConditionMet_ShouldPrependItem() + { + // Arrange + IEnumerable strings = ["B", "C"]; + + // Act + var result = strings.PrependIf(true, "A"); + + // Assert + Assert.Equal(["A", "B", "C"], result); + } + + [Fact] + public void PrependIf_WhenConditionNotMet_ShouldReturnUnmodified() + { + // Arrange + IEnumerable strings = ["B", "C"]; + + // Act + var result = strings.PrependIf(false, "A"); + + // Assert + Assert.Equal(["B", "C"], result); + } + } +} diff --git a/CUERipper.Avalonia.Tests/Extensions/ObservableCollectionExtensionsTests.cs b/CUERipper.Avalonia.Tests/Extensions/ObservableCollectionExtensionsTests.cs new file mode 100644 index 00000000..43a4c6aa --- /dev/null +++ b/CUERipper.Avalonia.Tests/Extensions/ObservableCollectionExtensionsTests.cs @@ -0,0 +1,41 @@ +using CUERipper.Avalonia.Extensions; +using System.Collections.ObjectModel; + +namespace CUERipper.Avalonia.Tests.Extensions +{ + public class ObservableCollectionExtensionsTests + { + [Fact] + public void MoveAll_WhenSourceIsNull_ShouldNotModifyDestination() + { + // Arrange + ObservableCollection? colSrc = null; + ObservableCollection colDest = ["One", "Two", "Three"]; + + // Act + colSrc.MoveAll(colDest); + + // Assert + Assert.Null(colSrc); + Assert.Equal(["One", "Two", "Three"], colDest); + } + + [Theory] + [InlineData(new string[] { "One", "Two" }, new string[] { "Three" }, new string[] { "Three", "One", "Two" })] + [InlineData(new string[] { "One" }, new string[] { }, new string[] { "One" })] + [InlineData(new string[] { }, new string[] { "One", "Two", "Three" }, new string[] { "One", "Two", "Three" })] + public void MoveAll_ShouldMoveAllItemsFromSourceToDestination(string[] src, string[] dest, string[] expectedDest) + { + // Arrange + ObservableCollection colSrc = [..src]; + ObservableCollection colDest = [..dest]; + + // Act + colSrc.MoveAll(colDest); + + // Assert + Assert.Empty(colSrc); + Assert.Equal(expectedDest, colDest); + } + } +} diff --git a/CUERipper.Avalonia/App.axaml b/CUERipper.Avalonia/App.axaml new file mode 100644 index 00000000..dcda1ac1 --- /dev/null +++ b/CUERipper.Avalonia/App.axaml @@ -0,0 +1,16 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/CUERipper.Avalonia/App.axaml.cs b/CUERipper.Avalonia/App.axaml.cs new file mode 100644 index 00000000..5da99ec5 --- /dev/null +++ b/CUERipper.Avalonia/App.axaml.cs @@ -0,0 +1,187 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core.Plugins; +using Avalonia.Markup.Xaml; +using CUERipper.Avalonia.Compatibility; +using CUERipper.Avalonia.Configuration; +using CUERipper.Avalonia.Configuration.Abstractions; +using CUERipper.Avalonia.Services; +using CUERipper.Avalonia.Services.Abstractions; +using CUERipper.Avalonia.ViewModels; +using CUERipper.Avalonia.Views; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using Serilog.Events; +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; + +namespace CUERipper.Avalonia +{ + public partial class App : Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + + if (Design.IsDesignMode) + { + Log.Logger = new LoggerConfiguration().CreateLogger(); + } + else + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .Enrich.FromLogContext() + .Enrich.WithProperty("Application", "CUERipper") + .WriteTo.File("logs/log-.txt" + , rollingInterval: RollingInterval.Day + , retainedFileCountLimit: 10 + ).CreateLogger(); + } + } + + public override void OnFrameworkInitializationCompleted() + { + var services = new ServiceCollection(); + + // Register services and viewmodels + ConfigureServices(services); + + var serviceProvider = services.BuildServiceProvider(); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + // Avoid duplicate validations from both Avalonia and the CommunityToolkit. + // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins + DisableAvaloniaDataAnnotationValidation(); + + desktop.Exit += (sender, args) => { OnApplicationShutdown(serviceProvider); }; + + var mainWindow = serviceProvider.GetRequiredService(); + mainWindow.DataContext = serviceProvider.GetRequiredService(); + + desktop.MainWindow = mainWindow; + } + + base.OnFrameworkInitializationCompleted(); + } + + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + + if (OS.IsWindows()) + services.AddSingleton(); +#if false + else if(OS.IsLinux()) + services.AddSingleton(); +#endif + else + services.AddSingleton(); + + services.AddTransient(); + services.AddTransient(); + services.AddSingleton(); + + services.AddLogging(builder => + { + builder.AddSerilog(); + }); + + var config = CUEConfigFacade.Create(); + services.AddSingleton(config); + + services.AddSingleton(CreateHttpClient(config)); + services.AddSingleton(); + + services.AddLocalization(options => options.ResourcesPath = "Resources"); + Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(config.Language); + } + + private static HttpClient CreateHttpClient(CUEConfigFacade config) + { + HttpClient? httpClient = null; + + var proxy = config.ToCUEConfig().GetProxy(); + if (proxy != null) + { + Uri cueToolsUri = new("https://cue.tools/"); + Uri? proxyUri = proxy.GetProxy(cueToolsUri); + if (proxyUri != null && proxyUri != cueToolsUri) + { + var handler = new HttpClientHandler + { + Proxy = proxy + , UseProxy = true + }; + + httpClient = new HttpClient(handler); + } + } + + httpClient ??= new HttpClient(); + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(Constants.UserAgent); + return httpClient; + } + + private static void OnApplicationShutdown(ServiceProvider serviceProvider) + { + if (!Design.IsDesignMode) + { + var config = serviceProvider.GetRequiredService(); + config.Save(); + } + + serviceProvider.Dispose(); + + Log.CloseAndFlush(); + + if (!Design.IsDesignMode) + { + var fileInDir = Directory.GetFiles($"{Constants.PathImageCache}", $"*{Constants.JpgExtension}", SearchOption.TopDirectoryOnly); + foreach (var file in fileInDir) + { + File.Delete(file); + } + } + } + + private void DisableAvaloniaDataAnnotationValidation() + { + // Get an array of plugins to remove + var dataValidationPluginsToRemove = + BindingPlugins.DataValidators.OfType().ToArray(); + + // remove each entry found + foreach (var plugin in dataValidationPluginsToRemove) + { + BindingPlugins.DataValidators.Remove(plugin); + } + } + } +} \ No newline at end of file diff --git a/CUERipper.Avalonia/Assets/album-placeholder.bmp b/CUERipper.Avalonia/Assets/album-placeholder.bmp new file mode 100644 index 0000000000000000000000000000000000000000..cc794c06e1996e96270571f90ed8502432e0f6e3 GIT binary patch literal 1216 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSjKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85o30K$!7fntTONO{k}fV@O5Z+i9M8PYeWDof((_>hoF7?_SL;T$hc=h*`IqHG6%XBJY4Nx$e`cA z7C!$GgIW8ru<8Pa*-p-r_gXaYE?jt3L*{_ng$pSvd@UA_lsw%#2d?ZQKoZuV${k@O;6>t9NfGG5nq2 z{*Hrp?Ym!crFtCnwE=YWPIhY3ZHv+3&V?4kVsr+&R4CHeOBF?j2F09 zO}y~af9b-j>K9^P@wBl!=r40~dTu`z7!(VvuU_QgZHV*eZ8=x3#Jkqb@%w6JPX30t z2a_*ekS$-j@oL5kOFK#542CZU+<}_zPizajT`%uh{Z3Swle|6L|qy!NjK>XGZne2!=G>My&m0d*Kxdx`@?_R@Qh zcH`>EPw@^WCGB3$lfT^d2P%}2bi5(o$XexLB(-bt)V8pf4M7NZ(Tqqu2qf@axfIKnh#P{-qUg}+}%K>foZQpw#wU`CMGxkJzEL%{MyA5Vh)XQ zOQd`)C%1)twVWr#dpA(_rkmjM4@M$%UD=-<;CJ!Z+nIxJpIaHlN#@tY5Nh*Q8X& zkQdDtF1(6;G0(Vq!RITo5poVo>qMlY-~O!lI?vy(_1v!~emTOxFnjmI@a4?DJH5J0 zKzII{@g(ELIqi3+E-?T-VfQR{zEk?$((-An4#}%oYM;c`C|_n?u<+{Y3mltkt%I3? zsZK6qf#5&YWh@TPs~0a|eE;LRq#Q%<+F7MM+zoAEXRF#+fI8Fooq)PRH;X4SUbwO9 zX8${&;aA^yhclRL%leiU!SGC$hx5Rm_6z$N&-{MDcVLe+mGdxqp)hOy{Qq-=nm0YJ SZ&d}BIt-q!elF{r5}E*%Wc^D3 literal 0 HcmV?d00001 diff --git a/CUERipper.Avalonia/Assets/cue2.ico b/CUERipper.Avalonia/Assets/cue2.ico new file mode 100644 index 0000000000000000000000000000000000000000..5212447b33693b6dff89d912ee0dbb3cc3260a61 GIT binary patch literal 23558 zcmeHP33yetwoWNfhQdLL2v#peDwILsBBRJ4qm@}?mYE`R%UIeXJq$v{0uF$dsWOPP zpbQmITFdEG>h)fq1!O8?MP`wL$Ru3y{y%%?bf43K7V&=XeV>8V>?AA6O0trywUQhm zJVY_!=T`=PVR62Y5OahOWy%=g01qL?c?watu26XEOAE22umHXpE+#~CAL1L~5k-VJ z>#Ono#QLX&=v-OjBMiJ7?KHlh7=f}@_7Wlhb)rbo5I|bjqt)qrbi@(8sVs^K$w0pVn=AG$}o2>5zZl5g^Nv{3WTUAB&!U?<^lnA z&(&e1Pqhm{Dp(-X=UT&zcMC@_Og!Qvc?Zf{cnjqi;fT=qsHn(DBVEjm*b%N%ZzYDtCn5|46oUeGgsU(R7({{b8<-9R6&CZ%aOf2yTr55@ndwIJ5f}BA3LB~X z8@1rF(HA)}>9=-$1%c;%H>M9-c*MV~%>M8AIhMF0N%#ee|=#Nfe$#n7QcMNm+X7(RTs z7%^gm7(IHl7&~^X2o4SwA@D=SjT0duA!6dhiDJ^ENrJ2#hVQV+lSLT6!@|V0Y12d` zX22|b&w|f&XV#1vV%Dr#V)pFWf|Dy6anUnpifDwRqM}4JAv#(tSg=4WLHd$6-xNy_ zUV?D!{P`kw;X<)w$r2H}Xpx9rzFfq{#)`Q2-V~7backCyxQ{*( zaqHKMgjK6V0>TOF)`=v1Cm}uw@ktvth@?+G5lNdiiG+j%k(87qQa}D!q#`XfK3=45 z+$d6!mPUW`W|8{YXCn2BFGOl$qDbAfOQa)D`nGK%ea8-Q3E|X#{6nOsriwK9Y2SV; z(*OBSk)EC|jvqfRPJREqxb&}oiR(ze{y+aCAoyfRKon2M`Z@x#tMt0EZ3(j*9C?@QpI0@{hwmAtdURCr<+Z zM|}Sz0*Uvx0M?Jp)GEx8O ztH4E_=TS!{{2x)~X~5;%z`F~4zT!DXw1!?iJAXE9m(Rd2WK9tH8U8^j`tL z0d6DzojU-0-$mMg;A64{1_p|dRj+tX_Lh@9w_R=K*C;bpPA;QXq4D3y-QQ+37GH!l zTKQ|trlt7*>r#C9VItu(hH3sK){7@*H5K0Qw;)^rX_&G?;}eg0beP|aqpU#iF1cPr zA@AHD8w8IH$r2@%E8$hNaPf-w$x^1I4X$#{nl&r)qhyi7T>Nu$WlP&0 zZSeAl6{}XSUHjR#4|+88v6U*~`A9D4PnEX$b{iYHbop{K!}jeT_N`?rRk%pioN|=4 zJ>EGyV)}HK3`r>i{cR;Y%4g3|##V3Ggi!Ykj+B&?KwGJQly%Qwvvn9ZF2p^d+GVIX!DFbaK zAgF3*t1}@ahYYLOTGIvE3OmQ4e7Vt+a>}qxBh&XUh26|>wk{E&Ic3;o#UE%ZVzNeg z-{}!KWms)RPf4j&s<2snyGYjzQ)WjmTrhV=R%@AY*Q>T7*jM3fVN0zUg5O=T=&iRF zEsoz}Y3}yO;X#j=C_(hHe_yuDl40T}8-PC#_?xzx8FsCngzYq_u}yWU?ZxGm456QI z-tf+3jP$Hco3-iOH+8;|EL_KOWKo7zl1#^2Xt2KRi`!cw+MQ zkdW~cCQS+r508kLUY+IFC}%AK^aM4vd2kuA)f<PlurOiOHeXDQYXO%J1pK4_}>b9$^%JUXtKS+w?6&Yl1E8*`?* zP0_YCsHt>MmQT=_%HLsS@2pkBx0Hbnf&yLe*IL*+Yt?9DbFNrV+TOQh*p9!! zI5TYfD0_yE?&v~PF-h9B?Xzqdp3ja>lKk0AN!#5s3<+?h-E1?4!4{a{*s;Sk!cD{mmA*P;$PlHo4jVSiske?AHA?BN zN@pE2CQEOn4r|d}CsSvo?g~9t>95dN!^6YH^y$;ZEcn!0XFzYA2|bm%t5a`9oYGzA z%u%}Qym|9PG=cgmbXDrC@4WMlh+Vu`>8jLIm3|7nH352S5_Q+OIFSIIGYR@>67-IIE!(m7AUKL&jh zdaBkpj~*5BFyanV=Y)S0IM8F2?s)<_DD}@DGN4aFH>4iAAG+aw=!S>!eGKtO5I%Yq zbwM}We+f8%L(mToLT5YNLjI02shi$L*s7o2yQg&2cJ12z zrKfU12=IL*CFK#{0PEtA_3c}#l(&yhNpG*hMSZhk^_Op%iVa)$>fNVryGDh?T z9dGynK7N6d7QC}4X8FobHf?#OUTF^{!)v6f6uom!pSo(ZX3|_&?Tpa8MS) zW*F4xS1;y72&m9yz(5PaPNX|FR4=Nl@aZ~apao$ClTm`Nhe4!o%i%#*1Qt)%qjVuf zLgleztO)NKsrXy32>aow4ih!P(9p;k(^osw`<2E9AS#YtU?Pm1_vRZ>Z>>*M2wyH8 zHlUIxDyaY7+Xlk4xR2~3rp)~~KH1@joeZPkfW}_FBB1$V6XCPCsZA<9-DXF8(xPEZ z9WbE1cR5j`_fiAlz3uP6Shj&E|H7)I2}bgOPE7Cl&f5znfr4e*r`4jMTytE~B5Qgl zGq7m;sG5Zy5e;gNPg-VTGFLh5E!~L8# z7Wniq5H_1VX$*H~A14F)Mk|Dk%Pa_89;>TLS2g&ugfBjc%|hr}p^h^dA3n`CIJR!e zLTH**xPcbn+xCMkCc@|rl?}?RC5UQ1e{a>jZ=Y_>Yx`7jNyrKW)Tu$26>?fs1MmYl?dde|<=|d^`DJB8r`;S(rDTqgu$zw@IZ})!&|VIigEsRJd{2bT zF=fgWF%7cDH2AcSM?^%3*I$2K*vF3-Ghs7-1K~N8B_LxcTR3dr3m{L-hb*xOGQ}d; zzE{FuZrZ?Cy#K!V05m21Rm*tyrOT`Sf>ZrHRQGR23me?xxw z4)*a>$QK7zuNDU(YaFGmd&?G)2HW;mkROi0zWo#Aii@ympM_j;_RBBDh2&(hJIS872Uvce(Qj*nu$J^|Q^{d6Dp(<9hJPe^f-`*;TSdD@q`$7dkSxYLjku&3WX zkG-7xxw17Q?wVg^8{fTR?$O+z_1=67-{xN2v17--b zUbQCFt5c^=xx$s6Z_}>xh)2V{g4#aUv{lvOe$O^;^5W=b-p@?zhC(Y;^#6P7{$Z0| z?K^5PYbjPyG-Saedv!-Go$A7hQF&}w*i;mXYTI+c6Hkl{n>?Hmfu=5Bxj76;Lk88) z60U02c)VJ5;zIAGj_W0yXO{L{)=1(q%}!mE_{evwh5NasAJK#zcOd>mM+%DqxXH7{ z+-}{0@k|G$SpNTSI0{Lm(;v_bFaW?i12X}$EWj}12LW0G*qe_6D0cq0+rs+TkCgx& zy}i9>R;g0ui)PK59mM(bIqaLixC7!ZG;P}SSU^C)zl#?y{u%PE1w;XQ0jdFLXZ&xp zgZ<6=2Gy@$|4Y~+&f@%AS^?+S5@*x~y!ob_Ww*Pe6Bl-hYYiGSNJm??p{(~=9)RKn z`{mDY?3)TcK0ZBLwQ7|LTgdOE1J-tlb8?AuYl$;viTzSS_mwz@mC$=7_DYGfR0;i6 z;tW^fOk3hiU4{e)t2W^tz@6uwd+sFKu><8V2J{9{Q+b$UQ5fabc=p+6V{!lBH_{Co zjKq1n#2Gl`!3+uAUP7mr^6c3>+ko?62_0PGyjkKbUcwIJYzM~kDC*e=2m{mxa6R~= z&Z|O&3axRM;;^E7(jY zZ{i+}g#Av!{w8r3W9zUVoTv;NIXiZ}Vz|p3~;q%Kd?RTyA_+9P{-8c^-G1B<#r# zzYW;O3hKDFaz2}T+#t}e&ouFw<-j-N72j;yWUTFn4?p}i+`U^+^07=Ce8hbk)Kb=< z?B_lPUg-Pt+*e_jm-6V*hu;Ru6c6aQPn%;v*{=)0WnY7hr7!y)_w}SadGf*A@B{Yb z0~ayp!6$pL#vizbzJMHb{3i*U{ew}DF~gaQn#;9o*Zu*t+W}6!5Z_|Zph0n@A7>_p zEK|@n?7=)g0a`J1xO;~+*M$A>F_gGB2YC+gA3%N`_c+p#mZsJ|oPX)HXbniFzM*-j zFs>&2inA;UyNiVVrC@E?4_V>@_!{Dy3;k)3)vWZNNBU(zUTxs~xOB;wcY3`xeVnbS zF~B_D3fieFsXZCzg^d;OaNeFdQ?NE1!W_SXI?39kKb?GoF|^7W$51AgP25v7=tr}p ztLUTTYinOS_ZmwZaK}{9U%7JSRM6fZp!NdrPAKWe`JEyA7NiZCAcjjK=|2VrL4B+T z_iAPIj2Q}WmlcU{R{`vQ(ogY$ea-!V`-_Fn+`e&+sOW$G`R6HTj|bQQ0_U@Dl74v+ z^X@!4i8@Gr+Q2o6A~5Mc1ESF9xc{r_fb4Vw{qirAdxd4k#>N5(j<}lse9~=gPso@t zvVHsZXF#?cK)su1%3mw`xxN*o4dhcz|B(|CcZ3z~&?!$p@x&8NQM4z@Edy|{eB3L> z9LLFeUQIvS|B&d%8M`4X?B96h%Z6%}X^#Mdv*5dl0 zkK_u2m;TMZQ){87pSmQ=Wj(BudJ$z70{4FEM+C0vK|5_DwDVL(xDEM-b3?65nttl_1=Qnc*D(1f5@jp?0sRRqzrgg9 ze>MHbPf6TuS9%9+BYu8<-=X|Z%9bs=73*^*%U60(IyN{>Ki7!@>Y}uFfc{G;dlW#) zahSieSw7dN0=0qTtm!vZl2nvI{g35YLFu35_4C+MHT~>+@=gKTz%pUKIS$%;02F_s z|A#Q%sVClVPLU@$_vpBeb4}#>Z?*yV^d;U&NX{v|nNaJJ;+vD$AN2Zv0pHd=E+1{c z`v%n~jT$xD3)-IpsQnkT*CqYr-(35jG6?zQ_H7XA<~04>e-&V(vdZ2EA^eU73zIJvow>@5s~WXJYoBAl zHqd^Qn+tp`NbY8xR!3PF>t@W-7#SRab%6P4lZO5j*uS5#|3eN4e6_EV4>%V2;U(Hz zX%E5ufoq_#EkLcAnqGGww6`Svv{~fl*!S7!rz`~7J7(EZ&>!xi0|gFv74sSs8!`;} zN%KyA<^%Nz(t|_EXwd4S^SaZn!@yt2JWiS2ts&X^{~i_}?q`s_W0o(I!P6pK^oPKK zpdaJFHmI?{IH>-=k9`U602S?c6ZQ>gtE}nEj}Q9O*ao~8bfaEMcm5BaJyZ4R?p?_; zX2o(D{5t5j=sZ`Aw1HI1xw62~X^0}W;@7R-D8?5V( zfBQBvaOfa4e>HFTw{5L>Nr$;6abD}N_Vs+Q@)F)wI%$Ucq8E9ALnO8w{Aj;tv|?_ed~?y5DCW#b+=b+>%2Gda4pvD1kSiA)91Oq=S5$B(@@_a-451` z_tp~cpe5cTt25r5cnNQhm3;~CqvfpW(-n_1FKou=X!l0lIm!h2rRhQNP=8C<9ajN< zBrW*`=zC|N&#wpF9Z>I|-rxCY8)&afJ&w94U@gACg8wxEeY_5~@44WS*YFO%5z6Cv z_+Jh^fFFwRFy4{ipjaY&3r0oySD+x!cVH2tZzr<)pZhxv9{DtsAH)+Ig1xdyzfzKZaVg6yvdPxlOyrY$9ew-vfWM4asuZW z)N3eTb3J<$P!LzKUcGv=s9)f1lf>I0*h)?q_fdG3!~KmmdCJ$k3q(1Ix&roWyd6^a zXGll2YSn1dSqlgPF zoEO%)sm}QEjx9@%r%k>fG`RJ>J1OJ`@}xO0I5)ik%E6W#tj@T}V;pB~)5$LlyknLR z$Mb5=O;pPDf$JGvVeI4Ssav-?_31om*b9E#4;#Wh7-tV+{vE|!%*6c5hr`=F)d#3E z4#2gP?;;y>ZBb{;yfdfI`E%-rgU6sV--X=^mC@7zlNa98K`#YB<;c;|24Xo6;T^G#J}#5~)heIz#;j)69^7(Bf9!@M|&d4a%5=sD;DWvBiReC?>WQNPzV zQPbyn4%cc_%`p&^qwtP0OSjIAhTqT*)(^WeZPIK9-grv9wS=sXcvPqC=3E#UUzsQa>hi~-Mo>(;INJxK2dC`%gD8Q5Xe%lg^= zg4R#lU>hE^S{b8^cC-%kOuYx#{%zhF*e{ywx9OBJoXEo$D8%fG3#HG(-}wb_+S^iNX$pD z<&yuv?p(`w4nx3OUf3E?C-~35eOs42w5KRrFUOPPiZQ1A!28swd$3vm1l50*{*Rd- z4ZF+>hW!9l(y^@F4orH^zf-?s4r0>p2VWk++N0;+kz+EjPcN4}OWD*cdpF)UOWeUE z|1@F!;9o!PJL;T`{f50FaQLw7wn!4eH3my<@Z>Bzw}0z)Xcy=?3|l1ji@-iTm9K2T zIqaKU>u`^91*)#V`stu6?p1S$b-?9l3pJ9IJ>9=eYm71EomJY03jR0D|S=3fN+&x$GBKTe$n)O<9ieWQ!IpmZO^7WIrc4pdpsLp!-2r zt?qS0&ept@&6XMlp0KXj;0MZblqWIYsgvBdZcPOHf&E2(aGpESo<$j7+eEp}a$nD7 z&%*B%s6L0Coa{Sy0Ypix4Z406th2OuZj`E=K|Nn5(AtGT$XmJnbguBN3S})irJTssh0EEK?JN*2D z1GoU@CwQVfFPJNADBpOyYZq8MJ=L`0rDH1DegJK!XnB8bVgGXd><9=0ECsAJeTEs& z^qfj6iui`u|27XDIPlE7^XA>b+gkbV{P}kV4;=Wjc@K0WaQH6kd4QFNJgZl)-X&z@ z$cuP$Ys_`7f7HXNgJW)T&A@MxT!Va_ioEZdWhgozCpDgd9~H$rTSGqO`2^PsT?TFK zaGdYUmMyzumO(n$mf}-FLXN9<9$cUK-h(o(wT$1f2b+#%kPhTyTRKAT8ilvQhFr!w zQM!!%(6$OUB+Sm@~B-$`FQK=8onh}8q*1QuPpm@ z?|u>a7ri}i-YKqAjHhg?{LH(Cf6DOjrq`K|c?r|TjJb*YtMGPO;w`gE$6IBI_sCfL zt{M4FAMc1&m}TSbvpkD)YrLxldy7T*x#4|yFRjw?KHA84*{pxxbt4S@LeXXE)7jIf z-%#xzHEIjqUAwjy@~Fbs`7@06F;2x}Z2R=;bsG6QV(sXPH`;DJjNm26Zbu=hRatkC?(KuHc*E9?jp?&s`4umj(Ld{=Bj{4ZE8 BSWW-{ literal 0 HcmV?d00001 diff --git a/CUERipper.Avalonia/Assets/discogs.png b/CUERipper.Avalonia/Assets/discogs.png new file mode 100644 index 0000000000000000000000000000000000000000..c5cb26ff61dc65970956393600fbf5d092717019 GIT binary patch literal 424 zcmV;Z0ayNsP)wGnI%Ik0XaE0Bod88 zZVrhAx5>_dL{{Djl8Rk&U4p2bc*}7#%bscfyzjMt`(6Y@IFH%%rQXjMZ$UO{7NqGd z0AnmmDLYd30BHUBM?sbiwAMt#D3M4Knf6t1Rm_W0U9EL+d)imS@d$w4>MR?a3bJfq zOdillA^}*t+3ih|NNaPhX#r7HRo;8%y!S$g$9r6NzLu-~;cx-K+8n^~=*rSXv*~2M zc&!g6qV0#P<=q2G*#Tf}1z__06bDY8#v+i`R!noWn`vxQ_PBGnd zLPW+Cr#G+|jz{n7&2Dc3-$dlxH}6~ZEaiD|Lzhgq+rBK1{D3BnU|Ko;AMYO=QGXb&b-mgzL`N4t_G?0h4)_0IgQ6-8G!HKZn?WZWnYwKNtR`#X-XR)&;2Ty z#6yyJ_-+jF`O`1He)&xZ0VL`8=vp5?{FEe_c7G+c$v5r3@;ujUHdBiqixB{xvWrH! zooGO7aL!c< zMwost~~iKQVA3UO?Hp)_HDw}2K%7#3Qa#Qxy`fI6f|vqS=fmNwdMu_ zQdybDIae)-mEXjHM+8h138_FT08bM4m_D8ZV66oTthG!Z&j3i`9#B>`#~d6^Df~Zt ocp+$U#_rK%P3C3{0r0>Bul}G76b5rN8vp#<_sDjiiJe!P#+_R_tO!k za-2>H1r7s*;Dlge9FXvgfP!ESs8;bNVlYs^1|@sCA4=eBU%Q^&d+zJH|F66MyTM9) zYt|rOL+sPAR_t8GQY8nmtc1P@tABn=d%;)R*x>Kvg15Z`K8{X!ZLr77&Yo9X-SK(B zanaPn-4$eWTZ z;`F>7*{5{m736cF=n5|D)^j%D2mvlHa_97U>XS1>{W_lWaKdr*v$Uq^Ximx?WzRkw zZSCkE{E4ZVQFun8R!$c2bJ!p91{>Yn*s#i)t{el`lhQDYeiB2&2-p-rM|V5d%1UVO zX=8ZuH=@Jd;@z;_T>dbTE`1)Blyoeqnbe<1;?^gr9N!g6;AVdwDO22iI86Ob6F-mi za_`{~_h(1gwJiv&tqJw_r7XXWD`!e6IbX%Wh&VQF3FPgdU`D5hnEGQJW6>qLdOOHF zcaAgqG~&h-BHI*Vx)okct7PkITR0XUkDu0)py2H+D03JKFVZ)7hpGAdIIUfeUi29I zgF@_Wg}4rd?a3vKOb#=z%Fgp5{=if?r^On45WK|9rsc z!~m0Xzhf)zce9rl7K@qL$J1nfe3ETi-^IB;+^4Yf^j(mFJde)QdnAmB=Wj@ z_^~7FOJ3UjzF_}P$U41Vw+vY?OD5T{LWt35G^S-iR=GxAGSC}xoSkE2NfwY)Rd0}G zjcjPtXhNh#L8lX3QoUk?n^r(@*`&siRkp~(g_n?@aDyy`hp*rwmTXw&&T25vB`%p% nLEvA1tt3k!E4fxEhMXAn{E3NHYmiNnR!&*@CjQ4OR(<~lcl@TF literal 0 HcmV?d00001 diff --git a/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f195.png b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f195.png new file mode 100644 index 0000000000000000000000000000000000000000..48adb1ae20ae82fb01fe95263e974bc6888b0f8c GIT binary patch literal 740 zcmVk_V@N9bFKwYtyCiI z0|5Y)UALpv{@+C?eitL}gt2b7;s66r9!#ovJ^zQ^e)y!;nVVNBGkS$)yyu4#w*v;C zYZrvl$*z2(?DAJK(X86CFz4xIVG^BO_H_AgNm zPXT1hQ@{(LEsP;f7t@Z&uzya1zzmiY8yAKYNEN+I)uDf1&jl3!{MC^A48@#uESOCld zipl6{Rp~c-8|eW901MDy1`#(q!=5hxZTkRTSp4!s|4|_gB3g_wk6wR{;RrRrBP{sK6&D+29o?-_tbJak#kKE&z1fdTibbxKwg7ZR(!8hB@%IOg*k4b_x)q-Q zI04WV0M%MO2tY*m83yM+3x!}Gz<)K%G$R4vxbnNxb%Ox#M=>wNRK%H>rsE)g1sDK! Wc1oW*WpW;@DuU}SnvlTu|e#@AAqt#tXio^>`)ho1ya+rDQ(&$ ze$K@69?l(4q9nDGP(T$|`h3UZ@%_#{_k525Uht5f=TBdLcWZ%@W80=9zI&eE=G6Y; zlWTYWGXQkD#OY(&qE#}XsR}CP65=Gm?rwlM7IO~d7VTStx$&<6pyNeOD_Y5H*k`LK zJ7qYU0!w=K&k4aaTRm)UHU*e<-da8hh;)o&2a@!as;i&b=j$lZv3OXfmHA=wi*n4u zEdsg!U=u+QtrkGqrv{Ka2X0PJnhW*$DYP>aZlXXdo)*r_H0=cb1S5CX9tki)8}APT zA&+u?dD>X`;I%U9MvRIQL6=h`dkYtqop5}LqTuS43#d&xLbNxFwT}m2sQ6}fQeSxY zl`$w%imDo6QVlUq5K`eX&Q1qlZ_Ll1!^A{cfLJ*efDoK6%b$KYZ$gh#Nc5n~2?0d8 zRg@Sh-6jQy^lyaQ)k`z5Ons>k^+*O*-kB^x^Hu161}jQH=3(U^!ki6F8tgshXL zWa`9b$etT3;jNcuag+u5;O=Xh(zqh4$XxEQ$Dce2b`V2PQYt?thyXP$g5U3>xzoXu ztu{7kTfBC6{b$r|Dl%%Off?JxxUQiwHGzq8sZr#VXBfEtMoEL5Wzd2IikBgEWkjwD z!%tx|(TrzwzSBh9X~XwCblP3C8E!qdkDX>W2b7W)O_otLbey*>oH2FsB_T{xT866^ zrcflbe5iXDx`dA!C7qNHPF4w1L=3$+CWu&pY-QN$^dNJiVKYI$AB&LMOEQb5sXNX@ z8D7W5mDxHB?mpsLeF9Ipse~32fa8qi&mUqy^P`H5Q$IyBS zo^B;*b$~}Zp>Qse%N*nSb7A7z_rq1M<3XpBovw#o7{jH8g!Q5rU7DEt1Y$Fl7xiSH z0VkuwjYEF5}+#IMxjlTe2n|zcDPM758p* z`T>Nw+^t9RDd32#q=j=aN+l!rc}N4QayYY#hcih+N{a|=m%sv5IWg>1vZd0o#yR1C zg^6{JMr@O!-DiMb*1Nsk5pgW#sve`N$1Eck(6E$R+*_PZW6TGA%rR2VyDrY{dZ;mM zC&B<@1ZD6@`(7Th>j%TIE$aDUrxGmgSR|1$%kSBWjWW-5+_r?Hm4hIP>y@f0IXdff z#a1yEg_!Pkak16LY+G1oF!P=iMY+#I)sTy{-hT6C$D|llc>q9_24j7kjf&6EWj0kq<`n8c$p(iYc>elvPjpWchcqBM8L#Ut z*6L^WohoEPXFSWq*$yM53?GT~In@eFZR&(Uv=rw_M4_^onoVG`u0qsJgBdd-dl7-8 z0ku@rmNhR#(KX>HfNwV*Z7;rh;cR{cQogPXpGWhAs`iQ_nyNW@+U@h602Fc2K#0uJ zU{oZu5VBnhVX%G}N|HAX36C)IxuGD%FTEi5HMAk&G2DCa^~;S*WI@F4wjZJuM0p5} z8iE0r$WyAH=#qpmO?EOB1lYd!7*BTmkxl9GZ8?*R zVQ5RU7we*@8wo&2w9k)jHgKN5Ewy{_9&RFjp0<40iwS`=W@c)r638${0y^$cxZ-`l zd)h?wPhU(%0J%=PRw|i}$|NJInkZtTjvRS4`+<&E{}(T(FMRwZzyJ(&DkCy5`!@gp N002ovPDHLkV1l#F4e|g0 literal 0 HcmV?d00001 diff --git a/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f4c4.png b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f4c4.png new file mode 100644 index 0000000000000000000000000000000000000000..cc6a4a3ebf57e09c614ffadea49331f2d7b29e9c GIT binary patch literal 466 zcmV;@0WJQCP)IP)h|I~~{~-WGQOWEJ zK-@UdQQ!kUMJ#B110fD32Qvvaifq38<~PIBFMl0?Ob4K+9Pv2<-2n_h2QYXjuv6p! zAr3}{WjDTqJp{!2@i~Ht7V!%51ahcC9U(wB2Vem>9l=D$1dr7bOmxn5AV(zNO@!0{ zDgrDF6CC(a#J>Oe&(MA53&Xn~|NMd633LwuP8LQ6Y}uP&2pm0sl_HDHbk!MHSy+fJ zh%kVTssh9BKYz)xoRx(cujNEJfS-q(rcp!C0Uy77B|8K_KtzC#G6!6_{eU7La%rkC zaB;Ac?*MBFEu~9Do1=0Ezx(V;W=9#{d8T07*qo IM6N<$f}7s9KmY&$ literal 0 HcmV?d00001 diff --git a/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f50d.png b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f50d.png new file mode 100644 index 0000000000000000000000000000000000000000..7d2d7911ff0a69d57f271c0e32c87787f0d0143d GIT binary patch literal 1331 zcmV-31-gb*OC zlu#&_ZmU4slvZ5@6_1pr)OeO!ow*LV7#vAGjuORU-2~8?{LE-^uB?JR$z{U_% zgUD;dB|s88ICkP(yz^NO%TQ=BgKkGUv6J)pJ3s%<0>d03PHHbha zuMEKU_V%dBWa^)rn}dag1<2$;f2Q=~{*iToJO{d{&4=k9OK z?=yT?GMRkR)6+Ak2Y{iFa5XhGKTJUe4GsN*Ah=dmR={!h;V++j*yfk>kUq&O z9G?N6mlZCK8chR_Ok_)K#M=GqjM*aB*Vhl|VSv`IkBRe~!E|!3=gWgK16UIoh2zjU zM;yz*9mxv+O3WWgxxaIuSY+BHCyeE+)9LIA27_I?0AR_rZ*Fdatu65TJ7u&7-|sfXk{7xt00!gl-`(AXR4N6e%oqyXNHiD z<#aU@3aA^VjPLO95H3>%&l?*XLEQP(hgqK0;~X0m<+V`uG8&khrcCqB&Q9UL(golt z34uEu^XoxxF#yx*psRfr%6Thy+*EF697{_}#5Ac3fR+c7?Ki9Z5D9tS^pJ@L!)*s$ zdEaAb@|98UWJ=E7waS6zZC`q`0GZ)At6Ap5p&*$;b5V*y~#onQzq34&H2Zo1-{Vgpm&&+0XyU1Agk1gJp zF>}Sfz}QcS-JTU373r9-GIi8q&5Axk`ReK_bar+^C=^m>2u%nRLEQj~fDC%Q-tT-q zpR1IeOiEi@Tk5og@}#e?uYO`;q95CO@iM|fF9P9o15lG97*yv%1VQG)E~MB+9BAX& zn&#kXA?CO~Ug)evLe@Px4+M3U6D|TEIkZ~BbS?<`DgmGa;;2;IRbU#x>tmpZiGG5o zgo)FbSo_CFcGa?m($UdDa^c?D=85_h+NN>I!L;M! zKde*Xd0riWEEY=x^A`U81+@W1Wg;4ll5VNy!_?FiOJBElt@Iv|s1V1R^8zrS-vR@t pAdq%en-zLzXJ;qj{~&(}FaRR)Vs^SpFW3M8002ovPDHLkV1jvCZd(8V literal 0 HcmV?d00001 diff --git a/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f529.png b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f529.png new file mode 100644 index 0000000000000000000000000000000000000000..0aa50a8b32a27aa658aa415b360f562fd70b3339 GIT binary patch literal 1105 zcmV-X1g`suP)ug@ zQV;|+X%P~mAxJkSE)3{K;|dqLav?DhH@eV`uGGXm-MaA`LrjcqbRigH+JHbP)*^JM zZKY$O11&9h?=VxRwS{&H@g^^C=FR7vxu0|20Q_ew%H^gvvwky9T{uiQ%;JFIPh+jO z&viMrl>mbG;xv%ynqy*yE7ez8uA1r`+d^O?a6TFb?_!J&IUI^fsFZ*U1eRmcA%nyA z*y&-mjKF5#0s($me#%IBQ80mAz{$c3k$+O6U;?>RVV^77lx9#jo485M zSvlEI2x@dXZg|RNd41!uLvaX1%L|-J7aJDMj6WP6xbxJ`C?+6o(f_&@%a2NJ(lwJ6 z+&MihR14|EUPfn*#p5oKnvbtCQ=?7z~gCu}POkfjoDI~~>H5$*EqituUe@fMM5)gY!tL&e_iWN}s5ABxP9{~mc X5Aip!m=3tK00000NkvXXu0mjfpfKlK literal 0 HcmV?d00001 diff --git a/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f9e9.png b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f9e9.png new file mode 100644 index 0000000000000000000000000000000000000000..ebfc6bed29bd19921e0480a8ddd454164e8718c5 GIT binary patch literal 1339 zcmV-B1;qM^P)RxAMHHg8*%s8cQny&EZ|;hqAi?a5K9x4l3Y+SS55;UnF(7TWpeU(ix3!|N zt=%9ZB9eeUMA9T{wE5e6XFTWL+2!u;-DH#aGcY@I@64U=JKs5TW)@(B<+JgvCj0}1 zZ4fw(HGws`ziTSf#C!{D0p}JsML;i>(+ieMAPGJ9IMzPj>x=?O9q zPx@-W0l^arSla)NZxVbp5--$%V`bndNMy6a=6V%KtO8yxX(ZvF(^&-EY! zqZxE(J_h3E+*;FIm5@ZT)^bVUv<|km(+XkVcvA%InWGu!&&QtTkso_R`zSDDW;GJS ze#s?v99MegS}*3Fq2b95?E>^2$(lAg-j9p6uj|H@Pj1(8O~9tMbPI?-4uEmv;Z!_+ zc%UJH_paP!8cd}_Oqn>Cdj5gmCTr)M8|lAh-2SfR2c{ntU^xMYr`{-njAn7@G=k@$ zkqgpe+s!2@b77_|$rK2t2s~A1Y3t>DNwKP+R4Q0@vCxH5L=V>j*AY1O)edtJ!6jps z%_M|90zI`KeDvM6&SfYhm}J0M*?Ly7zG6A~99z5fOPG#c>RdGyzmmVtj0%~HFln`L z2`xwfU4T^*NUZmv377q;%XeGoox!_%6^xAzq^so0F}bZVOg@|vF@KU!34h8 zbHXuZk4F$3NdS=uYXS&Z#7P-!sh<#lvB9c?M`;(x8u?2)@>hrewo6hZPwxI9Q%7Lv z+y}$wqo?AwF{=rvDisXR9Uv%kVap+afVQ5JjZ>CilUR{NfFl83T#!6Aw69R8B0#C} zckyd(G%{o{z5o%eKv!nKoX=vSO;T;0K!xh@VTKJe0~MWzF*NUAaIue_7|j>oOi_i6NfGq zD`x?2lR}5w8tYy@Se$c0FlQTHbqpv{wRIcxZ2Q{~luEY(FKu(br8=wEnRWXin6Zt{ zLvn8-FcFawSNVAEQ!!%J1Y=bk)CZxL?@if}KeG7p1?;w86<7K`7o)fX(0b*{Pu9ax z*bPsxF3JsA)*$6t->~mLf zPecCkfrrCe}FB8?Qkc2t0o}EcR;TPo@{s)AUFla zp#PoJ=Xpu7RWFjtJBn+S%%U6mk@+5cynxRq8eU2fobvU=CU4m!Ed^j!K08uu<^vvd zg$gRSu`RBQOn(o^FaQAqkJ%=P)WEa|+e`Hk0agTz&%H3W24>8UYG_+dg0T zRUE!L8gGqt)ir?J_g9z(Gx9&VepcjAyCvr}YEggVOrN9y^(9^p<0@OalrNkX; z9Ke)H=CqRzPXN`DEw$^bhgOIDUjGB+9-#Cni~yV4=o@m4Ls4Y>5#bOcM+4`wm%#$i zKWDSq!O`J!6ro@Mc%BWhi6XRq5e$GKG8;-K9XJuBEdZnJhUC7lPHdK?EdV`p=1f_X zE}OtY0Hf?u+m6-Uep7k|up>M#Whsk7N$+~sr=P^K!|k}+_%^HDq=D%LcLZP09s%g| zQsFyerHG(R2+-KCdvXxDiu$`3_-!Kx5T-!6U27fF-B?ZaL4@xQ|1br57XYjy-LjRs#a0c} zY$bt2q7f2rylWF*K%W4gKzIQ!d;!W6*e6i#yo6f~ru8Q#CWg4ikOmsM{aac}t8>QL zGM(x5I`7mQ zaC%}+;*^)?#CcJw3fRU%FdPOoHAmOlM^&(@Cqw7S_A<`!tQq69SE7U(@sq#kz4=q3fy|gOz(aghC-Wu9{G- z)}h_*&>(F2^vi&ig$zW<9zl{k`$qy`yDww>F!S1KpA^I<)c z1a|EHk)BP!{=oqa1m{Ah6dPE<^)3h!?EBrb;cvtqrDitqvCSVRn{Qu`M}@;$?ee$b zfGf8y3#Q8jJ@*Kvq*2NHr;8E%RsBt1Us zp=Vz%jwH0YU0Awx+n0o%Wr%Q3+Uk0w9N(2obpGW215g#kw+!7np~-HwF~RIGOHptQ zonsIWbzO8`baL+32^|&CFf#nJVL*hmYQ!2b$G~v@XmJtrR9ecG)Mey#+=n&N6IfNK zN)+akb6(_gyo{u-<0}&X>zI+R?Bz_ieS2(;VQZnty` zzPD`4(w%O1Hxeh=>~u2oKHq(B=FKa>e{LLWaCYvMh_5yC=giXLJH;;mZ7tv{PFh8zHbx z!f?nw!r>ck6L1+KoZw6W-6Dd6gBOyCcX$y_PEY3%{)IKCr)8^%pVA_iZbY0K9Us;^#H&1YL zvG6>f_8Na&6USu5VY15j7GTYAKHD^k$fF3^vw(j9Z@09Q2;q|9eMJZ?_{fMTjg8&{ zRjos_*>to_cRcUzh%!Y8-SAlKhLpHHW*m_1?SiAlQl$drKUMIMCu|~kD;$eZX=41W zZlsFH>74(@bB_0V2K#R);ZWjn*%r(S>|B6ZI!iarTCmYLhJ%B{o^TVmj7f&_`&&vl zH0H9X5v>*S1;6PS&ocoHH}Ki^IAuE$K_T%V;(QNaOukZuqiI@lI1(`mNqXGCecYl{ z4b5X+nPL4eU>?7Ia1TYzg_7KM3aG{-Pf*S02j0gMHaIg6SVee+vqbP5kGCi&0x#I$ zJu@MRxI1+(epFVHxG_D!yG1mPk45|s@ch%q53=X{iSe9@zFUfF%9HB?xL^(oEz%oY;y$Gi!Sk zWxr3KcC`{fs(n5<@W|jFwORfdJx<@fsn=Fn`zDr+{x1n=UF>eXK0Xdz01w~4^+vyC zM@Ufq2a)oBi2hGG4cJ~|?Fxg&`U->Hbxzj6?jsj!5IFfl-;IqbgC}=6+^aJ3-&P9f z0X?WzC;x~d^&+MhBN5ukl()otKFZFc)Xh?;b|rO zWx;V-X$fd)TS@_XXcd}hfxNt!q{rq~X%a=iF}9}SDv1F@LJkL3By7NwePEU&1osWN zyy}A?rL!Z{j)G;tXA&qvLOWC>UU`*MTku>0MM(@J8NS@Cgt&>-ZNTRe$R&XUh{&Q= z1biU@TM|XVr?McgCE0P+zs-)FM^3;CDUea(3)YM}#JG=(I1ikHa=&Q(SShi+Q=oaK zSh;Gv;Ou>!YIBPcm2%v$>SYQTa|Lc22h0{+NG&0G;1w)lHS83CMXeMGZVOHaiu0kM zfD56*Csp*}PEtGcax6Sg@Hr9Ro(DdTWpEwKeN{!HC=};sN`Z6%D?);mscLEPw6uv@ zt7{&AwujT=vuelUVq^pU*OHYa!CZhZLxQ^k*W0!l12^N`n7||XR7{v4>?{jjhD4Ps ztj_ekzybKe<>=wb8xOwvR5g7sa0#BLL~{AbW&yIL^>4TW&ngj1IKoqtm+?>cf~Ha6 zkS+?5xc$)uxKZMAB%0r@MSun;gV;>&1N`rXkzXfE_x%xI0KTJ%8C7pIk^lez07*qo IM6N<$f=5eR@Bjb+ literal 0 HcmV?d00001 diff --git a/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u2795.png b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u2795.png new file mode 100644 index 0000000000000000000000000000000000000000..c9608c7a9df8a60c78cbf3caf7ba9ac3bd787526 GIT binary patch literal 467 zcmV;^0WAKBP)K z1};x(h?43627aIeK;SFT=QnTOW%&8?Cj%vbWCwsO2H|VhZXyRg{T%S_-3KtH7GPo+ zU@+={Q3njT0~n!2?0q1mz|GCYz|73d@afZMdf2@HSZ^f4$^iujT^Y?>~LoKulwTgPmPKm|wsE zm;B$qe?Gi<|Na7zfp7s{4v^3!a{yBN@hCX}%-fEmMe(QuMjbHf0Gc`A&4>34KY#zi zDgXWF588H2e*XFe#&`h9Jr+h%HMsfsJK!tdcWv27k|O{D3;^-Kg^{-ynJEAO002ov JPDHLkV1j|3(dhsH literal 0 HcmV?d00001 diff --git a/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u2796.png b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u2796.png new file mode 100644 index 0000000000000000000000000000000000000000..4b671b619acaae3bfe227f2cc35f8e80750fa2b4 GIT binary patch literal 291 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz$r9IylHmNblJdl&R0hYC{G?O` z&)mfH)S%SFl*+=BsWw1GM?GB}Ln>}1B}lL?P7vv73N$#-An2l^wp5itlU3^WSH=!~ zh0o8=&n|6b&9FT2=%(hGW@6`O^ZOmb`vVL$d n!`6Vy?VyGPYcmrAiv+{I%~t<@m_Az!^bmumtDnm{r-UW|hF)_F literal 0 HcmV?d00001 diff --git a/CUERipper.Avalonia/Assets/noto-emoji/LICENSE b/CUERipper.Avalonia/Assets/noto-emoji/LICENSE new file mode 100644 index 00000000..d09d3d0e --- /dev/null +++ b/CUERipper.Avalonia/Assets/noto-emoji/LICENSE @@ -0,0 +1,93 @@ +Copyright 2013 Google LLC + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/CUERipper.Avalonia/Assets/noto-emoji/README.md b/CUERipper.Avalonia/Assets/noto-emoji/README.md new file mode 100644 index 00000000..ddb6544d --- /dev/null +++ b/CUERipper.Avalonia/Assets/noto-emoji/README.md @@ -0,0 +1 @@ +Taken from https://github.com/googlefonts/noto-emoji \ No newline at end of file diff --git a/CUERipper.Avalonia/CUERipper.Avalonia.csproj b/CUERipper.Avalonia/CUERipper.Avalonia.csproj new file mode 100644 index 00000000..759f648b --- /dev/null +++ b/CUERipper.Avalonia/CUERipper.Avalonia.csproj @@ -0,0 +1,61 @@ + + + Max Visser + Copyright (c) 2025 Max Visser + GPL-2.0-or-later + 2.2.6.0 + WinExe + net47;net8.0 + 12 + enable + true + app.manifest + true + ./Assets/cue2.ico + + + ..\bin\$(Configuration)\ + + + + + + + + + + + + + + None + All + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CUERipper.Avalonia/Compatibility/CancellationTokenSourceExtensions.cs b/CUERipper.Avalonia/Compatibility/CancellationTokenSourceExtensions.cs new file mode 100644 index 00000000..be4ed989 --- /dev/null +++ b/CUERipper.Avalonia/Compatibility/CancellationTokenSourceExtensions.cs @@ -0,0 +1,11 @@ +using System.Threading; + +namespace CUERipper.Avalonia.Compatibility +{ +#if NET47 + internal static class CancellationTokenSourceExtensions + { + public static bool TryReset(this CancellationTokenSource _) => false; + } +#endif +} diff --git a/CUERipper.Avalonia/Compatibility/CompilerFeatureRequiredAttribute.cs b/CUERipper.Avalonia/Compatibility/CompilerFeatureRequiredAttribute.cs new file mode 100644 index 00000000..3524a4bb --- /dev/null +++ b/CUERipper.Avalonia/Compatibility/CompilerFeatureRequiredAttribute.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Modified for CUERipper + +namespace System.Runtime.CompilerServices +{ +#if NET47 + /// + /// Indicates that compiler support for a particular feature is required for the location where this attribute is applied. + /// + [AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)] + public sealed class CompilerFeatureRequiredAttribute : Attribute + { + public CompilerFeatureRequiredAttribute(string featureName) + { + FeatureName = featureName; + } + + /// + /// The name of the compiler feature. + /// + public string FeatureName { get; } + + /// + /// If true, the compiler can choose to allow access to the location where this attribute is applied if it does not understand . + /// + public bool IsOptional { get; init; } + + /// + /// The used for the ref structs C# feature. + /// + public const string RefStructs = nameof(RefStructs); + + /// + /// The used for the required members C# feature. + /// + public const string RequiredMembers = nameof(RequiredMembers); + } +#endif +} diff --git a/CUERipper.Avalonia/Compatibility/IEnumerableExtensions.cs b/CUERipper.Avalonia/Compatibility/IEnumerableExtensions.cs new file mode 100644 index 00000000..4027df50 --- /dev/null +++ b/CUERipper.Avalonia/Compatibility/IEnumerableExtensions.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Linq; + +namespace CUERipper.Avalonia.Compatibility +{ +#if NET47 + internal static class IEnumerableExtensions + { + public static IEnumerable Prepend(this IEnumerable src, T item) + => new[] { item }.Concat(src); + } +#endif +} \ No newline at end of file diff --git a/CUERipper.Avalonia/Compatibility/IsExternalInit.cs b/CUERipper.Avalonia/Compatibility/IsExternalInit.cs new file mode 100644 index 00000000..d15734df --- /dev/null +++ b/CUERipper.Avalonia/Compatibility/IsExternalInit.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Modified for CUERipper + +using System.ComponentModel; + +namespace System.Runtime.CompilerServices +{ +#if NET47 + /// + /// Reserved to be used by the compiler for tracking metadata. + /// This class should not be used by developers in source code. + /// + [EditorBrowsable(EditorBrowsableState.Never)] +#if SYSTEM_PRIVATE_CORELIB + public +#else + internal +#endif + static class IsExternalInit + { + } +#endif +} \ No newline at end of file diff --git a/CUERipper.Avalonia/Compatibility/MathClamp.cs b/CUERipper.Avalonia/Compatibility/MathClamp.cs new file mode 100644 index 00000000..912b5974 --- /dev/null +++ b/CUERipper.Avalonia/Compatibility/MathClamp.cs @@ -0,0 +1,247 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// =================================================================================================== +// Portions of the code implemented below are based on the 'Berkeley SoftFloat Release 3e' algorithms. +// =================================================================================================== + +/*============================================================ +** +** +** +** Purpose: Some floating-point math operations +** +** +===========================================================*/ + +// Modified for CUERipper + +using System; +using System.Runtime.CompilerServices; + +namespace CUERipper.Avalonia.Compatibility +{ + public static class MathClamp + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte Clamp(byte value, byte min, byte max) + { + if (min > max) + { + throw new ArgumentOutOfRangeException("Min should be lower than max."); + } + + if (value < min) + { + return min; + } + else if (value > max) + { + return max; + } + + return value; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static decimal Clamp(decimal value, decimal min, decimal max) + { + if (min > max) + { + throw new ArgumentOutOfRangeException("Min should be lower than max."); + } + + if (value < min) + { + return min; + } + else if (value > max) + { + return max; + } + + return value; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double Clamp(double value, double min, double max) + { + if (min > max) + { + throw new ArgumentOutOfRangeException("Min should be lower than max."); + } + + if (value < min) + { + return min; + } + else if (value > max) + { + return max; + } + + return value; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static short Clamp(short value, short min, short max) + { + if (min > max) + { + throw new ArgumentOutOfRangeException("Min should be lower than max."); + } + + if (value < min) + { + return min; + } + else if (value > max) + { + return max; + } + + return value; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int Clamp(int value, int min, int max) + { + if (min > max) + { + throw new ArgumentOutOfRangeException("Min should be lower than max."); + } + + if (value < min) + { + return min; + } + else if (value > max) + { + return max; + } + + return value; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long Clamp(long value, long min, long max) + { + if (min > max) + { + throw new ArgumentOutOfRangeException("Min should be lower than max."); + } + + if (value < min) + { + return min; + } + else if (value > max) + { + return max; + } + + return value; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static sbyte Clamp(sbyte value, sbyte min, sbyte max) + { + if (min > max) + { + throw new ArgumentOutOfRangeException("Min should be lower than max."); + } + + if (value < min) + { + return min; + } + else if (value > max) + { + return max; + } + + return value; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static float Clamp(float value, float min, float max) + { + if (min > max) + { + throw new ArgumentOutOfRangeException("Min should be lower than max."); + } + + if (value < min) + { + return min; + } + else if (value > max) + { + return max; + } + + return value; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ushort Clamp(ushort value, ushort min, ushort max) + { + if (min > max) + { + throw new ArgumentOutOfRangeException("Min should be lower than max."); + } + + if (value < min) + { + return min; + } + else if (value > max) + { + return max; + } + + return value; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint Clamp(uint value, uint min, uint max) + { + if (min > max) + { + throw new ArgumentOutOfRangeException("Min should be lower than max."); + } + + if (value < min) + { + return min; + } + else if (value > max) + { + return max; + } + + return value; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong Clamp(ulong value, ulong min, ulong max) + { + if (min > max) + { + throw new ArgumentOutOfRangeException("Min should be lower than max."); + } + + if (value < min) + { + return min; + } + else if (value > max) + { + return max; + } + + return value; + } + + } +} \ No newline at end of file diff --git a/CUERipper.Avalonia/Compatibility/OS.cs b/CUERipper.Avalonia/Compatibility/OS.cs new file mode 100644 index 00000000..c31bd308 --- /dev/null +++ b/CUERipper.Avalonia/Compatibility/OS.cs @@ -0,0 +1,15 @@ +using System; + +namespace CUERipper.Avalonia.Compatibility +{ + internal static class OS + { +#if NET47 + public static bool IsWindows() => true; + public static bool IsLinux() => false; +#else + public static bool IsWindows() => OperatingSystem.IsWindows(); + public static bool IsLinux() => OperatingSystem.IsLinux(); +#endif + } +} diff --git a/CUERipper.Avalonia/Compatibility/RequiredMemberAttribute.cs b/CUERipper.Avalonia/Compatibility/RequiredMemberAttribute.cs new file mode 100644 index 00000000..9063f3b1 --- /dev/null +++ b/CUERipper.Avalonia/Compatibility/RequiredMemberAttribute.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Modified for CUERipper + +using System.ComponentModel; + +namespace System.Runtime.CompilerServices +{ +#if NET47 + /// Specifies that a type has required members or that a member is required. + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] + [EditorBrowsable(EditorBrowsableState.Never)] +#if SYSTEM_PRIVATE_CORELIB + public +#else + internal +#endif + sealed class RequiredMemberAttribute : Attribute + { } +#endif +} \ No newline at end of file diff --git a/CUERipper.Avalonia/Configuration/Abstractions/ICUEConfigFacade.cs b/CUERipper.Avalonia/Configuration/Abstractions/ICUEConfigFacade.cs new file mode 100644 index 00000000..bc9d9a4f --- /dev/null +++ b/CUERipper.Avalonia/Configuration/Abstractions/ICUEConfigFacade.cs @@ -0,0 +1,86 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CUETools.Codecs; +using CUETools.CTDB; +using CUETools.Processor; +using System.Collections.Generic; + +namespace CUERipper.Avalonia.Configuration.Abstractions +{ + public interface ICUEConfigFacade + { + /// + /// Avoid using this function unless absolutely necessary. + /// + /// + CUEConfig ToCUEConfig(); + + string Language { get; set; } + + Dictionary Formats { get; } + EncoderListViewModel Encoders { get; } + Dictionary Scripts { get; } + + string DefaultDrive { get; set; } + SerializableDictionary DriveOffsets { get; } + SerializableDictionary DriveC2ErrorModes { get; } + AudioEncoderType OutputCompression { get; set; } + string DefaultLosslessFormat { get; set; } + string DefaultLossyFormat { get; set; } + string EncodingConfiguration { get; set; } + + string CTDBServer { get; set; } + CTDBMetadataSearch MetadataSearch { get; set; } + CUEConfigAdvanced.CTDBCoversSize CoversSize { get; set; } + CUEConfigAdvanced.CTDBCoversSearch CoversSearch { get; set; } + bool DetailedCTDBLog { get; set; } + + // Extraction options + bool PreserveHTOA { get; set; } + bool DetectGaps { get; set; } + bool CreateEACLog { get; set; } + bool CreateM3U { get; set; } + bool EmbedAlbumArt { get; set; } + bool EjectAfterRip { get; set; } + bool DisableEjectDisc { get; set; } + string TrackFilenameFormat { get; set; } + bool AutomaticRip { get; set; } + bool SkipRepair { get; set; } + + // Proxy options + CUEConfigAdvanced.ProxyMode UseProxyMode { get; set; } + string ProxyServer { get; set; } + int ProxyPort { get; set; } + string ProxyUser { get; set; } + string ProxyPassword { get; set; } + + // Various options + string FreedbSiteAddress { get; set; } + + // UI + int CUEStyleIndex { get; set; } + int SecureModeIndex { get; set; } + bool TestAndCopyEnabled { get; set; } + string PathFormat { get; set; } + List PathFormatTemplates { get; set; } + bool DetailPaneOpened { get; set; } + + void Save(); + } +} \ No newline at end of file diff --git a/CUERipper.Avalonia/Configuration/CUEConfigFacade.cs b/CUERipper.Avalonia/Configuration/CUEConfigFacade.cs new file mode 100644 index 00000000..559a23a7 --- /dev/null +++ b/CUERipper.Avalonia/Configuration/CUEConfigFacade.cs @@ -0,0 +1,196 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Controls; +using CUETools.Processor; +using System.IO; +using System; +using CUETools.Processor.Settings; +using System.Xml; +using System.Xml.Serialization; +using System.Collections.Generic; +using CUETools.Codecs; +using CUETools.CTDB; +using CUERipper.Avalonia.Configuration.Abstractions; + +namespace CUERipper.Avalonia.Configuration +{ + public class CUEConfigFacade : ICUEConfigFacade + { + private readonly CUEConfig _cueConfig; + /// + /// Avoid using this function unless absolutely necessary. + /// + /// + public CUEConfig ToCUEConfig() => _cueConfig; + + private readonly CUERipperConfig _cueRipperConfig; + + public string Language { get => _cueConfig.language; set => _cueConfig.language = value; } + + public Dictionary Formats { get => _cueConfig.formats; } + public EncoderListViewModel Encoders { get => _cueConfig.Encoders; } + public Dictionary Scripts { get => _cueConfig.scripts; } + + public string DefaultDrive { get => _cueRipperConfig.DefaultDrive; set => _cueRipperConfig.DefaultDrive = value; } + public SerializableDictionary DriveOffsets { get => _cueRipperConfig.DriveOffsets; } + public SerializableDictionary DriveC2ErrorModes { get => _cueRipperConfig.DriveC2ErrorModes; } + public AudioEncoderType OutputCompression { get; set; } = AudioEncoderType.Lossless; + public string DefaultLosslessFormat { get => _cueRipperConfig.DefaultLosslessFormat; set => _cueRipperConfig.DefaultLosslessFormat = value; } + public string DefaultLossyFormat { get => _cueRipperConfig.DefaultLossyFormat; set => _cueRipperConfig.DefaultLossyFormat = value; } + public string EncodingConfiguration { get => _cueRipperConfig.EncodingConfiguration; set => _cueRipperConfig.EncodingConfiguration = value; } + + // CTDB Options + public string CTDBServer { get => _cueConfig.advanced.CTDBServer; set => _cueConfig.advanced.CTDBServer = value; } + public CTDBMetadataSearch MetadataSearch { get => _cueConfig.advanced.metadataSearch; set => _cueConfig.advanced.metadataSearch = value; } + public CUEConfigAdvanced.CTDBCoversSize CoversSize { get => _cueConfig.advanced.coversSize; set => _cueConfig.advanced.coversSize = value; } + public CUEConfigAdvanced.CTDBCoversSearch CoversSearch { get => _cueConfig.advanced.coversSearch; set => _cueConfig.advanced.coversSearch = value; } + public bool DetailedCTDBLog { get => _cueConfig.advanced.DetailedCTDBLog; set => _cueConfig.advanced.DetailedCTDBLog = value; } + + // Extraction options + public bool PreserveHTOA { get => _cueConfig.preserveHTOA; set => _cueConfig.preserveHTOA = value; } + public bool DetectGaps { get => _cueConfig.detectGaps; set => _cueConfig.detectGaps = value; } + public bool CreateEACLog { get => _cueConfig.createEACLOG; set => _cueConfig.createEACLOG = value; } + public bool CreateM3U { get => _cueConfig.createM3U; set => _cueConfig.createM3U = value; } + public bool EmbedAlbumArt { get => _cueConfig.embedAlbumArt; set => _cueConfig.embedAlbumArt = value; } + public bool EjectAfterRip { get => _cueConfig.ejectAfterRip; set => _cueConfig.ejectAfterRip = value; } + public bool DisableEjectDisc { get => _cueConfig.disableEjectDisc; set => _cueConfig.disableEjectDisc = value; } + public string TrackFilenameFormat { get => _cueConfig.trackFilenameFormat; set => _cueConfig.trackFilenameFormat = value; } + public bool AutomaticRip { get => _cueRipperConfig.AutomaticRip; set => _cueRipperConfig.AutomaticRip = value; } + public bool SkipRepair { get => _cueRipperConfig.SkipRepair; set => _cueRipperConfig.SkipRepair = value; } + + // Proxy options + public CUEConfigAdvanced.ProxyMode UseProxyMode { get => _cueConfig.advanced.UseProxyMode; set => _cueConfig.advanced.UseProxyMode = value; } + public string ProxyServer { get => _cueConfig.advanced.ProxyServer; set => _cueConfig.advanced.ProxyServer = value; } + public int ProxyPort { get => _cueConfig.advanced.ProxyPort; set => _cueConfig.advanced.ProxyPort = value; } + public string ProxyUser { get => _cueConfig.advanced.ProxyUser; set => _cueConfig.advanced.ProxyUser = value; } + public string ProxyPassword { get => _cueConfig.advanced.ProxyPassword; set => _cueConfig.advanced.ProxyPassword = value; } + + // Various options + public string FreedbSiteAddress { get => _cueConfig.advanced.FreedbSiteAddress; set => _cueConfig.advanced.FreedbSiteAddress = value; } + + // UI + public int CUEStyleIndex { get; set; } = 0; + public int SecureModeIndex { get; set; } = 0; + public bool TestAndCopyEnabled { get; set; } = false; + public string PathFormat { get; set; } = string.Empty; + public List PathFormatTemplates { get; set; } = []; + public bool DetailPaneOpened { get => _cueRipperConfig.DetailPaneOpened; set => _cueRipperConfig.DetailPaneOpened = value; } + + private CUEConfigFacade(CUEConfig cueConfig, CUERipperConfig cueRipperConfig) + { + _cueConfig = cueConfig; + _cueRipperConfig = cueRipperConfig; + } + + public static CUEConfigFacade Create() + { + var cueConfig = new CUEConfig(); + var cueRipperConfig = new CUERipperConfig(); + + AudioEncoderType outputCompression = AudioEncoderType.Lossless; + int? cueStyleIndex = null; + int? secureModeIndex = null; + bool? testAndCopyEnabled = null; + string pathFormat = Constants.DefaultPathFormats[0]; + int pathFormatTemplateCount = 0; + List pathFormatTemplates = []; + + if (!Design.IsDesignMode) + { +#if NET47 + var appDirectory = AppDomain.CurrentDomain.BaseDirectory; +#else + var appDirectory = Environment.ProcessPath; +#endif + + var settingsReader = new SettingsReader("CUERipper", "settings.txt", appDirectory); + cueConfig.Load(settingsReader); + + try + { + outputCompression = (AudioEncoderType?)settingsReader.LoadInt32("OutputAudioType", null, null) ?? AudioEncoderType.Lossless; + cueStyleIndex = settingsReader.LoadInt32("ComboImage", int.MinValue, int.MaxValue); + secureModeIndex = settingsReader.LoadInt32("SecureMode", int.MinValue, int.MaxValue); + testAndCopyEnabled = settingsReader.LoadBoolean("TestAndCopy"); + + pathFormat = settingsReader.Load("PathFormat") ?? Constants.DefaultPathFormats[0]; + pathFormatTemplateCount = settingsReader.LoadInt32("OutputPathUseTemplates", 0, Constants.MaxPathFormats) ?? 0; + for(int i = 0; i < pathFormatTemplateCount; ++i) + { + var template = settingsReader.Load($"OutputPathUseTemplate{i}") ?? string.Empty; + pathFormatTemplates.Add(template); + } + + using TextReader reader = new StringReader(settingsReader.Load("CUERipper")); + if (CUERipperConfig.serializer.Deserialize(reader) is CUERipperConfig ripperConfig) cueRipperConfig = ripperConfig; + } + catch (Exception) + { + // Do nothing... + } + } + + var config = new CUEConfigFacade(cueConfig, cueRipperConfig); + config.OutputCompression = outputCompression; + if (cueStyleIndex != null) config.CUEStyleIndex = cueStyleIndex.Value; + if (secureModeIndex != null) config.SecureModeIndex = secureModeIndex.Value; + if (testAndCopyEnabled != null) config.TestAndCopyEnabled = testAndCopyEnabled.Value; + config.PathFormat = pathFormat; + config.PathFormatTemplates = pathFormatTemplates; + + return config; + } + + private readonly static XmlSerializerNamespaces xmlEmptyNamespaces = new([XmlQualifiedName.Empty]); + private readonly static XmlWriterSettings xmlEmptySettings = new() { Indent = true, OmitXmlDeclaration = true }; + public void Save() + { +#if NET47 + var appDirectory = AppDomain.CurrentDomain.BaseDirectory; +#else + var appDirectory = Environment.ProcessPath; +#endif + + var sw = new SettingsWriter("CUERipper", "settings.txt", appDirectory); + _cueConfig.Save(sw); + + sw.Save("OutputAudioType", (int)OutputCompression); + sw.Save("ComboImage", CUEStyleIndex); + sw.Save("SecureMode", SecureModeIndex); + sw.Save("TestAndCopy", TestAndCopyEnabled); + // sw.Save("WidthIncrement", SizeIncrement.Width); + // sw.Save("HeightIncrement", SizeIncrement.Height); + + sw.Save("PathFormat", PathFormat); + sw.Save("OutputPathUseTemplates", PathFormatTemplates.Count); + for (int i = 0; i < PathFormatTemplates.Count; ++i) + { + sw.Save($"OutputPathUseTemplate{i}", PathFormatTemplates[i]); + } + + using TextWriter tw = new StringWriter(); + using XmlWriter xw = XmlTextWriter.Create(tw, xmlEmptySettings); + + CUERipperConfig.serializer.Serialize(xw, _cueRipperConfig, xmlEmptyNamespaces); + sw.SaveText("CUERipper", tw.ToString()); + + sw.Close(); + } + } +} diff --git a/CUERipper.Avalonia/Constants.cs b/CUERipper.Avalonia/Constants.cs new file mode 100644 index 00000000..b7a31712 --- /dev/null +++ b/CUERipper.Avalonia/Constants.cs @@ -0,0 +1,58 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CUETools.Processor; + +namespace CUERipper.Avalonia +{ + public static class Constants + { + public const string UnknownArtist = "Unknown artist"; + public const string UnknownTitle = "Unknown title"; + public const string UnknownTrack = "Track"; + public const string TrackNullLength = "-:--"; + public const string NoCDDriveFound = "No CD drive found"; + + public static readonly string[] SecureModeValues = ["Burst", "Secure", "Paranoid"]; + public const int SecureModeDefault = 1; + + public const string TempFolderCUERipper = ".cuetmp"; + public const string CueExtension = ".cue"; + public const string JpgExtension = ".jpg"; + public const string HiResCoverName = "cover_hi-res"; + + public static readonly string[] DefaultPathFormats = [ + $"%music%/%artist%/[%year% - ]%album%[ '('disc %discnumberandname%')']/%artist% - %album%{CueExtension}", + $"%music%/%artist%/[%year% - ]%album%[ '('disc %discnumberandname%')'][' ('%releasedateandlabel%')'][' ('%unique%')']/%artist% - %album%{CueExtension}" + ]; + public const int MaxPathFormats = 10; // Based on the original CUERipper limit + + public readonly static string ApplicationName = $"CUERipper.Avalonia {CUESheet.CUEToolsVersion}"; + + public const string PathNoto = "avares://CUERipper.Avalonia/Assets/noto-emoji/32/"; + public const string PathImageCache = "./CUERipper/.AlbumCache/"; + public const string PathUpdate = "./.cueupdate/"; + + public const int EmbeddedImageMaxDimension = 500; + public const int HiResImageMaxDimension = 2048; + + public const string GithubApiUri = "https://api.github.com/repos/gchudov/cuetools.net/releases"; + public const string UserAgent = "Mozilla/5.0"; + public const string UpdaterExecutable = "CUETools.Updater.exe"; + } +} diff --git a/CUERipper.Avalonia/Events/DirectoryConflictEventArgs.cs b/CUERipper.Avalonia/Events/DirectoryConflictEventArgs.cs new file mode 100644 index 00000000..ed8083f4 --- /dev/null +++ b/CUERipper.Avalonia/Events/DirectoryConflictEventArgs.cs @@ -0,0 +1,34 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using System; + +namespace CUERipper.Avalonia.Events +{ + public class DirectoryConflictEventArgs : EventArgs + { + public string Directory { get; set; } + public bool CanModifyContent { get; set; } + + public DirectoryConflictEventArgs(string directory, bool canModifyContent) + { + Directory = directory; + CanModifyContent = canModifyContent; + } + } +} \ No newline at end of file diff --git a/CUERipper.Avalonia/Events/DriveChangedEventArgs.cs b/CUERipper.Avalonia/Events/DriveChangedEventArgs.cs new file mode 100644 index 00000000..3a3c6c84 --- /dev/null +++ b/CUERipper.Avalonia/Events/DriveChangedEventArgs.cs @@ -0,0 +1,33 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using System; + +namespace CUERipper.Avalonia.Events +{ + public class DriveChangedEventArgs : EventArgs + { + public char PreviousDrive { get; } + public char NextDrive { get; } + public DriveChangedEventArgs(char previousDrive, char nextDrive) + { + PreviousDrive = previousDrive; + NextDrive = nextDrive; + } + } +} diff --git a/CUERipper.Avalonia/Events/GenericProgressEventArgs.cs b/CUERipper.Avalonia/Events/GenericProgressEventArgs.cs new file mode 100644 index 00000000..cb86137c --- /dev/null +++ b/CUERipper.Avalonia/Events/GenericProgressEventArgs.cs @@ -0,0 +1,14 @@ +using System; + +namespace CUERipper.Avalonia.Events +{ + public class GenericProgressEventArgs : EventArgs + { + public float Progress { get; set; } + + public GenericProgressEventArgs(float progress) + { + Progress = progress; + } + } +} diff --git a/CUERipper.Avalonia/Events/RipperFinishedEventArgs.cs b/CUERipper.Avalonia/Events/RipperFinishedEventArgs.cs new file mode 100644 index 00000000..e8dd6580 --- /dev/null +++ b/CUERipper.Avalonia/Events/RipperFinishedEventArgs.cs @@ -0,0 +1,36 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using System; + +namespace CUERipper.Avalonia.Events +{ + public class RipperFinishedEventArgs : EventArgs + { + public bool IsSuccess { get; } + public string Status { get; } + public string PopupContent { get; } + + public RipperFinishedEventArgs(bool isSuccess, string status, string popupContent) + { + IsSuccess = isSuccess; + Status = status; + PopupContent = popupContent; + } + } +} diff --git a/CUERipper.Avalonia/Exceptions/CUEToolsCoreException.cs b/CUERipper.Avalonia/Exceptions/CUEToolsCoreException.cs new file mode 100644 index 00000000..405803a7 --- /dev/null +++ b/CUERipper.Avalonia/Exceptions/CUEToolsCoreException.cs @@ -0,0 +1,33 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using System; + +namespace CUERipper.Avalonia.Exceptions +{ + /// + /// Generic wrapper for internal errors + /// + public class CUEToolsCoreException : Exception + { + public CUEToolsCoreException(string message) : base(message) + { + + } + } +} diff --git a/CUERipper.Avalonia/Exceptions/NotInAvaloniaDesignModeException.cs b/CUERipper.Avalonia/Exceptions/NotInAvaloniaDesignModeException.cs new file mode 100644 index 00000000..c60b8141 --- /dev/null +++ b/CUERipper.Avalonia/Exceptions/NotInAvaloniaDesignModeException.cs @@ -0,0 +1,31 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using System; + +namespace CUERipper.Avalonia.Exceptions +{ + public class NotInAvaloniaDesignModeException + : Exception + { + public NotInAvaloniaDesignModeException() + : base("This code may not be executed outside of the Avalonia design mode.") + { + } + } +} diff --git a/CUERipper.Avalonia/Exceptions/NotInitializedException.cs b/CUERipper.Avalonia/Exceptions/NotInitializedException.cs new file mode 100644 index 00000000..6f24d5f4 --- /dev/null +++ b/CUERipper.Avalonia/Exceptions/NotInitializedException.cs @@ -0,0 +1,29 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using System; + +namespace CUERipper.Avalonia.Exceptions +{ + public class NotInitializedException : NullReferenceException + { + public NotInitializedException(string obj) : base($"{obj} is null. Did you forget to call Init?") + { + } + } +} diff --git a/CUERipper.Avalonia/Exceptions/UnexpectedParentException.cs b/CUERipper.Avalonia/Exceptions/UnexpectedParentException.cs new file mode 100644 index 00000000..dca2da65 --- /dev/null +++ b/CUERipper.Avalonia/Exceptions/UnexpectedParentException.cs @@ -0,0 +1,30 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using System; + +namespace CUERipper.Avalonia.Exceptions +{ + public class UnexpectedParentException : Exception + { + public UnexpectedParentException(Type expected, Type? actual) + : base($"Expected a parent of type '{expected.Name}', but received a parent of type '{actual?.Name}'.") + { + } + } +} diff --git a/CUERipper.Avalonia/Exceptions/ViewModelMismatchException.cs b/CUERipper.Avalonia/Exceptions/ViewModelMismatchException.cs new file mode 100644 index 00000000..ccabdac2 --- /dev/null +++ b/CUERipper.Avalonia/Exceptions/ViewModelMismatchException.cs @@ -0,0 +1,27 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using System; + +namespace CUERipper.Avalonia.Exceptions +{ + public class ViewModelMismatchException(Type expected, Type? actual) + : Exception($"Expected view model of type {expected.Name}, got {actual?.Name}.") + { + } +} diff --git a/CUERipper.Avalonia/Extensions/AlbumMetadataExtensions.cs b/CUERipper.Avalonia/Extensions/AlbumMetadataExtensions.cs new file mode 100644 index 00000000..aa6dac4c --- /dev/null +++ b/CUERipper.Avalonia/Extensions/AlbumMetadataExtensions.cs @@ -0,0 +1,72 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CUERipper.Avalonia.Compatibility; +using CUERipper.Avalonia.Configuration.Abstractions; +using CUERipper.Avalonia.Models; +using CUETools.CDImage; +using CUETools.Processor; + +namespace CUERipper.Avalonia.Extensions +{ + internal static class AlbumMetadataExtensions + { + // I'll put this here for now. It's a bit of a nasty function, but it does the job. + /// + /// Generate a path based on the current metadata and provided format. + /// Only call this for UI related functions. + /// + /// + /// + /// + /// Formatted path + public static string PathStringFromFormat(this AlbumMetadata? meta, string format, ICUEConfigFacade config) + { + CUESheet? cueSheet = null; + if (meta?.Data.Tracks.Count > 0) + { + // Dummy layout for the CopyMetadata function + var cdLayout = new CDImageLayout(); + for (uint i = 0; i < meta.Data.Tracks.Count; ++i) + { + cdLayout.AddTrack(new CDTrack(0, i * 100, 100, true, false)); + } + + cueSheet = new CUESheet(config.ToCUEConfig()) + { + Action = CUEAction.Encode + , TOC = cdLayout + }; + cueSheet.CopyMetadata(meta.Data); + } + + var path = CUESheet.GenerateUniqueOutputPath( + _config: config.ToCUEConfig() + , format: format + , ext: Constants.CueExtension + , action: CUEAction.Encode + , vars: [] + , pathIn: null + , cueSheet: cueSheet); + + cueSheet?.Close(); + + return OS.IsWindows() ? path.Replace('/', '\\') : path.Replace('\\', '/'); + } + } +} diff --git a/CUERipper.Avalonia/Extensions/BitmapExtensions.cs b/CUERipper.Avalonia/Extensions/BitmapExtensions.cs new file mode 100644 index 00000000..91c775c2 --- /dev/null +++ b/CUERipper.Avalonia/Extensions/BitmapExtensions.cs @@ -0,0 +1,43 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Media.Imaging; +using Avalonia; +using System; + +namespace CUERipper.Avalonia.Extensions +{ + public static class BitmapExtensions + { + public static Bitmap ContainedResize(this Bitmap bitmap, int maxDimension) + { + var targetSize = bitmap.PixelSize; + + if (targetSize.Width > maxDimension || targetSize.Height > maxDimension) + { + var longestSide = Math.Max(targetSize.Width, targetSize.Height); + var scaleFactor = (double)maxDimension / longestSide; + + targetSize = new PixelSize((int)(targetSize.Width * scaleFactor) + , (int)(targetSize.Height * scaleFactor)); + } + + return bitmap.CreateScaledBitmap(targetSize, BitmapInterpolationMode.HighQuality); + } + } +} diff --git a/CUERipper.Avalonia/Extensions/EnumerableExtensions.cs b/CUERipper.Avalonia/Extensions/EnumerableExtensions.cs new file mode 100644 index 00000000..ac3117ff --- /dev/null +++ b/CUERipper.Avalonia/Extensions/EnumerableExtensions.cs @@ -0,0 +1,50 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CUERipper.Avalonia.Compatibility; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CUERipper.Avalonia.Extensions +{ + public static class EnumerableExtensions + { + /// + /// Silly replacement for !Any for readability + /// + /// + /// + /// inverted .Any() with an additional null check + public static bool None(this IEnumerable? src) + => src == null || !src.Any(); + + public static bool None(this IEnumerable? src, Func predicate) + => src == null || !src.Any(predicate); + + public static IEnumerable PrependIf(this IEnumerable src, bool condition, T item) + { + if (condition) + { + src = src.Prepend(item); + } + + return src; + } + } +} diff --git a/CUERipper.Avalonia/Extensions/MD5Extensions.cs b/CUERipper.Avalonia/Extensions/MD5Extensions.cs new file mode 100644 index 00000000..ad50a276 --- /dev/null +++ b/CUERipper.Avalonia/Extensions/MD5Extensions.cs @@ -0,0 +1,54 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using System.Security.Cryptography; +using System.Text; + +namespace CUERipper.Avalonia.Extensions +{ + public static class MD5Extensions + { + /// + /// Generates an MD5 hash. + /// The resulting hash is 16 bytes long and relatively unique. + /// + /// The bytes to hash. + /// A 16-byte MD5 hash as a hexidecimal string. + public static string ComputeHashAsString(this MD5 md5, byte[] input, string format = "X2") + { + var hash = md5.ComputeHash(input); + + var stringBuilder = new StringBuilder(); + for (int i = 0; i < hash.Length; ++i) + { + stringBuilder.Append(hash[i].ToString(format)); + } + + return stringBuilder.ToString(); + } + + /// + /// Generates an MD5 hash. + /// The resulting hash is 16 bytes long and relatively unique. + /// + /// The bytes to hash. + /// A 16-byte MD5 hash as a hexidecimal string. + public static string ComputeHashAsString(this MD5 md5, string input, string format = "X2") + => ComputeHashAsString(md5, Encoding.UTF8.GetBytes(input), format); + } +} diff --git a/CUERipper.Avalonia/Extensions/ObservableCollectionExtensions.cs b/CUERipper.Avalonia/Extensions/ObservableCollectionExtensions.cs new file mode 100644 index 00000000..983a9866 --- /dev/null +++ b/CUERipper.Avalonia/Extensions/ObservableCollectionExtensions.cs @@ -0,0 +1,37 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using System.Collections.ObjectModel; +using System.Linq; + +namespace CUERipper.Avalonia.Extensions +{ + public static class ObservableCollectionExtensions + { + public static void MoveAll(this ObservableCollection? src, ObservableCollection dst) + { + if (src == null) return; + + while (src.Any()) + { + dst.Add(src[0]); + src.RemoveAt(0); + } + } + } +} diff --git a/CUERipper.Avalonia/Extensions/WindowExtensions.cs b/CUERipper.Avalonia/Extensions/WindowExtensions.cs new file mode 100644 index 00000000..73fe2576 --- /dev/null +++ b/CUERipper.Avalonia/Extensions/WindowExtensions.cs @@ -0,0 +1,33 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Controls; +using System.Threading.Tasks; + +namespace CUERipper.Avalonia.Extensions +{ + internal static class WindowExtensions + { + public static async Task ShowDialog(this Window self, Window parent, bool lockParent) + { + if (lockParent) parent.IsEnabled = false; + await self.ShowDialog(parent); + if (lockParent) parent.IsEnabled = true; + } + } +} diff --git a/CUERipper.Avalonia/Language.cs b/CUERipper.Avalonia/Language.cs new file mode 100644 index 00000000..5a5f91ac --- /dev/null +++ b/CUERipper.Avalonia/Language.cs @@ -0,0 +1,26 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion + +namespace CUERipper.Avalonia +{ + public class Language + { + // Empty class matching the resource name + } +} diff --git a/CUERipper.Avalonia/Models/AlbumMetadata.cs b/CUERipper.Avalonia/Models/AlbumMetadata.cs new file mode 100644 index 00000000..efeb7692 --- /dev/null +++ b/CUERipper.Avalonia/Models/AlbumMetadata.cs @@ -0,0 +1,24 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CUETools.Processor; + +namespace CUERipper.Avalonia.Models +{ + public record AlbumMetadata(MetaSource Source, CUEMetadata Data); +} diff --git a/CUERipper.Avalonia/Models/AlbumRelease.cs b/CUERipper.Avalonia/Models/AlbumRelease.cs new file mode 100644 index 00000000..c48190e2 --- /dev/null +++ b/CUERipper.Avalonia/Models/AlbumRelease.cs @@ -0,0 +1,24 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Media.Imaging; + +namespace CUERipper.Avalonia.Models +{ + public record AlbumRelease(string Name, Bitmap? Icon); +} diff --git a/CUERipper.Avalonia/Models/EncodingConfiguration.cs b/CUERipper.Avalonia/Models/EncodingConfiguration.cs new file mode 100644 index 00000000..e817a3a8 --- /dev/null +++ b/CUERipper.Avalonia/Models/EncodingConfiguration.cs @@ -0,0 +1,26 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +namespace CUERipper.Avalonia.Models +{ + public record EncodingConfiguration(bool IsLossless + , int CUEStyleIndex + , string Encoding + , string Encoder + , string EncoderMode); +} diff --git a/CUERipper.Avalonia/Models/Github/GithubAsset.cs b/CUERipper.Avalonia/Models/Github/GithubAsset.cs new file mode 100644 index 00000000..db8a9e35 --- /dev/null +++ b/CUERipper.Avalonia/Models/Github/GithubAsset.cs @@ -0,0 +1,38 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using System; +using Newtonsoft.Json; + +namespace CUERipper.Avalonia.Models.Github +{ + public class GithubAsset + { + [JsonProperty("id")] public long Id { get; set; } + [JsonProperty("url")] public required string Url { get; set; } + [JsonProperty("name")] public required string Name { get; set; } + [JsonProperty("content_type")] public required string ContentType { get; set; } + [JsonProperty("state")] public required string State { get; set; } + [JsonProperty("size")] public long Size { get; set; } + [JsonProperty("download_count")] public int DownloadCount { get; set; } + [JsonProperty("created_at")] public DateTime CreatedAt { get; set; } + [JsonProperty("updated_at")] public DateTime UpdatedAt { get; set; } + [JsonProperty("browser_download_url")] public required string BrowserDownloadUrl { get; set; } + [JsonProperty("uploader")] public required GithubReleaseUser Uploader { get; set; } + } +} diff --git a/CUERipper.Avalonia/Models/Github/GithubRelease.cs b/CUERipper.Avalonia/Models/Github/GithubRelease.cs new file mode 100644 index 00000000..4913d0f8 --- /dev/null +++ b/CUERipper.Avalonia/Models/Github/GithubRelease.cs @@ -0,0 +1,39 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace CUERipper.Avalonia.Models.Github +{ + public class GithubRelease + { + [JsonProperty("id")] public long Id { get; set; } + [JsonProperty("tag_name")] public required string TagName { get; set; } + [JsonProperty("target_commitish")] public required string TargetCommitish { get; set; } + [JsonProperty("name")] public required string Name { get; set; } + [JsonProperty("draft")] public bool Draft { get; set; } + [JsonProperty("prerelease")] public bool PreRelease { get; set; } + [JsonProperty("created_at")] public DateTime CreatedAt { get; set; } + [JsonProperty("published_at")] public DateTime PublishedAt { get; set; } + [JsonProperty("body")] public required string Body { get; set; } + [JsonProperty("author")] public required GithubReleaseUser Author { get; set; } + [JsonProperty("assets")] public required List Assets { get; set; } + } +} diff --git a/CUERipper.Avalonia/Models/Github/GithubReleaseUser.cs b/CUERipper.Avalonia/Models/Github/GithubReleaseUser.cs new file mode 100644 index 00000000..34db9ae7 --- /dev/null +++ b/CUERipper.Avalonia/Models/Github/GithubReleaseUser.cs @@ -0,0 +1,28 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Newtonsoft.Json; + +namespace CUERipper.Avalonia.Models.Github +{ + public class GithubReleaseUser + { + [JsonProperty("id")] public long Id { get; set; } + [JsonProperty("url")] public required string Url { get; set; } + } +} diff --git a/CUERipper.Avalonia/Models/Github/GithubUser.cs b/CUERipper.Avalonia/Models/Github/GithubUser.cs new file mode 100644 index 00000000..c5227647 --- /dev/null +++ b/CUERipper.Avalonia/Models/Github/GithubUser.cs @@ -0,0 +1,28 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Newtonsoft.Json; + +namespace CUERipper.Avalonia.Models.Github +{ + public class GithubUser + { + [JsonProperty("id")] public long Id { get; set; } + [JsonProperty("name")] public required string Name { get; set; } + } +} diff --git a/CUERipper.Avalonia/Models/MetaSource.cs b/CUERipper.Avalonia/Models/MetaSource.cs new file mode 100644 index 00000000..433592e0 --- /dev/null +++ b/CUERipper.Avalonia/Models/MetaSource.cs @@ -0,0 +1,41 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +namespace CUERipper.Avalonia.Models +{ + public enum MetaSource + { + None + , Local + , MusicBrainz + , Discogs + , Freedb + } + + public static class MetaSourceHelper + { + public static MetaSource FromString(string? str) + => str?.ToLower() switch { + "local" => MetaSource.Local + , "musicbrainz" => MetaSource.MusicBrainz + , "discogs" => MetaSource.Discogs + , "freedb" => MetaSource.Freedb + , _ => MetaSource.None + }; + } +} \ No newline at end of file diff --git a/CUERipper.Avalonia/Models/RipSettings.cs b/CUERipper.Avalonia/Models/RipSettings.cs new file mode 100644 index 00000000..f2b024a1 --- /dev/null +++ b/CUERipper.Avalonia/Models/RipSettings.cs @@ -0,0 +1,32 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CUETools.Ripper; + +namespace CUERipper.Avalonia.Models +{ + public readonly struct RipSettings + { + public int DriveOffset { get; init; } + public DriveC2ErrorModeSetting C2ErrorModeSetting { get; init; } + public int CorrectionQuality { get; init; } + public bool TestAndCopy { get; init; } + public string AlbumCoverUri { get; init; } + public EncodingConfiguration[] EncodingConfiguration { get; init; } + } +} diff --git a/CUERipper.Avalonia/Models/TrackModel.cs b/CUERipper.Avalonia/Models/TrackModel.cs new file mode 100644 index 00000000..b047d0ae --- /dev/null +++ b/CUERipper.Avalonia/Models/TrackModel.cs @@ -0,0 +1,46 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CommunityToolkit.Mvvm.ComponentModel; +using System; +namespace CUERipper.Avalonia.Models +{ + public partial class TrackModel : ObservableObject + { + public int TrackNo { get; init; } + + private string title = string.Empty; + public string Title { + get => title; + set { title = value; OnUpdate?.Invoke(this); } + } + + public string Length { get; init; } = string.Empty; + + private string artist = string.Empty; + public string Artist { + get => artist; + set { artist = value; OnUpdate?.Invoke(this); } + } + + public Action? OnUpdate { private get; init; } + + [ObservableProperty] + private int progress; + } +} diff --git a/CUERipper.Avalonia/Models/UpdateMetadata.cs b/CUERipper.Avalonia/Models/UpdateMetadata.cs new file mode 100644 index 00000000..2cdde4f6 --- /dev/null +++ b/CUERipper.Avalonia/Models/UpdateMetadata.cs @@ -0,0 +1,46 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using System; + +namespace CUERipper.Avalonia.Models +{ + public record UpdateMetadata( + string Version + , string CurrentVersion + , string Author + , string Description + , DateTime Date + , string Uri + , long Size + , string HashUri + , long HashSize + ); + + public static class UpdateMetadataExtensions + { +#if DEBUG + // Allow downloading the same version when debugging + public static bool UpdateAvailable(this UpdateMetadata? updateMetadata) + => updateMetadata != null; +#else + public static bool UpdateAvailable(this UpdateMetadata? updateMetadata) + => updateMetadata != null && updateMetadata.Version != updateMetadata.CurrentVersion; +#endif + } +} diff --git a/CUERipper.Avalonia/Program.cs b/CUERipper.Avalonia/Program.cs new file mode 100644 index 00000000..175f748c --- /dev/null +++ b/CUERipper.Avalonia/Program.cs @@ -0,0 +1,45 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia; +using System; + +namespace CUERipper.Avalonia +{ + /// + /// Avalonia boilerplate code + /// + internal sealed class Program + { + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) + { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); + } +} diff --git a/CUERipper.Avalonia/Resources/Language.nl-NL.resx b/CUERipper.Avalonia/Resources/Language.nl-NL.resx new file mode 100644 index 00000000..1ea190c1 --- /dev/null +++ b/CUERipper.Avalonia/Resources/Language.nl-NL.resx @@ -0,0 +1,267 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Nummers + + + Albuminformatie + + + Titel + + + Tijdsduur + + + Voortgang + + + Artiest + + + CD + + + Annuleren + + + Enkelvoudig + + + Verliesloos + + + Verlieslatend + + + Ja + + + Nee + + + Meervoudig + + + Gereed + + + Poging + + + Afronden... even geduld. + + + De CD is onverwacht verwijderd. + + + De CD is verwijderd. + + + Het rippen wordt gestopt, even geduld. + + + Het rippen is gestopt. + + + van + + + Artiest + + + Titel + + + Genre + + + Jaar + + + Huidige CD + + + Aantal CD's + + + CD Naam + + + Platenlabel + + + Label No. + + + Uitgavedatum + + + Streepjescode + + + Land + + + Opmerking + + + Oké + + + (RipFailedNoAccessDrive) De applicatie heeft geen toegang tot de CD-speler. + + + Het rippen is mislukt! + + + (RipFailedMetadata) De applicatie kan de metadata van het album niet verwerken. + + + (RipFailedOutputPath) Ongeldig pad meegegeven. + + + Het rippen is afgrond. + + + Het rippen is gestopt door de gebruiker. + + + (Unexpected) Er is een onverwachte fout opgetreden. + + + CD bevat fouten. + + + Verliesloos levert bestanden op die groot en accuraat zijn. Verlieslatend levert bestanden op die klein en inaccuraat zijn. + + + Enkelvoudig levert een groot audiobestand op. Meervoudig levert een bestand per nummer op. + + + Albumhoes wordt gedownload... even geduld. + + + (NoEncodingFound) Geen codering gevonden. + + + (MultiEncodingNotLossless) Eerste coderingsmethode moet verliesloos zijn bij het gebruik van meerdere coderingen. + + \ No newline at end of file diff --git a/CUERipper.Avalonia/Resources/Language.resx b/CUERipper.Avalonia/Resources/Language.resx new file mode 100644 index 00000000..790021a2 --- /dev/null +++ b/CUERipper.Avalonia/Resources/Language.resx @@ -0,0 +1,267 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Tracks + + + Metadata + + + Title + + + Length + + + Progress + + + Artist + + + Disc + + + Cancel + + + Image + + + Lossless + + + Lossy + + + Yes + + + No + + + Tracks + + + Ready + + + Retry + + + Finalizing... please wait. + + + Disc has unexpectedly been removed, please wait. + + + Disc has been removed. + + + Ripping is being stopped, please wait. + + + Ripping has been stopped + + + / + + + Artist + + + Title + + + Genre + + + Year + + + Current Disc + + + Total Discs + + + Disc Name + + + Label + + + Label No. + + + Release Date + + + Barcode + + + Country + + + Comment + + + Ok + + + (RipFailedNoAccessDrive) Couldn't access the disc drive. + + + Ripping has failed! + + + (RipFailedMetadata) Couldn't process the album metadata. + + + (RipFailedOutputPath) Invalid output path. + + + Ripping has finished. + + + Ripping has been stopped by user. + + + (Unexpected) Unexpected error occurred. + + + Disc contains error(s). + + + Lossless will result in accurate but bigger files, while Lossy will result in smaller but less accurate (compressed) files. + + + Image will result in a single audio file. Tracks will result in one file per track. + + + Downloading album cover... please wait. + + + (NoEncodingFound) No encoding configuration found. + + + (MultiEncodingNotLossless) First encoder must be lossless when using multiple encoders. + + \ No newline at end of file diff --git a/CUERipper.Avalonia/Services/Abstractions/ICUEMetaService.cs b/CUERipper.Avalonia/Services/Abstractions/ICUEMetaService.cs new file mode 100644 index 00000000..3ff2d864 --- /dev/null +++ b/CUERipper.Avalonia/Services/Abstractions/ICUEMetaService.cs @@ -0,0 +1,41 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Media.Imaging; +using CUERipper.Avalonia.Models; +using CUETools.CDImage; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; + +namespace CUERipper.Avalonia.Services.Abstractions +{ + public interface ICUEMetaService + { + void SetContentInfo(CDImageLayout TOC, string ARName); + + IImmutableList GetAlbumMetaInformation(bool advancedSearch); + void ResetAlbumMetaInformation(); + + IEnumerable GetTracksLength(); + + Task FetchImageAsync(string uri, CancellationToken ct); + void FinalizeMetadata(AlbumMetadata metadata); + } +} diff --git a/CUERipper.Avalonia/Services/Abstractions/ICUERipperService.cs b/CUERipper.Avalonia/Services/Abstractions/ICUERipperService.cs new file mode 100644 index 00000000..fd9d0966 --- /dev/null +++ b/CUERipper.Avalonia/Services/Abstractions/ICUERipperService.cs @@ -0,0 +1,72 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CUERipper.Avalonia.Events; +using CUERipper.Avalonia.Models; +using CUETools.CDImage; +using CUETools.Processor; +using CUETools.Ripper; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace CUERipper.Avalonia.Services.Abstractions +{ + public interface ICUERipperService + { + char SelectedDrive { get; set; } + + /// + /// Fired from UI thread + /// + public event EventHandler? OnSelectedDriveChanged; + /// + /// Fired from non UI thread + /// + public event EventHandler? OnSecondaryProgress; + /// + /// Fired from non UI thread + /// + public event EventHandler? OnRepairSelection; + /// + /// Fired from non UI thread + /// + public event EventHandler? OnRippingProgress; + /// + /// Fired from non UI thread + /// + public event EventHandler? OnFinish; + /// + /// Fired from non UI thread + /// + public event EventHandler? OnDirectoryConflict; + + IDictionary QueryDrivesAvailable(); + string GetDriveName(); + string GetDriveARName(); + + CDImageLayout? GetDiscTOC(); + + void EjectTray(); + int GetDriveOffset(); + + Task RipAudioTracks(RipSettings settings + , CancellationToken token); + } +} diff --git a/CUERipper.Avalonia/Services/Abstractions/IDriveNotificationService.cs b/CUERipper.Avalonia/Services/Abstractions/IDriveNotificationService.cs new file mode 100644 index 00000000..0a90737f --- /dev/null +++ b/CUERipper.Avalonia/Services/Abstractions/IDriveNotificationService.cs @@ -0,0 +1,29 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using System; + +namespace CUERipper.Avalonia.Services +{ + public interface IDriveNotificationService + { + void SetCallbacks(Action onDriveRefresh + , Action onDriveUnmounted + , Action onDriveMounted); + } +} \ No newline at end of file diff --git a/CUERipper.Avalonia/Services/Abstractions/IIconService.cs b/CUERipper.Avalonia/Services/Abstractions/IIconService.cs new file mode 100644 index 00000000..c174e4d9 --- /dev/null +++ b/CUERipper.Avalonia/Services/Abstractions/IIconService.cs @@ -0,0 +1,48 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Media.Imaging; +using CUERipper.Avalonia.Models; + +namespace CUERipper.Avalonia.Services.Abstractions +{ + public enum AppIcon + { + Local + , MusicBrainz + , Freedb + , Discogs + , Disc + , Search + , Eject + , Cross + , File + , Cog + , Bolt + , Add + , Subtract + , Multiply + , New + } + + public interface IIconService + { + Bitmap? GetIcon(AppIcon appIcon); + Bitmap? GetIcon(MetaSource metaSource); + } +} diff --git a/CUERipper.Avalonia/Services/Abstractions/IUpdateService.cs b/CUERipper.Avalonia/Services/Abstractions/IUpdateService.cs new file mode 100644 index 00000000..dfaf42d8 --- /dev/null +++ b/CUERipper.Avalonia/Services/Abstractions/IUpdateService.cs @@ -0,0 +1,15 @@ +using CUERipper.Avalonia.Events; +using CUERipper.Avalonia.Models; +using System; +using System.Threading.Tasks; + +namespace CUERipper.Avalonia.Services.Abstractions +{ + public interface IUpdateService + { + public UpdateMetadata? UpdateMetadata { get; } + + public Task FetchAsync(); + public Task DownloadAsync(EventHandler progressEvent); + } +} diff --git a/CUERipper.Avalonia/Services/CUEMetaService.cs b/CUERipper.Avalonia/Services/CUEMetaService.cs new file mode 100644 index 00000000..9044f6da --- /dev/null +++ b/CUERipper.Avalonia/Services/CUEMetaService.cs @@ -0,0 +1,238 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Media.Imaging; +using CUERipper.Avalonia.Events; +using CUERipper.Avalonia.Extensions; +using CUERipper.Avalonia.Models; +using CUERipper.Avalonia.Services.Abstractions; +using CUETools.CDImage; +using CUETools.CTDB; +using CUETools.Processor; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; + +namespace CUERipper.Avalonia.Services +{ + public class CUEMetaService : ICUEMetaService + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + private readonly Dictionary> _cache = []; + + private CDImageLayout _toc = new(); + private string _arName = string.Empty; + + public CUEMetaService(ICUERipperService ripperService + , HttpClient httpClient + , ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + + ripperService.OnSelectedDriveChanged += (object? _, DriveChangedEventArgs e) => + { + SetContentInfo(ripperService.GetDiscTOC(), ripperService.GetDriveARName()); + }; + } + + public void SetContentInfo(CDImageLayout? TOC, string ARName) + { + _toc = TOC ?? new(); + _arName = ARName; + } + + private static CTDBResponseMeta CreateDummy(uint audioTracks) + { + var dummy = new CTDBResponseMeta + { + artist = Constants.UnknownArtist + , album = Constants.UnknownTitle + , track = new CTDBResponseMetaTrack[audioTracks] + , year = string.Empty + , disccount = "1" + , discnumber = "1" + }; + + for (int i = 0; i < dummy.track.Length; ++i) + { + dummy.track[i] = new CTDBResponseMetaTrack + { + name = $"{Constants.UnknownTrack} {i + 1}", + artist = dummy.album + }; + } + + return dummy; + } + + public IImmutableList GetAlbumMetaInformation(bool advancedSearch) + { + _logger.LogInformation("Retrieving album information {TOC}", _toc); + + if (_toc.AudioTracks == 0) return []; + + if (!advancedSearch && _cache.TryGetValue(_toc.TOCID, out var cached)) return cached; + + CUEMetadata? userCache = null; + try + { + userCache = CUEMetadata.Load(_toc.TOCID); + } + catch (FileNotFoundException) + { + _logger.LogInformation("Album not found in user cache."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Non fatal error parsing CUE Metadata cache."); + } + + var ctdb = new CUEToolsDB(_toc, null); + ctdb.ContactDB(null, Constants.ApplicationName, _arName, true, false, advancedSearch ? CTDBMetadataSearch.Extensive : CTDBMetadataSearch.Fast); + + switch (ctdb.QueryResponseStatus) + { + case HttpStatusCode.OK: + { + _logger.LogInformation("Retrieved {MetadataCount} releases from the database", ctdb.Metadata.Count()); + + var result = ctdb.Metadata + .Concat([CreateDummy(_toc.AudioTracks)]) + .Select(m => + { + var meta = new CUEMetadata(_toc.TOCID, (int)_toc.AudioTracks); + meta.FillFromCtdb(m, _toc.FirstAudio - 1); + return new AlbumMetadata(MetaSourceHelper.FromString(m.source), meta); + }).PrependIf(userCache != null, new AlbumMetadata(MetaSource.Local, userCache!)) + .ToImmutableList(); + + _cache.Remove(_toc.TOCID); + _cache.Add(_toc.TOCID, result); + + return result; + } + case HttpStatusCode.NotFound: + { + _logger.LogWarning("Album not found in the database"); + + IEnumerable result = [CreateDummy(_toc.AudioTracks)]; + return result.Select(m => + { + var meta = new CUEMetadata(_toc.TOCID, (int)_toc.AudioTracks); + meta.FillFromCtdb(m, _toc.FirstAudio - 1); + return new AlbumMetadata(MetaSourceHelper.FromString(m.source), meta); + }).PrependIf(userCache != null, new AlbumMetadata(MetaSource.Local, userCache!)) + .ToImmutableList(); + } + default: + { + _logger.LogError("Unexpected problem contacting the database {StatusCode}", ctdb.QueryResponseStatus); + + IEnumerable result = [CreateDummy(_toc.AudioTracks)]; + return result.Select(m => + { + var meta = new CUEMetadata(_toc.TOCID, (int)_toc.AudioTracks); + meta.FillFromCtdb(m, _toc.FirstAudio - 1); + return new AlbumMetadata(MetaSourceHelper.FromString(m.source), meta); + }).PrependIf(userCache != null, new AlbumMetadata(MetaSource.Local, userCache!)) + .ToImmutableList(); + } + } + } + + public void ResetAlbumMetaInformation() + => _cache.Remove(_toc.TOCID); + + public IEnumerable GetTracksLength() + { + _logger.LogInformation("Retrieving album information {TOC}", _toc); + if (_toc.AudioTracks == 0) return []; + + var result = new List(); + for (int i = 1; i <= _toc.TrackCount; ++i) + { + var trackLength = _toc[i].LengthMSF; + var timeParts = trackLength.Split(':').Select(int.Parse).ToArray(); + if (timeParts.Length != 3) + { + _logger.LogWarning("{TrackLength} does not match expected format.", trackLength); + return []; + } + + if (timeParts[2] >= 50) timeParts[1] += 1; + + result.Add($"{timeParts[0]}:{timeParts[1]:00}"); + } + + return result; + } + + public async Task FetchImageAsync(string uri, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(uri)) return null; + + if (!Directory.Exists(Constants.PathImageCache)) + { + Directory.CreateDirectory(Constants.PathImageCache); + } + + using var md5 = MD5.Create(); + var fileIdentifier = md5.ComputeHashAsString(uri); + var filePath = Path.Combine(Constants.PathImageCache, $"{fileIdentifier}{Constants.JpgExtension}"); + if (File.Exists(filePath)) return new Bitmap(filePath); + + try + { + using var response = await _httpClient.GetAsync(uri, ct); + response.EnsureSuccessStatusCode(); + +#if NET47 + using var stream = await response.Content.ReadAsStreamAsync(); +#else + using var stream = await response.Content.ReadAsStreamAsync(ct); +#endif + + var bitmapFromStream = new Bitmap(stream); + var bitmap = bitmapFromStream.ContainedResize(Constants.HiResImageMaxDimension); + bitmapFromStream.Dispose(); + + bitmap.Save(filePath); + return bitmap; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve album cover from {uri}", uri); + return null; + } + } + + public void FinalizeMetadata(AlbumMetadata metadata) + => metadata.Data.Save(); + } +} \ No newline at end of file diff --git a/CUERipper.Avalonia/Services/CUERipperService.cs b/CUERipper.Avalonia/Services/CUERipperService.cs new file mode 100644 index 00000000..bf7d570c --- /dev/null +++ b/CUERipper.Avalonia/Services/CUERipperService.cs @@ -0,0 +1,663 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Media.Imaging; +using CUERipper.Avalonia.Configuration.Abstractions; +using CUERipper.Avalonia.Events; +using CUERipper.Avalonia.Exceptions; +using CUERipper.Avalonia.Extensions; +using CUERipper.Avalonia.Models; +using CUERipper.Avalonia.Services.Abstractions; +using CUETools.AccurateRip; +using CUETools.CDImage; +using CUETools.CTDB; +using CUETools.Processor; +using CUETools.Ripper; +using CUETools.Ripper.Exceptions; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; + +namespace CUERipper.Avalonia.Services +{ + public class CUERipperService : ICUERipperService + { + private Dictionary _driveList = []; + + private char _selectedDrive; + public char SelectedDrive + { + get => _selectedDrive; + set + { + var eventArgs = new DriveChangedEventArgs(_selectedDrive, value); + _selectedDrive = value; + OnSelectedDriveChanged?.Invoke(this, eventArgs); + } + } + + public event EventHandler? OnSelectedDriveChanged; + public event EventHandler? OnSecondaryProgress; + public event EventHandler? OnRepairSelection; + public event EventHandler? OnRippingProgress; + public event EventHandler? OnFinish; + public event EventHandler? OnDirectoryConflict; + + private readonly ICUEConfigFacade _config; + private readonly IStringLocalizer _localizer; + private readonly ILogger _logger; + public CUERipperService(ICUEConfigFacade config + , IStringLocalizer stringLocalizer + , ILogger logger) + { + _config = config; + _localizer = stringLocalizer; + _logger = logger; + } + + private static ICDRipper CreateCDRipperInstance() + => Activator.CreateInstance(CUEProcessorPlugins.ripper) as ICDRipper + ?? throw new NullReferenceException("Failed to create instance of CD ripper."); + + private (string, string) QueryDriveName(char drive) + { + using var audioSource = CreateCDRipperInstance(); + + var nullResult = (string.Empty, string.Empty); + try + { + return audioSource.Open(drive) ? (audioSource.Path, audioSource.ARName) : nullResult; + } + catch (TOCException) + { + // Not clean but it's safe at this point + return (audioSource.Path, audioSource.ARName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to open drive '{DriveLetter}'.", drive); + return nullResult; + } + } + + public IDictionary QueryDrivesAvailable() + { + var result = new Dictionary(); + + var drives = CDDrivesList.DrivesAvailable(); + foreach(var drive in drives) + { + (string name, string arName) = QueryDriveName(drive); + if(!string.IsNullOrEmpty(name) && !string.IsNullOrWhiteSpace(arName)) + { + result.Add(drive, (name, arName)); + } + } + + _driveList = result; + return result.ToDictionary(x => x.Key, x => x.Value.Name); + } + + public string GetDriveName() + => _driveList.TryGetValue(SelectedDrive, out var result) + ? result.Name + : throw new KeyNotFoundException($"Couldn't find drive key '{SelectedDrive}'."); + + public string GetDriveARName() + => _driveList.TryGetValue(SelectedDrive, out var result) + ? result.ARName + : throw new KeyNotFoundException($"Couldn't find drive key '{SelectedDrive}'."); + + public CDImageLayout? GetDiscTOC() + { + using var audioSource = CreateCDRipperInstance(); + try + { + if (!audioSource.Open(SelectedDrive)) return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to open drive while trying retrieve TOC."); + return null; + } + + return audioSource.TOC; + } + + public void EjectTray() + { + using var audioSource = CreateCDRipperInstance(); + try + { + audioSource.Open(SelectedDrive); + } + catch (TOCException) + { + // Ignore... We don't care about the TOC here + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to open drive while trying to eject tray."); + return; + } + + try + { + audioSource.EjectDisk(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to eject tray."); + return; + } + } + + public int GetDriveOffset() + => AccurateRipVerify.FindDriveReadOffset(GetDriveARName(), out var driveOffset) + ? driveOffset + : 0; + + public Task RipAudioTracks(RipSettings ripSettings, CancellationToken ct) + { + var selectedDrive = SelectedDrive; + + return Task.Factory.StartNew(() => + { + if (ripSettings.EncodingConfiguration.None()) + { + _logger.LogError("Ripping has failed! No encoding configuration found"); + + OnFinish?.Invoke(this, new(false, _localizer["Status:RipFail"], _localizer["Error:NoEncodingFound"])); + return; + } + + if (ripSettings.EncodingConfiguration.Length > 1 + && !ripSettings.EncodingConfiguration[0].IsLossless) + { + _logger.LogError("Ripping has failed! First encoding must be lossless"); + + OnFinish?.Invoke(this, new(false, _localizer["Status:RipFail"], _localizer["Error:MultiEncodingNotLossless"])); + return; + } + + SetEncodingVariables(ripSettings.EncodingConfiguration[0]); + + using var audioSource = CreateCDRipper(selectedDrive, ripSettings, ct); + if (audioSource == null) + { + _logger.LogError("Ripping has failed! Couldn't open audio source on selected drive {selectedDrive}:\\.", selectedDrive); + + OnFinish?.Invoke(this, new(false, _localizer["Status:RipFail"], _localizer["Error:RipFailedNoAccessDrive"])); + return; + } + + var cueSheet = new CUESheet(_config.ToCUEConfig()); + ct.Register(() => cueSheet.Stop()); + + cueSheet.OpenCD(audioSource); + cueSheet.Action = CUEAction.Encode; + cueSheet.UseCUEToolsDB(Constants.ApplicationName, audioSource.ARName, false, _config.MetadataSearch); + cueSheet.UseAccurateRip(); + + General.SetCUELine(cueSheet.Attributes, "REM", "DISCID", AccurateRipVerify.CalculateCDDBId(audioSource.TOC), false); + + CUEMetadataEntry? metadataEntry = GetMetadataEntry(cueSheet, audioSource.TOC, ripSettings.AlbumCoverUri); + if (metadataEntry == null) + { + OnFinish?.Invoke(this, new(false, _localizer["Status:RipFail"], _localizer["Error:RipFailedMetadata"])); + + cueSheet.Close(); + return; + } + + cueSheet.CopyMetadata(metadataEntry.metadata); + + var encodingFormat = ripSettings.EncodingConfiguration[0].Encoding; + var encoderType = ripSettings.EncodingConfiguration[0].IsLossless + ? AudioEncoderType.Lossless + : AudioEncoderType.Lossy; + + cueSheet.OutputStyle = ripSettings.EncodingConfiguration[0].CUEStyleIndex == 0 + ? CUEStyle.SingleFileWithCUE + : CUEStyle.GapsAppended; + + string pathOut = cueSheet.GenerateUniqueOutputPath(_config.PathFormat, + cueSheet.OutputStyle == CUEStyle.SingleFileWithCUE ? "." + encodingFormat : Constants.CueExtension, + CUEAction.Encode, null); + + if (string.IsNullOrWhiteSpace(pathOut)) + { + _logger.LogError("Ripping has failed! Couldn't generate the output path."); + + OnFinish?.Invoke(this, new(false, _localizer["Status:RipFail"], _localizer["Error:RipFailedOutputPath"])); + + cueSheet.Close(); + return; + } + + if (Directory.Exists(Path.GetDirectoryName(pathOut) + ?? throw new DirectoryNotFoundException(pathOut))) + { + var eventArgs = new DirectoryConflictEventArgs(pathOut, false); + OnDirectoryConflict?.Invoke(this, eventArgs); + + if (!eventArgs.CanModifyContent) + { + _logger.LogError("Ripping has failed! Couldn't generate the output path. Directory already exists."); + + OnFinish?.Invoke(this, new(false, _localizer["Status:RipFail"], _localizer["Error:RipFailedOutputPath"])); + + cueSheet.Close(); + return; + + } + } + + if (string.IsNullOrWhiteSpace(cueSheet.Metadata.Comment)) + { + cueSheet.Metadata.Comment = audioSource.RipperVersion; + } + + cueSheet.GenerateFilenames(encoderType, encodingFormat, pathOut); + + CopyRawAlbumCoverFromCache(ripSettings.AlbumCoverUri, pathOut); + + try + { + if (_config.DisableEjectDisc) + audioSource.DisableEjectDisc(true); + + if (ripSettings.TestAndCopy) cueSheet.TestBeforeCopy(); + else cueSheet.ArTestVerify = null; + + cueSheet.Go(); + + _logger.LogInformation("Ripping has finished."); + + #if !DEBUG + cueSheet.CTDB.Submit( + (int)cueSheet.ArVerify.WorstConfidence() + 1, + audioSource.CorrectionQuality == 0 ? 0 : + (int)(100 * (1.0 - Math.Log(audioSource.FailedSectors.PopulationCount() + 1) / Math.Log(audioSource.TOC.AudioLength + 1))), + cueSheet.Metadata.Artist, + cueSheet.Metadata.Title, + cueSheet.TOC.Barcode); + #endif + + bool recoveryPossible = false; + if (cueSheet.CTDB.QueryExceptionStatus == WebExceptionStatus.Success && audioSource.FailedSectors.PopulationCount() != 0) + { + foreach (DBEntry entry in cueSheet.CTDB.Entries) + { + recoveryPossible = entry.hasErrors && entry.canRecover; + break; + } + } + + if (audioSource.FailedSectors.PopulationCount() != 0) + { + if (recoveryPossible && !_config.SkipRepair) + { + var repairCue = RepairTracks(encoderType, encodingFormat, cueSheet.OutputStyle, metadataEntry, pathOut, ct); + if (repairCue != null) + { + cueSheet.Close(); + cueSheet = repairCue; + recoveryPossible = false; + } + } + + EncodeTracksPerConfig(pathOut, ripSettings.EncodingConfiguration, metadataEntry, ct); + + OnFinish?.Invoke(this, new(true, _localizer["Warning:RipTroubledDisc"], cueSheet.GenerateVerifyStatus() + ".")); + } + else + { + EncodeTracksPerConfig(pathOut, ripSettings.EncodingConfiguration, metadataEntry, ct); + + if (_config.AutomaticRip) + OnFinish?.Invoke(this, new(true, _localizer["Status:RipFinished"], string.Empty)); + else + OnFinish?.Invoke(this, new(true, _localizer["Status:RipFinished"], cueSheet.GenerateVerifyStatus() + ".")); + } + } + catch (StopException) + { + _logger.LogInformation("Ripping has been stopped by user."); + + OnFinish?.Invoke(this, new(false, _localizer["Status:RipFailUser"], string.Empty)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Ripping has failed! Unexpected error occurred."); + + OnFinish?.Invoke(this, new(false, _localizer["Status:RipFail"], $"{_localizer["Error:Unexpected"]} {ex.Message}")); + } + finally + { + cueSheet.Close(); + + if (_config.DisableEjectDisc) + audioSource.DisableEjectDisc(false); + + if (_config.EjectAfterRip) + EjectTray(); + } + }, ct, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + private ICDRipper? CreateCDRipper(char selectedDrive, RipSettings ripSettings, CancellationToken ct) + { + var audioSource = CreateCDRipperInstance(); + if (!audioSource.Open(selectedDrive)) + { + audioSource.Dispose(); + return null; + } + + audioSource.DriveOffset = ripSettings.DriveOffset; + audioSource.DriveC2ErrorMode = (int)ripSettings.C2ErrorModeSetting; + audioSource.CorrectionQuality = ripSettings.CorrectionQuality; + + audioSource.ReadProgress += (object? sender, ReadProgressArgs args) => + { + // Without throwing the StopException, the application will crash because it'll try + // to continue reading while the audioSource has been disposed. + if (ct.IsCancellationRequested) throw new StopException(); + OnRippingProgress?.Invoke(sender, args); + }; + + return audioSource; + } + + private CUEMetadataEntry? GetMetadataEntry(CUESheet cueSheet + , CDImageLayout TOC + , string albumCoverUri) + { + try + { + CUEMetadata cache = CUEMetadata.Load(TOC.TOCID); + if (cache == null) return null; + + var metadataEntry = new CUEMetadataEntry(cache, TOC, "local"); + + using var albumCover = GetAlbumCoverFromCache(albumCoverUri); + if (albumCover != null) + { + Bitmap? embeddedArtwork = null; + if (albumCover.PixelSize.Width > Constants.EmbeddedImageMaxDimension + || albumCover.PixelSize.Height > Constants.EmbeddedImageMaxDimension) + { + embeddedArtwork = albumCover.ContainedResize(Constants.EmbeddedImageMaxDimension); + } + + byte[] byteArray = []; + using (var stream = new MemoryStream()) + { + (embeddedArtwork ?? albumCover).Save(stream, quality: 95); + byteArray = stream.ToArray(); + } + + embeddedArtwork?.Dispose(); + + metadataEntry.cover = byteArray; + + if (_config.EmbedAlbumArt) + { + var blob = new TagLib.ByteVector(metadataEntry.cover); + cueSheet.AlbumArt.Add(new TagLib.Picture(blob) { Type = TagLib.PictureType.FrontCover }); + } + } + + return metadataEntry; + } + catch (Exception ex) + { + _logger.LogError(ex, "Ripping has failed! Couldn't load album metadata."); + return null; + } + } + + private static Bitmap? GetAlbumCoverFromCache(string coverUri) + { + if (string.IsNullOrWhiteSpace(coverUri)) return null; + + using var md5 = MD5.Create(); + var fileIdentifier = md5.ComputeHashAsString(coverUri); + var filePath = Path.Combine(Constants.PathImageCache, $"{fileIdentifier}{Constants.JpgExtension}"); + return File.Exists(filePath) ? new Bitmap(filePath) : null; + } + + private static void CopyRawAlbumCoverFromCache(string coverUri, string destination) + { + if (string.IsNullOrWhiteSpace(coverUri) + || string.IsNullOrWhiteSpace(destination)) return; + + var outputFolder = Path.GetDirectoryName(destination) ?? throw new DirectoryNotFoundException(destination); + + using var md5 = MD5.Create(); + var fileIdentifier = md5.ComputeHashAsString(coverUri); + var filePath = Path.Combine(Constants.PathImageCache, $"{fileIdentifier}{Constants.JpgExtension}"); + + if(File.Exists(filePath)) + { + Directory.CreateDirectory(outputFolder); + File.Copy(filePath, Path.Combine(outputFolder, $"{Constants.HiResCoverName}{Constants.JpgExtension}"), true); + } + } + + private CUESheet? RepairTracks(AudioEncoderType encoderType, string encodingFormat, CUEStyle cueStyle, CUEMetadataEntry metaEntry, string cuePath, CancellationToken ctx) + { + var cueSheet = new CUESheet(_config.ToCUEConfig()) + { + Action = CUEAction.Encode, + OutputStyle = cueStyle, + }; + + cueSheet.CUEToolsProgress += (object? sender, CUEToolsProgressEventArgs args) => + { + if (ctx.IsCancellationRequested) throw new StopException(); + OnSecondaryProgress?.Invoke(sender, args); + }; + + cueSheet.CUEToolsSelection += (object? sender, CUEToolsSelectionEventArgs args) => + { + OnRepairSelection?.Invoke(sender, args); + }; + + cueSheet.Open(cuePath); + cueSheet.CopyMetadata(metaEntry.metadata); + + cueSheet.UseAccurateRip(); + + string cueDirectory = Path.GetDirectoryName(cuePath) ?? throw new DirectoryNotFoundException(cuePath); + string cueFileName = Path.GetFileName(cuePath); + string repairPath = $"{cueDirectory}/{Constants.TempFolderCUERipper}"; + string repairCuePath = $"{repairPath}/{cueFileName}"; + + if (Directory.Exists(repairPath)) + { + Directory.Delete(repairPath, true); + } + + cueSheet.GenerateFilenames(encoderType, encodingFormat, repairCuePath); + + const string REPAIR_SCRIPT = "repair"; + if (!_config.Scripts.TryGetValue(REPAIR_SCRIPT, out CUEToolsScript? value)) + { + _logger.LogError("Where did the repair script go?"); + throw new CUEToolsCoreException("For some reason the repair script seems to be missing?"); + } + + try + { + cueSheet.ExecuteScript(value); + + if (!Directory.Exists(repairPath)) + { + // Repair cancelled + return null; + } + + foreach (string source in Directory.GetFiles(repairPath)) + { + string fileName = Path.GetFileName(source); + string destination = Path.Combine(cueDirectory, fileName); + + if (File.Exists(destination)) + { + File.Delete(destination); + } + + File.Move(source, destination); + } + } + finally + { + if (Directory.Exists(repairPath)) + { + Directory.Delete(repairPath, true); + } + } + + return cueSheet; + } + + private void EncodeTracksPerConfig(string cuePath + , EncodingConfiguration[] encodingConfiguration + , CUEMetadataEntry metadataEntry + , CancellationToken ct) + { + string cueDirectory = Path.GetDirectoryName(cuePath) ?? throw new DirectoryNotFoundException(cuePath); + string cueFileName = Path.GetFileName(cuePath); + + // Skip the first, because it's already encoded :) + for (int i = 1; i < encodingConfiguration.Length; ++i) + { + if (ct.IsCancellationRequested) break; + + var encodingConfig = encodingConfiguration[i]; + SetEncodingVariables(encodingConfig); + + string destination = $"{cueDirectory}/{i}-{encodingConfig.Encoding}"; + string destinationCuePath = $"{destination}/{cueFileName}"; + + EncodeTracks(cuePath, destination, destinationCuePath, encodingConfig, metadataEntry, i, encodingConfiguration.Length - 1, ct); + } + + SetEncodingVariables(encodingConfiguration[0]); + } + + private void EncodeTracks(string source + , string destination + , string destinationCue + , EncodingConfiguration encodingConfig + , CUEMetadataEntry metadataEntry + , int current + , int total + , CancellationToken ct) + { + var cueSheet = new CUESheet(_config.ToCUEConfig()) + { + Action = CUEAction.Encode, + OutputStyle = encodingConfig.CUEStyleIndex == 0 + ? CUEStyle.SingleFileWithCUE + : CUEStyle.GapsAppended + }; + + cueSheet.CUEToolsProgress += (object? sender, CUEToolsProgressEventArgs args) => + { + if (ct.IsCancellationRequested) throw new StopException(); + + args.status = $"({current}/{total}) {args.status}"; + + OnSecondaryProgress?.Invoke(sender, args); + }; + + cueSheet.Open(source); + cueSheet.CopyMetadata(metadataEntry.metadata); + + var encoderType = encodingConfig.IsLossless + ? AudioEncoderType.Lossless + : AudioEncoderType.Lossy; + + if (encoderType == AudioEncoderType.Lossless) cueSheet.UseAccurateRip(); + + if (Directory.Exists(destination)) + { + Directory.Delete(destination, true); + } + + cueSheet.GenerateFilenames(encoderType, encodingConfig.Encoding, destinationCue); + + bool isSuccess = false; + + try + { + cueSheet.Go(); + + isSuccess = true; + } + finally + { + if (!isSuccess && Directory.Exists(destination)) + { + Directory.Delete(destination, true); + } + + cueSheet.Close(); + } + } + + private void SetEncodingVariables(EncodingConfiguration encodingConfig) + { + var currentEncoding = _config.Formats + .Where(f => f.Key == encodingConfig.Encoding) + .Select(e => e.Value) + .Single(); + + var requestedEncoder = _config.Encoders + .Where(e => string.Compare(e.Extension, encodingConfig.Encoding, true) == 0) + .Where(e => string.Compare(e.Name, encodingConfig.Encoder, true) == 0) + .Single(); + + requestedEncoder.Settings.EncoderMode = encodingConfig.EncoderMode; + _config.CUEStyleIndex = encodingConfig.CUEStyleIndex; + + if (encodingConfig.IsLossless) + { + currentEncoding.encoderLossless = requestedEncoder; + _config.DefaultLosslessFormat = encodingConfig.Encoding; + _config.OutputCompression = AudioEncoderType.Lossless; + } + else + { + currentEncoding.encoderLossy = requestedEncoder; + _config.DefaultLossyFormat = encodingConfig.Encoding; + _config.OutputCompression = AudioEncoderType.Lossy; + } + } + } +} diff --git a/CUERipper.Avalonia/Services/IconService.cs b/CUERipper.Avalonia/Services/IconService.cs new file mode 100644 index 00000000..4c06cd0b --- /dev/null +++ b/CUERipper.Avalonia/Services/IconService.cs @@ -0,0 +1,103 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using CUERipper.Avalonia.Models; +using CUERipper.Avalonia.Services.Abstractions; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; + +namespace CUERipper.Avalonia.Services +{ + public sealed class IconService : IIconService, IDisposable + { + private readonly Dictionary _appIconPathMapping = new() { + { AppIcon.Local, $"{Constants.PathNoto}emoji_u1f9e9.png" } + , { AppIcon.MusicBrainz, "avares://CUERipper.Avalonia/Assets/musicbrainz.ico" } + , { AppIcon.Freedb, "avares://CUERipper.Avalonia/Assets/freedb16.png" } + , { AppIcon.Discogs, "avares://CUERipper.Avalonia/Assets/discogs.png" } + , { AppIcon.Disc, $"{Constants.PathNoto}emoji_u1f4bf.png" } + , { AppIcon.Search, $"{Constants.PathNoto}emoji_u1f50d.png" } + , { AppIcon.Eject, $"{Constants.PathNoto}emoji_u23cf.png" } + , { AppIcon.Cross, $"{Constants.PathNoto}emoji_u274c.png" } + , { AppIcon.File, $"{Constants.PathNoto}emoji_u1f4c4.png" } + , { AppIcon.Cog, $"{Constants.PathNoto}emoji_u2699.png"} + , { AppIcon.Bolt, $"{Constants.PathNoto}emoji_u1f529.png" } + , { AppIcon.Add, $"{Constants.PathNoto}emoji_u2795.png" } + , { AppIcon.Subtract, $"{Constants.PathNoto}emoji_u2796.png" } + , { AppIcon.Multiply, $"{Constants.PathNoto}emoji_u2716.png" } + , { AppIcon.New, $"{Constants.PathNoto}emoji_u1f195.png" } + }; + + private readonly Dictionary _appIconBitmap = []; + + private readonly ILogger _logger; + public IconService(ILogger logger) + { + _logger = logger; + + // Read images + foreach (var item in _appIconPathMapping) + { + try + { + using var stream = AssetLoader.Open(new Uri(item.Value)); + _appIconBitmap.Add(item.Key, new Bitmap(stream)); + } + catch(Exception ex) + { + _logger.LogError(ex, "Failed to retrieve icon {path}.", item.Value); + } + } + } + + public Bitmap? GetIcon(AppIcon appIcon) + => _appIconBitmap.TryGetValue(appIcon, out Bitmap? result) ? result : null; + + public Bitmap? GetIcon(MetaSource metaSource) + => metaSource switch { + MetaSource.Local => GetIcon(AppIcon.Local), + MetaSource.MusicBrainz => GetIcon(AppIcon.MusicBrainz), + MetaSource.Freedb => GetIcon(AppIcon.Freedb), + MetaSource.Discogs => GetIcon(AppIcon.Discogs), + _ => GetIcon(AppIcon.File), + }; + + private bool _disposed; + + /// + /// Class is sealed, so no need for inheritance concerns including a complex dispose pattern. + /// + public void Dispose() + { + if (_disposed == true) return; + _disposed = true; + + foreach (var item in _appIconBitmap) + { + item.Value?.Dispose(); + } + + _appIconBitmap.Clear(); + + GC.SuppressFinalize(this); + } + } +} diff --git a/CUERipper.Avalonia/Services/LinuxDriveNotificationService.cs b/CUERipper.Avalonia/Services/LinuxDriveNotificationService.cs new file mode 100644 index 00000000..69aef3cd --- /dev/null +++ b/CUERipper.Avalonia/Services/LinuxDriveNotificationService.cs @@ -0,0 +1,157 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion + +#if false +// The implementation in NullDriveNotificationService doesn't function correctly on .NET 8 under Linux. +// As a temporary solution, I've implemented a quick workaround to ensure the drive notification works. +// Further investigation needed... + +using CUETools.Interop; +using CUETools.Ripper; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace CUERipper.Avalonia.Services +{ + public class LinuxDriveNotificationService : IDriveNotificationService, IDisposable + { + private readonly Thread _thread; + private volatile bool _requestExit; + + private Action? _onDriveRefresh; + private Action? _onDriveUnmounted; + private Action? _onDriveMounted; + + public void SetCallbacks(Action onDriveRefresh + , Action onDriveUnmounted + , Action onDriveMounted) + { + _onDriveRefresh = onDriveRefresh; + _onDriveUnmounted = onDriveUnmounted; + _onDriveMounted = onDriveMounted; + } + + private readonly ILogger _logger; + + public LinuxDriveNotificationService(ILogger logger) + { + _logger = logger; + _thread = new Thread(ScanDrives) + { + IsBackground = true + }; + } + + private void ScanDrives() + { + _logger.LogInformation("Started scanning for drives."); + + Dictionary knownDrives = []; + + while (!_requestExit) + { + Dictionary currentDrives = []; + try + { + currentDrives = CDDrivesList.DrivesAvailable() + .Select(d => new { Drive = d, IsReady = IsDriveReady(d) }) + .ToDictionary(item => item.Drive, item => item.IsReady); + } + catch(Exception ex) + { + _logger.LogError(ex, "Failed to retrieve the available drives."); + } + + var mountedDrives = currentDrives.Where(c => !knownDrives + .Any(k => c.Key == k.Key)); + + var unmountedDrives = knownDrives.Where(k => !currentDrives + .Any(c => c.Key == k.Key)); + + if (mountedDrives.Any() || unmountedDrives.Any()) + { + _onDriveRefresh?.Invoke(); + } + + var driveStateChange = currentDrives.Where( + c => knownDrives.TryGetValue(c.Key, out var knownDrive) && knownDrive != c.Value + ); + + foreach (var drive in driveStateChange) + { + if (drive.Value) _onDriveMounted?.Invoke(drive.Key); + else _onDriveUnmounted?.Invoke(drive.Key); + } + + knownDrives = currentDrives; + + Thread.Sleep(500); + } + + _logger.LogInformation("Drive scanning has been stopped."); + } + + private bool IsDriveReady(char drive) + { + var fullPath = $"{Linux.CDROM_DEVICE_PATH}{drive}"; + var fd = Linux.open(fullPath, Linux.O_RDONLY); + + if (fd == -1) + { + _logger.LogWarning("Drive scanning failed for '{fullPath}' with {errorCode} - {errorMessage}" + , fullPath + , Linux.GetErrorCode() + , Linux.GetErrorString()); + + return false; + } + + var result = Linux.ioctl(fd, Linux.CDROM_DRIVE_STATUS); + if (result < 0) + { + _logger.LogWarning("Drive scanning failed for '{fullPath}' with {errorCode} - {errorMessage}" + , fullPath + , Linux.GetErrorCode() + , Linux.GetErrorString()); + } + + return result == Linux.CDS_DISC_OK; + } + + private bool _disposed; + + /// + /// Class is sealed, so no need for inheritance concerns including a complex dispose pattern. + /// + public void Dispose() + { + if (_disposed == true) return; + _disposed = true; + + _requestExit = true; + _thread.Join(1000); + + GC.SuppressFinalize(this); + } + } +} +#endif \ No newline at end of file diff --git a/CUERipper.Avalonia/Services/NullDriveNotificationService.cs b/CUERipper.Avalonia/Services/NullDriveNotificationService.cs new file mode 100644 index 00000000..faedd1ae --- /dev/null +++ b/CUERipper.Avalonia/Services/NullDriveNotificationService.cs @@ -0,0 +1,113 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; + +namespace CUERipper.Avalonia.Services +{ + public class NullDriveNotificationService : IDriveNotificationService, IDisposable + { + private readonly Thread _thread; + private volatile bool _requestExit; + + private Action? _onDriveRefresh; + private Action? _onDriveUnmounted; + private Action? _onDriveMounted; + + public void SetCallbacks(Action onDriveRefresh + , Action onDriveUnmounted + , Action onDriveMounted) + { + _onDriveRefresh = onDriveRefresh; + _onDriveUnmounted = onDriveUnmounted; + _onDriveMounted = onDriveMounted; + } + + private readonly ILogger _logger; + + public NullDriveNotificationService(ILogger logger) + { + _logger = logger; + _thread = new Thread(ScanDrives) + { + IsBackground = true + }; + } + + private void ScanDrives() + { + _logger.LogInformation("Started scanning for drives."); + + Dictionary knownDrives = []; + + while (!_requestExit) + { + var currentDrives = DriveInfo.GetDrives().Where(d => d.DriveType == DriveType.CDRom) + .Select(drive => (drive.IsReady, drive)) + .ToDictionary(d => d.drive.Name); + + var mountedDrives = currentDrives.Where(c => !knownDrives + .Any(k => c.Key == k.Key)); + + var unmountedDrives = knownDrives.Where(k => !currentDrives + .Any(c => c.Key == k.Key)); + + if (mountedDrives.Any() || unmountedDrives.Any()) + { + _onDriveRefresh?.Invoke(); + } + + var driveStateChange = currentDrives.Where( + c => knownDrives.TryGetValue(c.Key, out var knownDrive) && knownDrive.IsReady != c.Value.IsReady); + + foreach (var drive in driveStateChange) + { + if(drive.Value.IsReady) _onDriveMounted?.Invoke(drive.Key[0]); + else _onDriveUnmounted?.Invoke(drive.Key[0]); + } + + knownDrives = currentDrives; + + Thread.Sleep(500); + } + + _logger.LogInformation("Drive scanning has been stopped."); + } + + private bool _disposed; + + /// + /// Class is sealed, so no need for inheritance concerns including a complex dispose pattern. + /// + public void Dispose() + { + if (_disposed == true) return; + _disposed = true; + + _requestExit = true; + _thread.Join(1000); + + GC.SuppressFinalize(this); + } + } +} diff --git a/CUERipper.Avalonia/Services/UpdateService.cs b/CUERipper.Avalonia/Services/UpdateService.cs new file mode 100644 index 00000000..dc049ef8 --- /dev/null +++ b/CUERipper.Avalonia/Services/UpdateService.cs @@ -0,0 +1,322 @@ +using CUERipper.Avalonia.Events; +using CUERipper.Avalonia.Models; +using CUERipper.Avalonia.Models.Github; +using CUERipper.Avalonia.Services.Abstractions; +using CUETools.Processor; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace CUERipper.Avalonia.Services +{ + public class UpdateService : IUpdateService + { + private const string UpdateCheckFilePath = "CT_LAST_UPDATE_CHECK"; + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public UpdateMetadata? UpdateMetadata { get; private set; } + + public UpdateService(HttpClient httpClient + , ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public async Task FetchAsync() + { +#if !NET47 + if (!OperatingSystem.IsWindows()) + { + _logger.LogWarning("Updater is not implemented for this operating system."); + return false; + } +#endif + + if (UpdateMetadata != null) return true; + + var githubReleases = await GetReleaseAsync(); + var latestRelease = githubReleases + .Where(r => !r.Draft && !r.PreRelease && r.TargetCommitish == "master") + .OrderByDescending(r => r.PublishedAt) + .FirstOrDefault(); + + if (latestRelease == null) + { + _logger.LogWarning("No releases found."); + return false; + } + + string versionPattern = @"^v\d+\.\d+\.\d+$"; + Regex regex = new(versionPattern); + if (!regex.IsMatch(latestRelease.TagName)) + { + _logger.LogError("Release tag '{TAG}' doesn't match expected format.", latestRelease.TagName); + return false; + } + + var zipAsset = GetZipAsset(latestRelease); + var hashAsset = GetHashAsset(latestRelease); + if (zipAsset == null || hashAsset == null) + { + _logger.LogWarning("Github assets are incomplete."); + return false; + } + + UpdateMetadata = new UpdateMetadata( + Version: latestRelease.TagName.Substring(1) + , CurrentVersion: CUESheet.CUEToolsVersion + , Author: await GetAuthorAsync(latestRelease) + , Description: latestRelease.Body + , Uri: zipAsset.BrowserDownloadUrl + , Size: zipAsset.Size + , HashUri: hashAsset.BrowserDownloadUrl + , HashSize: hashAsset.Size + , Date: latestRelease.PublishedAt + ); + + return true; + } + + private async Task> GetReleaseAsync() + { + IEnumerable githubReleases = GetReleasesFromDiskCache(); + if (githubReleases.Any()) return githubReleases; + + try + { + using var result = await _httpClient.GetAsync(Constants.GithubApiUri); + result.EnsureSuccessStatusCode(); + + string response = await result.Content.ReadAsStringAsync(); + WriteReleasesToDiskCache(response); + + githubReleases = JsonConvert.DeserializeObject(response) + ?? throw new NullReferenceException("Failed to deserialize object..."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve latest release."); + } + + return githubReleases; + } + + + /// + /// Mechanism that prevents spamming the GitHub API by limiting automated requests to once every 3 days. + /// + /// + private IEnumerable GetReleasesFromDiskCache() + { + if (!File.Exists(UpdateCheckFilePath)) return []; + + string[] content = File.ReadAllLines(UpdateCheckFilePath); + if (content.Length != 2) + { + _logger.LogError("Content of {File} is incorrect.", UpdateCheckFilePath); + return []; + } + + if (!DateTime.TryParseExact(content[0], "yyyyMMdd", null, System.Globalization.DateTimeStyles.None + , out DateTime lastUpdateCheck)) + { + _logger.LogError("Content of {File} is incorrect, can't parse to datetime.", UpdateCheckFilePath); + return []; + } + + bool shouldUpdate = (DateTime.Now - lastUpdateCheck).Days >= 3; + _logger.LogInformation("{State} check for update.", shouldUpdate ? "Should" : "Should not"); + if (shouldUpdate) return []; + + try + { + var jsonBytes = Convert.FromBase64String(content[1]); + var json = Encoding.UTF8.GetString(jsonBytes); + + var result = JsonConvert.DeserializeObject(json) + ?? throw new NullReferenceException("Failed to deserialize object..."); + + _logger.LogInformation("Found valid update information in disk cache."); + return result; + } + catch(Exception ex) + { + _logger.LogError(ex, "Failed to parse Github JSON from disk."); + return []; + } + } + + private void WriteReleasesToDiskCache(string json) + { + try + { + var jsonBytes = Encoding.UTF8.GetBytes(json); + var base64String = Convert.ToBase64String(jsonBytes); + + var fileContent = new StringBuilder(); + fileContent.Append(DateTime.Now.ToString("yyyyMMdd")); + fileContent.Append(Environment.NewLine); + fileContent.Append(base64String); + + File.WriteAllText(UpdateCheckFilePath, fileContent.ToString()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to write Github JSON to disk."); + } + } + + private static GithubAsset? GetZipAsset(GithubRelease latestRelease) + { + const string ZIP_PATTERN = @"^CUETools_\d+\.\d+\.\d+\.zip$"; + Regex regex = new(ZIP_PATTERN); + + return latestRelease.Assets + .Where(a => regex.IsMatch(a.Name)) + .FirstOrDefault(); + } + + private static GithubAsset? GetHashAsset(GithubRelease latestRelease) + { + const string ZIP_PATTERN = @"^CUETools_\d+\.\d+\.\d+\.zip.sha256$"; + Regex regex = new(ZIP_PATTERN); + + return latestRelease.Assets + .Where(a => regex.IsMatch(a.Name)) + .FirstOrDefault(); + } + + private async Task GetAuthorAsync(GithubRelease release) + { + try + { + using var result = await _httpClient.GetAsync(release.Author.Url); + result.EnsureSuccessStatusCode(); + + string response = await result.Content.ReadAsStringAsync(); + var githubUser = JsonConvert.DeserializeObject(response) + ?? throw new NullReferenceException("Failed to deserialize object..."); + + return githubUser.Name; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve release author."); + return string.Empty; + } + } + + private async Task DownloadFile(string uri + , long contentSize + , string filePath + , EventHandler? progressEvent) + { + using var response = await _httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + long totalBytes = response.Content.Headers.ContentLength ?? contentSize; + using var httpStream = await response.Content.ReadAsStreamAsync(); + using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None); + + byte[] buffer = new byte[8192]; + long totalReadBytes = 0; + int bytesRead; + + while ((bytesRead = await httpStream.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + await fileStream.WriteAsync(buffer, 0, bytesRead); + totalReadBytes += bytesRead; + + if (totalBytes >= 0) + { + var eventArgs = new GenericProgressEventArgs((float)totalReadBytes / totalBytes * 100); + progressEvent?.Invoke(this, eventArgs); + } + } + } + + public async Task DownloadAsync(EventHandler progressEvent) + { + if (!UpdateMetadata.UpdateAvailable()) return false; + + if (!Directory.Exists(Constants.PathUpdate)) + { + Directory.CreateDirectory(Constants.PathUpdate); + } + + try + { + var zipFile = $"{Constants.PathUpdate}Update-{UpdateMetadata!.Version}.zip"; + var hashFile = $"{Constants.PathUpdate}Update-{UpdateMetadata.Version}.sha256"; + + await DownloadFile(UpdateMetadata!.Uri + , contentSize: UpdateMetadata.Size + , filePath: zipFile + , progressEvent); + + await DownloadFile(UpdateMetadata.HashUri + , contentSize: UpdateMetadata.HashSize + , filePath: hashFile + , progressEvent: null); + + return VerifyFile(zipFile, hashFile); + } + catch(Exception ex) + { + _logger.LogError(ex, "Failed to download update."); + return false; + } + } + + public static string GetSHA256Hash(string filePath) + { + using SHA256 sha256 = SHA256.Create(); + using FileStream stream = File.OpenRead(filePath); + + byte[] hashBytes = sha256.ComputeHash(stream); + + var hashBuilder = new StringBuilder(); + for (int i = 0; i < hashBytes.Length; ++i) + { + hashBuilder.Append(hashBytes[i].ToString("x2")); + } + + return hashBuilder.ToString(); + } + + private string ParseSHA256FromHashFile(string hashFile) + { + var fileContent = File.ReadAllLines(hashFile); + if (fileContent.Length == 0) return string.Empty; + + return fileContent[0].Split(' ')[0]; + } + + private bool VerifyFile(string zipFile, string hashFile) + { + try + { + var actualHash = GetSHA256Hash(zipFile); + var validationHash = ParseSHA256FromHashFile(hashFile); + + return string.Compare(actualHash, validationHash, true) == 0; + } + catch(Exception ex) + { + _logger.LogError(ex, "Failed to verify hash."); + return false; + } + } + } +} diff --git a/CUERipper.Avalonia/Services/WindowsDriveNotificationService.cs b/CUERipper.Avalonia/Services/WindowsDriveNotificationService.cs new file mode 100644 index 00000000..1decdea7 --- /dev/null +++ b/CUERipper.Avalonia/Services/WindowsDriveNotificationService.cs @@ -0,0 +1,269 @@ +#region Copyright (C) 2025 Gregory S. Chudov, Max Visser +/* + Copyright (C) 2025 Gregory S. Chudov, Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +// This file contains modified code from frmCUERipper.cs. +#endregion +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; + +namespace CUERipper.Avalonia.Services +{ + public class WindowsDriveNotificationService : IDriveNotificationService, IDisposable + { + private delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + private readonly WndProcDelegate _wndProcDelegate; + + private IntPtr? _hwnd; + + private Action? _onDriveRefresh; + private Action? _onDriveUnmounted; + private Action? _onDriveMounted; + + private List _driveList = []; + + public void SetCallbacks(Action onDriveRefresh + , Action onDriveUnmounted + , Action onDriveMounted) + { + _onDriveRefresh = onDriveRefresh; + _onDriveUnmounted = onDriveUnmounted; + _onDriveMounted = onDriveMounted; + } + + private readonly ILogger _logger; + public WindowsDriveNotificationService(ILogger logger) + { + _logger = logger; + _wndProcDelegate = CustomWndProc; + + Init(); + } + + private static List GetCDDrives() + => DriveInfo.GetDrives() + .Where(d => d.DriveType == DriveType.CDRom) + .Select(d => d.Name[0]) + .ToList(); + + const string SCANNING_WINDOW = "CUERipperDriveScanningWindow"; + const string CLASS_NAME = "CUERipperDriveScanningClass"; + private void Init() + { +#if !NET47 + if (!OperatingSystem.IsWindows()) + { + throw new InvalidOperationException("Windows-specific code was executed on a non-Windows platform."); + } +#endif + + _driveList = GetCDDrives(); + + var wndClass = new WNDCLASS + { + lpszClassName = CLASS_NAME, + lpfnWndProc = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate), + hInstance = GetModuleHandle(null) + }; + RegisterClass(ref wndClass); + + _hwnd = CreateWindowEx(0, CLASS_NAME, SCANNING_WINDOW, 0, 0, 0, 0, 0, IntPtr.Zero, IntPtr.Zero, wndClass.hInstance, IntPtr.Zero); + if (_hwnd == IntPtr.Zero) _logger.LogError("Failed to create drive scanning window."); + } + + #region private constants + /// + /// The window message of interest, device change + /// + const int WM_DEVICECHANGE = 0x0219; + const ushort DBT_DEVICEARRIVAL = 0x8000; // Called when a disc is inserted + const ushort DBT_DEVICEREMOVECOMPLETE = 0x8004; // Called when a disc is removed + const ushort DBT_DEVNODES_CHANGED = 0x0007; + #endregion + + [StructLayout(LayoutKind.Sequential)] + internal class DEV_BROADCAST_HDR + { + internal Int32 dbch_size; + internal Int32 dbch_devicetype; + internal Int32 dbch_reserved; + } + + [StructLayout(LayoutKind.Sequential)] + private struct DEV_BROADCAST_VOLUME + { + public DEV_BROADCAST_HDR Header; + public int UnitMask; + public short Flags; + } + + private static int FirstBitSet(int iIn) + { + for (int i = 0; i < 32; i++) + { + if ((iIn & 1) != 0) + return i; + + iIn >>= 1; + } + + return -1; + } + + private const int DBT_DEVTYPE_VOLUME = 2; + private static char ConvertToDriveLetter(IntPtr lParam) + { + if (Marshal.PtrToStructure(lParam, typeof(DEV_BROADCAST_HDR)) is DEV_BROADCAST_HDR hdr + && hdr.dbch_devicetype == DBT_DEVTYPE_VOLUME) + { + var obj = Marshal.PtrToStructure(lParam, typeof(DEV_BROADCAST_VOLUME)); + if (obj == null) return (char)0; + + var vol = (DEV_BROADCAST_VOLUME)obj; + return (char)(FirstBitSet(vol.UnitMask) + ('A')); + } + + return (char)0; + } + /// + /// This method is called when a window message is processed by the dotnet application + /// framework. We override this method and look for the WM_DEVICECHANGE message. All + /// messages are delivered to the base class for processing, but if the WM_DEVICECHANGE + /// method is seen, we also alert any BWGBURN programs that the media in the drive may + /// have changed. + /// + /// the windows message being processed + private IntPtr CustomWndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) + { + switch (msg) + { + case WM_DEVICECHANGE: + int val = wParam.ToInt32(); + switch (val) + { + case DBT_DEVICEREMOVECOMPLETE: + { + var driveLetter = ConvertToDriveLetter(lParam); + var driveInfo = DriveInfo.GetDrives(); + var filteredDevice = driveInfo + .Where(d => d.Name[0] == driveLetter && d.DriveType == DriveType.CDRom) + .FirstOrDefault(); + + if (filteredDevice != null) _onDriveUnmounted?.Invoke(driveLetter); + } + break; + case DBT_DEVICEARRIVAL: + { + var driveLetter = ConvertToDriveLetter(lParam); + var driveInfo = DriveInfo.GetDrives(); + var filteredDevice = driveInfo + .Where(d => d.Name[0] == driveLetter && d.DriveType == DriveType.CDRom) + .FirstOrDefault(); + + if (filteredDevice != null) _onDriveMounted?.Invoke(driveLetter); + } + break; + case DBT_DEVNODES_CHANGED: + { + var currentDrives = GetCDDrives(); + var difference = currentDrives.Except(_driveList) + .Concat(_driveList.Except(currentDrives)); + + if (difference.Any()) + { + _onDriveRefresh?.Invoke(); + _driveList = currentDrives; + } + } + break; + } + break; + } + + return DefWindowProc(hWnd, msg, wParam, lParam); + } + + const string USER32_DLL = "user32.dll"; + const string KERNEL32_DLL = "kernel32.dll"; + + [DllImport(USER32_DLL, SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr CreateWindowEx( + int dwExStyle, string lpClassName, string lpWindowName, + int dwStyle, int x, int y, int nWidth, int nHeight, + IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam); + + [DllImport(USER32_DLL, SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool DestroyWindow(IntPtr hWnd); + + [DllImport(USER32_DLL, SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr DefWindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + + [DllImport(USER32_DLL, SetLastError = true, CharSet = CharSet.Unicode)] + private static extern ushort RegisterClass([In] ref WNDCLASS lpWndClass); + + [DllImport(KERNEL32_DLL, CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandle(string? lpModuleName); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WNDCLASS + { + public uint style; + public IntPtr lpfnWndProc; + public int cbClsExtra; + public int cbWndExtra; + public IntPtr hInstance; + public IntPtr hIcon; + public IntPtr hCursor; + public IntPtr hbrBackground; + [MarshalAs(UnmanagedType.LPWStr)] public string lpszMenuName; + [MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName; + } + + private bool disposedValue; + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + /* + if (disposing) + { + } + */ + + if (_hwnd != null) + { + DestroyWindow(_hwnd.Value); + _hwnd = null; + } + + disposedValue = true; + } + } + + ~WindowsDriveNotificationService() + => Dispose(disposing: false); + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} diff --git a/CUERipper.Avalonia/Utilities/Accessor.cs b/CUERipper.Avalonia/Utilities/Accessor.cs new file mode 100644 index 00000000..4edaaf7e --- /dev/null +++ b/CUERipper.Avalonia/Utilities/Accessor.cs @@ -0,0 +1,84 @@ +/* + Taken from: https://stackoverflow.com/a/43498938 + License: https://creativecommons.org/licenses/by-sa/3.0/ + Original author: https://stackoverflow.com/users/442204/sven + Modified by: Max Visser + + Modified for CUERipper, under the same license. +*/ + +using System.Linq.Expressions; +using System.Reflection; +using System; + +namespace CUERipper.Avalonia.Utilities +{ + public class Accessor + { + private readonly Func _getter; + private readonly Action? _setter; + + public bool IsReadOnly => _setter == null; + + /// Failed to retrieve getter or not a property or field. + public Accessor(Expression> expr) + { + var memberExpression = (MemberExpression)expr.Body; + var instanceExpression = memberExpression.Expression; + var parameter = Expression.Parameter(typeof(T)); + + if (memberExpression.Member is PropertyInfo propertyInfo) + { + var getMethod = propertyInfo.GetGetMethod() + ?? throw new ArgumentException("No getter found, a getter is required."); + + _getter = Expression.Lambda>(Expression.Call(instanceExpression, getMethod)).Compile(); + + var setMethod = propertyInfo.GetSetMethod(); + if (setMethod != null) + { + _setter = Expression.Lambda>(Expression.Call(instanceExpression, setMethod, parameter), parameter).Compile(); + } + } + else if (memberExpression.Member is FieldInfo fieldInfo) + { + _getter = Expression.Lambda>(Expression.Field(instanceExpression, fieldInfo)).Compile(); + _setter = Expression.Lambda>(Expression.Assign(memberExpression, parameter), parameter).Compile(); + } + else + { + throw new ArgumentException("Not a field or property."); + } + } + + /// Argument not provided. + public Accessor(Expression instanceExpression, MethodInfo? getMethod, MethodInfo? setMethod) + { + if (getMethod == null) throw new ArgumentNullException(nameof(getMethod)); + + var parameter = Expression.Parameter(typeof(T)); + _getter = Expression.Lambda>(Expression.Call(instanceExpression, getMethod)).Compile(); + + if (setMethod != null) + { + _setter = Expression.Lambda>(Expression.Call(instanceExpression, setMethod, parameter), parameter).Compile(); + } + } + + // Warning: Calls the code above! + public static object? CreateAccessor(Type type, Expression instanceExpression, MethodInfo? getMethod, MethodInfo? setMethod) + { + Type accessor = typeof(Accessor<>); + Type accessorWithType = accessor.MakeGenericType(type); + + return Activator.CreateInstance(accessorWithType + , instanceExpression + , getMethod + , setMethod + ); + } + + public T Get() => _getter != null ? _getter() : throw new InvalidOperationException("Getter not found."); + public void Set(T value) => _setter?.Invoke(value); + } +} diff --git a/CUERipper.Avalonia/Utilities/InterruptibleJob.cs b/CUERipper.Avalonia/Utilities/InterruptibleJob.cs new file mode 100644 index 00000000..523ae925 --- /dev/null +++ b/CUERipper.Avalonia/Utilities/InterruptibleJob.cs @@ -0,0 +1,112 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using System; +using System.Threading.Tasks; +using System.Threading; +using System.Linq; +using CUERipper.Avalonia.Compatibility; +using System.Runtime.ExceptionServices; + +namespace CUERipper.Avalonia.Utilities +{ + /// + /// A wrapper around a 'Task' that ensures only one task runs at a time. + /// If a new task is assigned, the current one is canceled before starting the new one. + /// If the current task can't be canceled (likely due to a deadlock), it will be ignored, and the next task will start. + /// + public sealed class InterruptibleJob : IDisposable + { + private Task? _wrappedTask; + private CancellationTokenSource _cts = new(); + + public bool IsCompleted => _wrappedTask?.IsCompleted ?? true; + public bool IsExecuting => !IsCompleted; + + public void Run(Func function) + { + TryFinish(); + + _wrappedTask = Task.Factory.StartNew(async () => + { + await function(_cts.Token); + }, _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default) + .ContinueWith((t) => + { + if (t.IsFaulted) + { + ExceptionDispatchInfo.Capture(t.Exception.InnerException + ?? t.Exception).Throw(); + }; + }); + } + + private void TryFinish() + { + if (_wrappedTask == null || _wrappedTask.IsCompleted) return; + + _cts.Cancel(); + + try + { + _wrappedTask.Wait(1000); + } + catch (AggregateException ex) + { + if (!ex.InnerExceptions.Select(e => e.GetType()).Contains(typeof(TaskCanceledException))) + throw; + } + + if (!_cts.TryReset()) + { + _cts.Dispose(); + _cts = new(); + } + } + + private bool _disposed; + public void Dispose() + { + if (_disposed == true) return; + _disposed = true; + + if (!_cts.IsCancellationRequested) _cts.Cancel(); + + if (_wrappedTask != null) + { + if (!_wrappedTask.IsCompleted) + { + try + { + _wrappedTask.Wait(1000); + } + catch + { + // .. + } + } + + _wrappedTask.Dispose(); + } + + _cts.Dispose(); + + GC.SuppressFinalize(this); + } + } +} diff --git a/CUERipper.Avalonia/ViewLocator.cs b/CUERipper.Avalonia/ViewLocator.cs new file mode 100644 index 00000000..e06721bf --- /dev/null +++ b/CUERipper.Avalonia/ViewLocator.cs @@ -0,0 +1,53 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using CUERipper.Avalonia.ViewModels; +using System; + +namespace CUERipper.Avalonia +{ + /// + /// Avalonia boilerplate code + /// + public class ViewLocator : IDataTemplate + { + + public Control? Build(object? param) + { + if (param is null) + return null; + + var name = param.GetType().FullName!.Replace("ViewModel", "View"); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + + return new TextBlock { Text = "Not Found: " + name }; + } + + public bool Match(object? data) + { + return data is ViewModelBase; + } + } +} diff --git a/CUERipper.Avalonia/ViewModels/Bindings/EditableFieldProxy.cs b/CUERipper.Avalonia/ViewModels/Bindings/EditableFieldProxy.cs new file mode 100644 index 00000000..e95d2231 --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/Bindings/EditableFieldProxy.cs @@ -0,0 +1,46 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using System; +using System.ComponentModel; + +namespace CUERipper.Avalonia.ViewModels.Bindings +{ + public class EditableFieldProxy(string field, Func getExternalValue, Action setExternalValue) + : INotifyPropertyChanged + { + public string Field { get; } = field; + + public string Value + { + get => getExternalValue(); + set + { + if (getExternalValue() != value) + { + setExternalValue(value); + OnPropertyChanged(nameof(Value)); + } + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + protected virtual void OnPropertyChanged(string propertyName) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} \ No newline at end of file diff --git a/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/Abstractions/IOptionProxy.cs b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/Abstractions/IOptionProxy.cs new file mode 100644 index 00000000..e285c046 --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/Abstractions/IOptionProxy.cs @@ -0,0 +1,35 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CommunityToolkit.Mvvm.Input; +using System.Collections.Generic; +using System.ComponentModel; + +namespace CUERipper.Avalonia.ViewModels.Bindings.OptionProxies.Abstractions +{ + public interface IOptionProxy : INotifyPropertyChanged + { + public string Name { get; set; } + public bool IsCombo { get; } + public bool IsReadOnly { get; } + + public string Value { get; set; } + public List Options { get; set; } + public IRelayCommand ResetCommand { get; } + } +} diff --git a/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/Abstractions/OptionProxy.cs b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/Abstractions/OptionProxy.cs new file mode 100644 index 00000000..0a099410 --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/Abstractions/OptionProxy.cs @@ -0,0 +1,95 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CommunityToolkit.Mvvm.Input; +using CUERipper.Avalonia.Utilities; +using System; +using System.Collections.Generic; +using System.ComponentModel; + +namespace CUERipper.Avalonia.ViewModels.Bindings.OptionProxies.Abstractions +{ + public abstract class OptionProxy : IOptionProxy + { + public string Name { get; set; } + public T Default { get; init; } + private Accessor Accessor { get; init; } + + public bool IsCombo => Options.Count != 0; + public bool IsReadOnly => Accessor.IsReadOnly; + + protected abstract string GetStringFromValue(T val); + protected abstract T GetValueFromString(string str); + + private string _viewValue = string.Empty; + public string Value + { + get + { + try + { + _viewValue = GetStringFromValue(Accessor.Get()); + } + catch (TypeInitializationException ex) + when (ex.InnerException != null && ex.InnerException.GetType() == typeof(DllNotFoundException)) + { + // CUETools error + _viewValue = "Dll not found!"; + } + + return _viewValue ?? string.Empty; + } + set + { + if (value == null || IsReadOnly) return; + _viewValue = value; + + try + { + T actualValue = GetValueFromString(_viewValue); + Accessor.Set(actualValue); + } + catch (TypeInitializationException ex) + when (ex.InnerException != null && ex.InnerException.GetType() == typeof(DllNotFoundException)) + { + // CUETools error + // do nothing... + } + } + } + + public List Options { get; set; } = []; + public IRelayCommand ResetCommand { get; } + public OptionProxy(string name, T defaultValue, Accessor accessor) + { + Name = name; + Default = defaultValue; + Accessor = accessor; + + ResetCommand = new RelayCommand(() => + { + accessor.Set(Default); + OnPropertyChanged(nameof(Value)); + }); + } + + public event PropertyChangedEventHandler? PropertyChanged; + protected virtual void OnPropertyChanged(string propertyName) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/BoolOptionProxy.cs b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/BoolOptionProxy.cs new file mode 100644 index 00000000..9392f18a --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/BoolOptionProxy.cs @@ -0,0 +1,40 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CUERipper.Avalonia.Utilities; +using CUERipper.Avalonia.ViewModels.Bindings.OptionProxies.Abstractions; + +namespace CUERipper.Avalonia.ViewModels.Bindings.OptionProxies +{ + public class BoolOptionProxy : OptionProxy + { + private const string True = "True"; + private const string False = "False"; + public BoolOptionProxy(string name, bool defaultValue, Accessor accessor) + : base(name, defaultValue, accessor) + { + Options = [True, False]; + } + + protected override string GetStringFromValue(bool val) + => val ? True : False; + + protected override bool GetValueFromString(string str) + => str == True; + } +} diff --git a/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/EnumOptionProxy.cs b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/EnumOptionProxy.cs new file mode 100644 index 00000000..ae8e754b --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/EnumOptionProxy.cs @@ -0,0 +1,41 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CUERipper.Avalonia.Utilities; +using CUERipper.Avalonia.ViewModels.Bindings.OptionProxies.Abstractions; +using System; +using System.ComponentModel; + +namespace CUERipper.Avalonia.ViewModels.Bindings.OptionProxies +{ + public class EnumOptionProxy : OptionProxy where T : Enum + { + public EnumOptionProxy(string name, T defaultValue, Accessor accessor) + : base(name, defaultValue, accessor) + { + Options = [.. Enum.GetNames(typeof(T))]; + } + + protected override string GetStringFromValue(T val) + => Enum.GetName(typeof(T), val) + ?? throw new InvalidEnumArgumentException("Can't get name of enum value."); + + protected override T GetValueFromString(string str) + => (T)Enum.Parse(typeof(T), str); + } +} \ No newline at end of file diff --git a/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/IntOptionProxy.cs b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/IntOptionProxy.cs new file mode 100644 index 00000000..d6c1042f --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/IntOptionProxy.cs @@ -0,0 +1,37 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CUERipper.Avalonia.Utilities; +using CUERipper.Avalonia.ViewModels.Bindings.OptionProxies.Abstractions; + +namespace CUERipper.Avalonia.ViewModels.Bindings.OptionProxies +{ + public class IntOptionProxy : OptionProxy + { + public IntOptionProxy(string name, int defaultValue, Accessor accessor) + : base(name, defaultValue, accessor) + { + } + + protected override string GetStringFromValue(int val) + => val.ToString(); + + protected override int GetValueFromString(string str) + => int.TryParse(str, out int result) ? result : Default; + } +} diff --git a/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/OptionProxyFactory.cs b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/OptionProxyFactory.cs new file mode 100644 index 00000000..3cce0762 --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/OptionProxyFactory.cs @@ -0,0 +1,73 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CUERipper.Avalonia.Utilities; +using CUERipper.Avalonia.ViewModels.Bindings.OptionProxies.Abstractions; +using System; + +namespace CUERipper.Avalonia.ViewModels.Bindings.OptionProxies +{ + public static class OptionProxyFactory + { + public static IOptionProxy Create(Type type + , string name + , object defaultValue + , object accessor) + { + if (type == typeof(int)) + { + if (defaultValue.GetType() != typeof(int) || accessor.GetType() != typeof(Accessor)) + throw new InvalidOperationException("Can't create int proxy."); + + return new IntOptionProxy(name, (int)defaultValue, (Accessor)accessor); + } + else if (type == typeof(string)) + { + if (defaultValue.GetType() != typeof(string) || accessor.GetType() != typeof(Accessor)) + throw new InvalidOperationException("Can't create string proxy."); + + return new StringOptionProxy(name, (string)defaultValue, (Accessor)accessor); + } + else if (type == typeof(bool)) + { + if (defaultValue.GetType() != typeof(bool) || accessor.GetType() != typeof(Accessor)) + throw new InvalidOperationException("Can't create bool proxy."); + + return new BoolOptionProxy(name, (bool)defaultValue, (Accessor)accessor); + } + else if (type.IsEnum) return CreateEnum(type, name, defaultValue, accessor); + + throw new NotSupportedException($"No proxy available for type '{type}'."); + } + + private static IOptionProxy CreateEnum(Type type + , string name + , object defaultValue + , object accessor) + { + Type proxyType = typeof(EnumOptionProxy<>); + Type specificProxyType = proxyType.MakeGenericType(type); + + return Activator.CreateInstance(specificProxyType + , name + , defaultValue + , accessor + ) as IOptionProxy ?? throw new NullReferenceException($"Failed to create enum proxy {type}."); + } + } +} diff --git a/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/StringOptionProxy.cs b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/StringOptionProxy.cs new file mode 100644 index 00000000..7ba3b30d --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/StringOptionProxy.cs @@ -0,0 +1,38 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CUERipper.Avalonia.Utilities; +using CUERipper.Avalonia.ViewModels.Bindings.OptionProxies.Abstractions; + +namespace CUERipper.Avalonia.ViewModels.Bindings.OptionProxies +{ + public class StringOptionProxy : OptionProxy + { + public StringOptionProxy(string name, string defaultValue, Accessor accessor) + : base(name, defaultValue, accessor) + { + Value = string.IsNullOrWhiteSpace(Value) ? defaultValue : Value; + } + + protected override string GetStringFromValue(string val) + => val; + + protected override string GetValueFromString(string str) + => str; + } +} diff --git a/CUERipper.Avalonia/ViewModels/EncoderOptionsDialogViewModel.cs b/CUERipper.Avalonia/ViewModels/EncoderOptionsDialogViewModel.cs new file mode 100644 index 00000000..3c125c7d --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/EncoderOptionsDialogViewModel.cs @@ -0,0 +1,28 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CUERipper.Avalonia.ViewModels.Bindings.OptionProxies.Abstractions; +using System.Collections.ObjectModel; + +namespace CUERipper.Avalonia.ViewModels +{ + public partial class EncoderOptionsDialogViewModel : ViewModelBase + { + public ObservableCollection Options { get; } = []; + } +} diff --git a/CUERipper.Avalonia/ViewModels/MainWindowViewModel.cs b/CUERipper.Avalonia/ViewModels/MainWindowViewModel.cs new file mode 100644 index 00000000..bfee1904 --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,282 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CUERipper.Avalonia.Compatibility; +using CUERipper.Avalonia.Configuration.Abstractions; +using CUERipper.Avalonia.Extensions; +using CUERipper.Avalonia.Models; +using CUERipper.Avalonia.Services.Abstractions; +using CUERipper.Avalonia.ViewModels.Bindings; +using Microsoft.Extensions.Localization; +using System.Collections.ObjectModel; +using System.Linq; + +namespace CUERipper.Avalonia.ViewModels +{ + public partial class MainWindowViewModel : ViewModelBase + { + public ObservableCollection Tracks { get; set; } = []; + public ObservableCollection Metadata { get; } = []; + + public ObservableCollection DiscDrives { get; set; } = []; + + [ObservableProperty] + private string selectedDrive = string.Empty; + + public bool CDDriveAvailable => string.Compare(DiscDrives[0], Constants.NoCDDriveFound) != 0; + + partial void OnSelectedDriveChanged(string? oldValue, string newValue) + { + if (string.IsNullOrWhiteSpace(newValue)) return; + if (string.Compare(oldValue, newValue) == 0) return; + if (string.Compare(newValue, Constants.NoCDDriveFound) == 0) return; + + _ripperService.SelectedDrive = newValue[0]; + _config.DefaultDrive = newValue; + + RefreshAlbums(); + } + + public ObservableCollection AlbumReleases { get; set; } = []; + + [ObservableProperty] + private AlbumRelease? selectedAlbum; + + partial void OnSelectedAlbumChanged(AlbumRelease? oldValue, AlbumRelease? newValue) + { + if (string.IsNullOrWhiteSpace(newValue?.Name)) return; + if (string.Compare(oldValue?.Name, newValue!.Name) == 0) return; + + RefreshTrackList(); + + var albumMeta = GetSelectedAlbumMeta(); + RefreshMetadata(albumMeta); + } + + [ObservableProperty] + private Bitmap? albumCoverImage; + + [ObservableProperty] + private string albumTitle = string.Empty; + + [ObservableProperty] + private string albumArtist = string.Empty; + + [ObservableProperty] + private string albumYear = string.Empty; + + [ObservableProperty] + private string albumDisc = string.Empty; + + [ObservableProperty] + private string outputPath = "Output Path"; + + [ObservableProperty] + private int readingProgress; + + [ObservableProperty] + private int totalProgress; + + [ObservableProperty] + private int errorProgress; + + [ObservableProperty] + private bool splitPaneOpen; + partial void OnSplitPaneOpenChanged(bool oldValue, bool newValue) + { + _config.DetailPaneOpened = newValue; + } + + public string HeaderTracks { get => _localizer["Main:Tracks"]; } + public string HeaderMetadata { get => _localizer["Main:Metadata"]; } + public string HeaderTitle { get => _localizer["TrackList:Title"]; } + public string HeaderLength { get => _localizer["TrackList:Length"]; } + public string HeaderProgress { get => _localizer["TrackList:Progress"]; } + public string HeaderArtist { get => _localizer["TrackList:Artist"]; } + + private readonly ICUEConfigFacade _config; + private readonly ICUERipperService _ripperService; + private readonly ICUEMetaService _metaService; + private readonly IStringLocalizer _localizer; + private readonly IIconService _iconService; + public MainWindowViewModel(ICUEConfigFacade config + , ICUERipperService ripperService + , ICUEMetaService metaService + , IStringLocalizer stringLocalizer + , IIconService iconService) + { + _config = config; + _ripperService = ripperService; + _metaService = metaService; + _localizer = stringLocalizer; + _iconService = iconService; + } + + public void RefreshAlbums() + { + AlbumReleases.Clear(); + + var metaInfo = _metaService.GetAlbumMetaInformation(false); + new ObservableCollection( + metaInfo.Select(meta => + { + const string YEAR_SEPERATOR = ": "; + + string year = meta.Data.Year; + string artist = meta.Data.Artist ?? Constants.UnknownArtist; + string title = meta.Data.Title ?? Constants.UnknownTitle; + string country = meta.Data.Country ?? string.Empty; + string labelName = meta.Data.Label ?? string.Empty; + string barcode = meta.Data.Barcode ?? string.Empty; + string releaseDate = meta.Data.ReleaseDate ?? string.Empty; + + if (string.IsNullOrWhiteSpace(country) + && string.IsNullOrWhiteSpace(labelName) + && string.IsNullOrWhiteSpace(barcode) + && string.IsNullOrWhiteSpace(releaseDate)) + { + return new AlbumRelease($"{(string.IsNullOrWhiteSpace(year) ? string.Empty : year + YEAR_SEPERATOR)}{artist} - {title}" + , Icon: _iconService.GetIcon(meta.Source)); + } + + return new AlbumRelease($"{(string.IsNullOrWhiteSpace(year) ? string.Empty : year + YEAR_SEPERATOR)}{artist} - {title} ({country} - {labelName} {barcode} - {releaseDate})" + , Icon: _iconService.GetIcon(meta.Source)); + }) + ).MoveAll(AlbumReleases); + + SelectedAlbum = AlbumReleases.Any() ? AlbumReleases[0] : null; + } + + public void RefreshTrackList() + { + Tracks.Clear(); + + if (!CDDriveAvailable) return; + + var tracksLength = _metaService.GetTracksLength(); + + var meta = GetSelectedAlbumMeta(); + if (meta == null) return; + + meta.Data.Title = string.IsNullOrWhiteSpace(meta.Data.Title) ? Constants.UnknownTitle : meta.Data.Title; + meta.Data.Artist = string.IsNullOrWhiteSpace(meta.Data.Artist) ? Constants.UnknownArtist : meta.Data.Artist; + + AlbumTitle = meta.Data.Title; + AlbumArtist = meta.Data.Artist; + AlbumYear = meta.Data.Year; + AlbumDisc = $"{_localizer["Main:Disc"]} {meta.Data.DiscNumber ?? "1"} {_localizer["Main:DiscSeperator"]} {meta.Data.TotalDiscs ?? "1"}"; + + for (int i = 0; i < meta.Data.Tracks.Count; ++i) + { + var trackInfo = meta.Data.Tracks[i]; + Tracks.Add(new TrackModel + { + Title = trackInfo?.Title ?? $"{Constants.UnknownTrack} {i+1}" + , TrackNo = i + 1 + , Artist = trackInfo?.Artist ?? meta.Data.Artist + , Length = tracksLength.ElementAtOrDefault(i) ?? Constants.TrackNullLength + , OnUpdate = (TrackModel model) => { + meta.Data.Tracks[model.TrackNo - 1].Title = model.Title; + meta.Data.Tracks[model.TrackNo - 1].Artist = model.Artist; + } + }); + } + + OutputPath = meta.PathStringFromFormat(_config.PathFormat, _config); + } + + public void RefreshMetadata(AlbumMetadata? meta) + { + Metadata.Clear(); + if (meta == null) return; + + new ObservableCollection { + new (_localizer["Meta:Artist"], () => meta.Data.Artist, x => { + meta.Data.Artist = x; + AlbumArtist = x; + }) + , new (_localizer["Meta:Title"], () => meta.Data.Title, x => { + meta.Data.Title = x; + AlbumTitle = x; + }) + , new (_localizer["Meta:Genre"], () => meta.Data.Genre, x => meta.Data.Genre = x) + , new (_localizer["Meta:Year"], () => meta.Data.Year, x => { + meta.Data.Year = x; + AlbumYear = x; + }) + , new (_localizer["Meta:CurrentDisc"], () => meta.Data.DiscNumber, x => { + meta.Data.DiscNumber = x; + AlbumDisc = $"{_localizer["Main:Disc"]} {meta.Data.DiscNumber ?? "1"}/{meta.Data.TotalDiscs ?? "1"}"; + }) + , new (_localizer["Meta:TotalDiscs"], () => meta.Data.TotalDiscs, x => { + meta.Data.TotalDiscs = x; + AlbumDisc = $"{_localizer["Main:Disc"]} {meta.Data.DiscNumber ?? "1"}/{meta.Data.TotalDiscs ?? "1"}"; + }) + , new (_localizer["Meta:DiscName"], () => meta.Data.DiscName, x => meta.Data.DiscName = x) + , new (_localizer["Meta:Label"], () => meta.Data.Label, x => meta.Data.Label = x) + , new (_localizer["Meta:LabelNo"], () => meta.Data.LabelNo, x => meta.Data.LabelNo = x) + , new (_localizer["Meta:ReleaseDate"], () => meta.Data.ReleaseDate, x => meta.Data.ReleaseDate = x) + , new (_localizer["Meta:Barcode"], () => meta.Data.Barcode, x => meta.Data.Barcode = x) + , new (_localizer["Meta:Country"], () => meta.Data.Country, x => meta.Data.Country = x) + , new (_localizer["Meta:Comment"], () => meta.Data.Comment, x => meta.Data.Comment = x) + }.MoveAll(Metadata); + } + + public AlbumMetadata? GetSelectedAlbumMeta() + { + if (!CDDriveAvailable || SelectedAlbum == null) return null; + + var index = AlbumReleases.IndexOf(SelectedAlbum); + if (index == -1) index = 0; + + var albumMetaInformation = _metaService.GetAlbumMetaInformation(false); + return index < albumMetaInformation.Count ? albumMetaInformation.ElementAt(index) : null; + } + + internal bool SetInitState(Bitmap? albumCover) + { + var driveList = _ripperService.QueryDrivesAvailable() + .Select(d => d.Value) + .ToList(); + + DiscDrives.Clear(); + foreach (var driveName in driveList) + { + DiscDrives.Add(driveName); + } + + if (DiscDrives.Count == 0) + { + DiscDrives.Add(Constants.NoCDDriveFound); + } + + SelectedDrive = !string.IsNullOrWhiteSpace(_config.DefaultDrive) + && DiscDrives.Contains(_config.DefaultDrive) + ? _config.DefaultDrive + : DiscDrives[0]; + + SplitPaneOpen = _config.DetailPaneOpened; + + AlbumCoverImage = albumCover; + + return true; + } + } +} diff --git a/CUERipper.Avalonia/ViewModels/MessageBoxViewModel.cs b/CUERipper.Avalonia/ViewModels/MessageBoxViewModel.cs new file mode 100644 index 00000000..dd269676 --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/MessageBoxViewModel.cs @@ -0,0 +1,37 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CommunityToolkit.Mvvm.ComponentModel; + +namespace CUERipper.Avalonia.ViewModels +{ + public partial class MessageBoxViewModel : ViewModelBase + { + [ObservableProperty] + private string message = string.Empty; + + [ObservableProperty] + private string affirm = string.Empty; + + [ObservableProperty] + private string negate = string.Empty; + + [ObservableProperty] + private bool showNegate; + } +} diff --git a/CUERipper.Avalonia/ViewModels/OptionsDialogViewModel.cs b/CUERipper.Avalonia/ViewModels/OptionsDialogViewModel.cs new file mode 100644 index 00000000..73421c5b --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/OptionsDialogViewModel.cs @@ -0,0 +1,31 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CUERipper.Avalonia.ViewModels.Bindings.OptionProxies.Abstractions; +using System.Collections.ObjectModel; + +namespace CUERipper.Avalonia.ViewModels +{ + public partial class OptionsDialogViewModel : ViewModelBase + { + public ObservableCollection CTDBOptions { get; } = []; + public ObservableCollection ExtractionOptions { get; } = []; + public ObservableCollection ProxyOptions { get; } = []; + public ObservableCollection VariousOptions { get; } = []; + } +} diff --git a/CUERipper.Avalonia/ViewModels/PathFormatWindowViewModel.cs b/CUERipper.Avalonia/ViewModels/PathFormatWindowViewModel.cs new file mode 100644 index 00000000..733d6da8 --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/PathFormatWindowViewModel.cs @@ -0,0 +1,86 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CommunityToolkit.Mvvm.ComponentModel; +using CUERipper.Avalonia.Configuration.Abstractions; +using CUERipper.Avalonia.Extensions; +using CUERipper.Avalonia.Models; +using System.Collections.ObjectModel; + +namespace CUERipper.Avalonia.ViewModels +{ + public partial class PathFormatDialogViewModel : ViewModelBase + { + [ObservableProperty] + private bool readOnly; + + [ObservableProperty] + private bool maximumReached; + + public ObservableCollection Formats { get; } = []; + + [ObservableProperty] + private int formatIndex = -1; + partial void OnFormatIndexChanged(int oldValue, int newValue) + { + if (oldValue == newValue) return; + if (newValue == -1) + { + // Workaround, as editing removes existing string in the observable collection. + FormatIndex = oldValue < Formats.Count ? oldValue : 0; + return; + } + + ReadOnly = newValue < Constants.DefaultPathFormats.Length; + FormatText = Formats[newValue]; + + // TODO find a better place, maybe... :) + MaximumReached = Formats.Count - Constants.DefaultPathFormats.Length >= Constants.MaxPathFormats; + } + + [ObservableProperty] + private string outputPreview = string.Empty; + + [ObservableProperty] + private string formatText = string.Empty; + partial void OnFormatTextChanged(string? oldValue, string newValue) + { + if (string.IsNullOrWhiteSpace(newValue)) return; + if (string.Compare(oldValue, newValue) == 0) return; + + Formats[FormatIndex] = newValue; + + try + { + OutputPreview = _meta.PathStringFromFormat(newValue, _config); + } + catch + { + OutputPreview = "Couldn't parse the current format."; + } + } + + private readonly ICUEConfigFacade _config; + private readonly AlbumMetadata? _meta; + public PathFormatDialogViewModel(ICUEConfigFacade config, AlbumMetadata? meta) + { + _config = config; + _meta = meta; + } + } +} diff --git a/CUERipper.Avalonia/ViewModels/UserControls/CoverViewAlbumViewModel.cs b/CUERipper.Avalonia/ViewModels/UserControls/CoverViewAlbumViewModel.cs new file mode 100644 index 00000000..5fb1a09c --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/UserControls/CoverViewAlbumViewModel.cs @@ -0,0 +1,57 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using System; + +namespace CUERipper.Avalonia.ViewModels.UserControls +{ + public partial class CoverViewAlbumViewModel : ObservableObject, IEquatable + { + public string Uri { get; set; } + public string Uri150 { get; set; } + public Bitmap? Bitmap150 { get; set; } + + private bool _isSelected; + public bool IsSelected + { + get => _isSelected; + set + { + _isSelected = value; + BorderColor = value ? "#0078D4" : "Transparent"; + } + } + + [ObservableProperty] + private string borderColor = "Transparent"; + + public CoverViewAlbumViewModel(string uri, string uri150) + { + Uri = uri; + Uri150 = uri150; + } + + public bool Equals(CoverViewAlbumViewModel? other) + { + if (other == null) return false; + return Uri == other.Uri && Uri150 == other.Uri150; + } + } +} diff --git a/CUERipper.Avalonia/ViewModels/UserControls/CoverViewerViewModel.cs b/CUERipper.Avalonia/ViewModels/UserControls/CoverViewerViewModel.cs new file mode 100644 index 00000000..7c285ce3 --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/UserControls/CoverViewerViewModel.cs @@ -0,0 +1,34 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using System.Collections.ObjectModel; + +namespace CUERipper.Avalonia.ViewModels.UserControls +{ + public partial class CoverViewerViewModel : ViewModelBase + { + public ObservableCollection AlbumCovers { get; } = []; + + [ObservableProperty] + private Bitmap? currentCover; + + public bool IsReadOnly { get; set; } + } +} diff --git a/CUERipper.Avalonia/ViewModels/UserControls/DriveSettingSectionViewModel.cs b/CUERipper.Avalonia/ViewModels/UserControls/DriveSettingSectionViewModel.cs new file mode 100644 index 00000000..3ce8e0cc --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/UserControls/DriveSettingSectionViewModel.cs @@ -0,0 +1,126 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CommunityToolkit.Mvvm.ComponentModel; +using CUERipper.Avalonia.Configuration.Abstractions; +using CUERipper.Avalonia.Events; +using CUERipper.Avalonia.Services.Abstractions; +using CUETools.Ripper; +using Microsoft.Extensions.Localization; +using System; +using System.Collections.ObjectModel; + +namespace CUERipper.Avalonia.ViewModels.UserControls +{ + public partial class DriveSettingSectionViewModel : ViewModelBase + { + [ObservableProperty] + private int selectedSecureMode = Constants.SecureModeDefault; + partial void OnSelectedSecureModeChanged(int oldValue, int newValue) + { + if (oldValue == newValue) return; + + SelectedSecureModeText = Constants.SecureModeValues[Math.Max(0, Math.Min(Constants.SecureModeValues.Length - 1, newValue))]; + _config.SecureModeIndex = newValue; + } + + [ObservableProperty] + private string selectedSecureModeText = Constants.SecureModeValues[Constants.SecureModeDefault]; + + public ObservableCollection C2ErrorMode { get; set; } + = [.. Enum.GetNames(typeof(DriveC2ErrorModeSetting))]; + + [ObservableProperty] + private string selectedC2ErrorMode = string.Empty; + + partial void OnSelectedC2ErrorModeChanged(string? oldValue, string newValue) + { + if (string.Compare(oldValue, newValue) == 0) return; + + int index = C2ErrorMode.IndexOf(newValue); + + if (_config.DriveC2ErrorModes.ContainsKey(_ripperService.GetDriveARName())) + { + _config.DriveC2ErrorModes[_ripperService.GetDriveARName()] = index; + } + else + { + _config.DriveC2ErrorModes.Add(_ripperService.GetDriveARName(), index); + } + } + + [ObservableProperty] + private bool testAndCopyEnabled = false; + + partial void OnTestAndCopyEnabledChanged(bool oldValue, bool newValue) + { + if (oldValue == newValue) return; + + _config.TestAndCopyEnabled = newValue; + } + + [ObservableProperty] + private int driveOffset = 0; + + partial void OnDriveOffsetChanged(int oldValue, int newValue) + { + if (oldValue == newValue) return; + + if (_config.DriveOffsets.ContainsKey(_ripperService.GetDriveARName())) + { + _config.DriveOffsets[_ripperService.GetDriveARName()] = newValue; + } + else + { + _config.DriveOffsets.Add(_ripperService.GetDriveARName(), newValue); + } + } + + private readonly ICUEConfigFacade _config; + private readonly ICUERipperService _ripperService; + private readonly IStringLocalizer _localizer; + + public DriveSettingSectionViewModel(ICUEConfigFacade config + , ICUERipperService ripperService + , IStringLocalizer localizer) + { + _config = config; + _ripperService = ripperService; + _localizer = localizer; + + _ripperService.OnSelectedDriveChanged += (object? sender, DriveChangedEventArgs e) => { + SelectedC2ErrorMode = _config.DriveC2ErrorModes.TryGetValue(_ripperService.GetDriveARName(), out int c2Value) + ? C2ErrorMode[(c2Value >= 0 && c2Value <= 3 ? c2Value : C2ErrorMode.Count - 1)] + : C2ErrorMode[C2ErrorMode.Count - 1]; + + DriveOffset = _config.DriveOffsets.TryGetValue(_ripperService.GetDriveARName(), out int offsetValue) + ? offsetValue + : _ripperService.GetDriveOffset(); + }; + } + + internal void SetInitState() + { + SelectedSecureMode = _config.SecureModeIndex >= 0 && _config.SecureModeIndex < Constants.SecureModeValues.Length + ? _config.SecureModeIndex + : Constants.SecureModeDefault; + + TestAndCopyEnabled = _config.TestAndCopyEnabled; + } + } +} diff --git a/CUERipper.Avalonia/ViewModels/UserControls/EncodingSectionViewModel.cs b/CUERipper.Avalonia/ViewModels/UserControls/EncodingSectionViewModel.cs new file mode 100644 index 00000000..e5599029 --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/UserControls/EncodingSectionViewModel.cs @@ -0,0 +1,233 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CommunityToolkit.Mvvm.ComponentModel; +using CUERipper.Avalonia.Configuration.Abstractions; +using CUERipper.Avalonia.Extensions; +using CUERipper.Avalonia.Models; +using CUETools.Processor; +using Microsoft.Extensions.Localization; +using System; +using System.Collections.ObjectModel; +using System.Linq; + +namespace CUERipper.Avalonia.ViewModels.UserControls +{ + public partial class EncodingSectionViewModel : ViewModelBase + { + + public ObservableCollection Compression + { + get => [_localizer["Encoding:Lossless"], _localizer["Encoding:Lossy"]]; + } + + [ObservableProperty] + private string selectedCompression = string.Empty; + + partial void OnSelectedCompressionChanged(string? oldValue, string newValue) + { + if (string.IsNullOrWhiteSpace(newValue)) return; + if (string.Compare(oldValue, newValue) == 0) return; + + RefreshEncoding(); + } + + public ObservableCollection CUEStyle + { + get => [_localizer["Encoding:Image"], _localizer["Encoding:Tracks"]]; + } + + [ObservableProperty] + private string selectedCUEStyle = string.Empty; + + public ObservableCollection Encoding { get; set; } = []; + + [ObservableProperty] + private string selectedEncoding = string.Empty; + + partial void OnSelectedEncodingChanged(string? oldValue, string newValue) + { + if (string.IsNullOrWhiteSpace(newValue)) return; + + RefreshEncoder(); + } + + public ObservableCollection Encoder { get; set; } = []; + + [ObservableProperty] + private string selectedEncoder = string.Empty; + + partial void OnSelectedEncoderChanged(string? oldValue, string newValue) + { + if (string.IsNullOrWhiteSpace(newValue)) return; + RefreshEncoderSettings(); + } + + [ObservableProperty] + private int selectedEncoderMode; + partial void OnSelectedEncoderModeChanged(int oldValue, int newValue) + => RefreshEncoderModeSelection(); + + [ObservableProperty] + private int encoderModeMaximum; + + [ObservableProperty] + private bool isEncoderModeEnabled; + + private string[] _encoderModeValues = []; + + [ObservableProperty] + private string selectedEncoderModeText = string.Empty; + + public string ToolTipCompression { get => _localizer["Encoding:ToolTipCompression"]; } + public string ToolTipCUEStyle { get => _localizer["Encoding:ToolTipCUEStyle"]; } + + private readonly ICUEConfigFacade _config; + private readonly IStringLocalizer _localizer; + public EncodingSectionViewModel(ICUEConfigFacade config + , IStringLocalizer localizer) + { + _config = config; + _localizer = localizer; + } + + public bool IsLossless() + => SelectedCompression == _localizer["Encoding:Lossless"]; + + public void RefreshEncoding() + { + Encoding.Clear(); + new ObservableCollection( + _config.Formats + .Where(f => f.Value.allowLossless == IsLossless() || f.Value.allowLossy == !IsLossless()) + .Select(f => f.Key) + ).MoveAll(Encoding); + + var favorite = IsLossless() ? _config.DefaultLosslessFormat : _config.DefaultLossyFormat; + if (!Encoding.Contains(favorite)) favorite = Encoding[0]; + + SelectedEncoding = Encoding.Any() ? favorite : string.Empty; + } + + public void RefreshEncoder() + { + Encoder.Clear(); + + new ObservableCollection( + _config.Encoders + .Where(e => string.Compare(e.Extension, SelectedEncoding, true) == 0) + .Select(e => e.Name) + ).MoveAll(Encoder); + + var favoriteEncoder = _config.Formats + .Where(f => f.Key == SelectedEncoding) + .Select(f => (IsLossless() ? f.Value.encoderLossless?.Name : f.Value.encoderLossy?.Name) ?? string.Empty) + .FirstOrDefault(); + + SelectedEncoder = Encoder.Any() + ? Encoder.Where(e => e == favoriteEncoder).FirstOrDefault() ?? Encoder[0] + : string.Empty; + } + + public void RefreshEncoderSettings() + { + var currentEncoding = _config.Formats + .Where(f => f.Key == SelectedEncoding) + .Select(e => e.Value) + .Single(); + + if (string.IsNullOrWhiteSpace(SelectedEncoder)) + { + _encoderModeValues = ["No encoder found for this format."]; + SelectedEncoderMode = 0; + EncoderModeMaximum = 0; + return; + } + + var requestedEncoder = _config.Encoders + .Where(e => string.Compare(e.Extension, SelectedEncoding, true) == 0) + .Where(e => string.Compare(e.Name, SelectedEncoder, true) == 0) + .Single(); + + _encoderModeValues = requestedEncoder.SupportedModes.Split(' '); + EncoderModeMaximum = _encoderModeValues.Length - 1; + SelectedEncoderMode = EncoderModeMaximum > requestedEncoder.EncoderModeIndex + ? Math.Max(0, requestedEncoder.EncoderModeIndex) + : 0; + + IsEncoderModeEnabled = EncoderModeMaximum > 0; + } + + public void RefreshEncoderModeSelection() + { + var encoderMode = _encoderModeValues[SelectedEncoderMode]; + SelectedEncoderModeText = encoderMode; + } + + internal bool SetInitState() + { + SelectedCompression = _config.OutputCompression == AudioEncoderType.Lossless + ? _localizer["Encoding:Lossless"] + : _localizer["Encoding:Lossy"]; + + SelectedCUEStyle = _config.CUEStyleIndex >= 0 && _config.CUEStyleIndex < CUEStyle.Count + ? CUEStyle[_config.CUEStyleIndex] + : CUEStyle[CUEStyle.Count - 1]; + + return true; + } + + public EncodingConfiguration? GetConfiguration() + { + if (string.IsNullOrWhiteSpace(SelectedEncoder)) return null; + + var requestedEncoder = _config.Encoders + .Where(e => string.Compare(e.Extension, SelectedEncoding, true) == 0) + .Where(e => string.Compare(e.Name, SelectedEncoder, true) == 0) + .SingleOrDefault(); + + if (requestedEncoder == null) return null; + + return new EncodingConfiguration + ( + IsLossless: IsLossless() + , CUEStyleIndex: CUEStyle.IndexOf(SelectedCUEStyle) + , Encoding: SelectedEncoding + , Encoder: SelectedEncoder + , EncoderMode: _encoderModeValues[SelectedEncoderMode] + ); + } + + public void SetConfiguration(EncodingConfiguration encodingConfiguration) + { + SelectedCompression = encodingConfiguration.IsLossless + ? _localizer["Encoding:Lossless"] + : _localizer["Encoding:Lossy"]; + + SelectedCUEStyle = encodingConfiguration.CUEStyleIndex >= 0 && encodingConfiguration.CUEStyleIndex < CUEStyle.Count + ? CUEStyle[encodingConfiguration.CUEStyleIndex] + : CUEStyle[CUEStyle.Count - 1]; + + SelectedEncoding = encodingConfiguration.Encoding; + SelectedEncoder = encodingConfiguration.Encoder; + + int encoderModeIndex = Array.IndexOf(_encoderModeValues, encodingConfiguration.EncoderMode); + SelectedEncoderMode = Math.Max(0, encoderModeIndex); + } + } +} diff --git a/CUERipper.Avalonia/ViewModels/ViewModelBase.cs b/CUERipper.Avalonia/ViewModels/ViewModelBase.cs new file mode 100644 index 00000000..5cd7e7ee --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/ViewModelBase.cs @@ -0,0 +1,26 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using CommunityToolkit.Mvvm.ComponentModel; + +namespace CUERipper.Avalonia.ViewModels +{ + public class ViewModelBase : ObservableObject + { + } +} diff --git a/CUERipper.Avalonia/Views/EncoderOptionsDialog.axaml b/CUERipper.Avalonia/Views/EncoderOptionsDialog.axaml new file mode 100644 index 00000000..2f64c1ca --- /dev/null +++ b/CUERipper.Avalonia/Views/EncoderOptionsDialog.axaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CUERipper.Avalonia/Views/EncoderOptionsDialog.axaml.cs b/CUERipper.Avalonia/Views/EncoderOptionsDialog.axaml.cs new file mode 100644 index 00000000..29ed7483 --- /dev/null +++ b/CUERipper.Avalonia/Views/EncoderOptionsDialog.axaml.cs @@ -0,0 +1,121 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Controls; +using CUERipper.Avalonia.Exceptions; +using CUERipper.Avalonia.Extensions; +using CUERipper.Avalonia.Utilities; +using CUERipper.Avalonia.ViewModels; +using CUERipper.Avalonia.ViewModels.Bindings.OptionProxies; +using CUETools.Codecs; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading.Tasks; + +namespace CUERipper.Avalonia; + +public partial class EncoderOptionsDialog : Window +{ + public EncoderOptionsDialogViewModel ViewModel => DataContext as EncoderOptionsDialogViewModel + ?? throw new ViewModelMismatchException(typeof(EncoderOptionsDialogViewModel), DataContext?.GetType()); + + public required IAudioEncoderSettings EncoderSettings { get; init; } + public EncoderOptionsDialog() + { + InitializeComponent(); + DataContextChanged += OnDataContextChanged; + } + + private void OnDataContextChanged(object? sender, EventArgs e) + { + ViewModel.Options.Clear(); + + var properties = GetBrowsableProperties(EncoderSettings); + foreach(var property in properties) + { + var accessorInstance = Accessor.CreateAccessor(property.Type + , Expression.Constant(EncoderSettings) + , property.GetMethod + , property.SetMethod + ); + + if (accessorInstance == null) continue; + + var defaultValue = (property.DefaultValue ?? (property.Type.IsValueType + ? Activator.CreateInstance(property.Type) + : (property.Type == typeof(string) ? string.Empty : null) + )) ?? throw new ArgumentNullException(nameof(property.DefaultValue), "Null can't be a default value for OptionProxy"); + + var proxyInstance = OptionProxyFactory.Create(property.Type + , property.DisplayName + , defaultValue + , accessorInstance + ); + + if (proxyInstance != null) + { + ViewModel.Options.Add(proxyInstance); + } + } + } + + public static List GetBrowsableProperties(object obj) + { + return obj.GetType() + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.GetCustomAttributes().None(attr => !attr.Browsable)) + .Select(p => new PropertyMetadata + { + Name = p.Name + , DisplayName = p.GetCustomAttribute()?.DisplayName ?? p.Name + , Description = p.GetCustomAttribute()?.Description + , DefaultValue = p.GetCustomAttribute()?.Value + , Type = p.PropertyType + , GetMethod = p.GetGetMethod() + , SetMethod = p.GetSetMethod() + }) + .ToList(); + } + + public class PropertyMetadata + { + public required string Name { get; init; } + public required string DisplayName { get; init; } + public string? Description { get; set; } + public object? DefaultValue { get; set; } + public required Type Type { get; init; } + public MethodInfo? GetMethod { get; set; } + public MethodInfo? SetMethod { get; set; } + } + + public static async Task CreateAsync(Window owner, IAudioEncoderSettings encoderSettings) + { + var encodingSettingsWindow = new EncoderOptionsDialog() + { + Owner = owner + , EncoderSettings = encoderSettings + , DataContext = new EncoderOptionsDialogViewModel() + }; + + await encodingSettingsWindow.ShowDialog(owner, lockParent: true); + } +} \ No newline at end of file diff --git a/CUERipper.Avalonia/Views/MainWindow.axaml b/CUERipper.Avalonia/Views/MainWindow.axaml new file mode 100644 index 00000000..54f6f61b --- /dev/null +++ b/CUERipper.Avalonia/Views/MainWindow.axaml @@ -0,0 +1,297 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Progress of the current section being read from the Audio CD. + + + + + Total progress of the Audio CD currently being ripped. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CUERipper.Avalonia/Views/OptionsDialog.axaml.cs b/CUERipper.Avalonia/Views/OptionsDialog.axaml.cs new file mode 100644 index 00000000..368f07a9 --- /dev/null +++ b/CUERipper.Avalonia/Views/OptionsDialog.axaml.cs @@ -0,0 +1,115 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Controls; +using CUERipper.Avalonia.Configuration.Abstractions; +using CUERipper.Avalonia.Exceptions; +using CUERipper.Avalonia.Extensions; +using CUERipper.Avalonia.ViewModels; +using CUERipper.Avalonia.ViewModels.Bindings.OptionProxies; +using CUERipper.Avalonia.ViewModels.Bindings.OptionProxies.Abstractions; +using CUETools.CTDB; +using CUETools.Processor; +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; + +namespace CUERipper.Avalonia; + +public partial class OptionsDialog : Window +{ + public OptionsDialogViewModel ViewModel => DataContext as OptionsDialogViewModel + ?? throw new ViewModelMismatchException(typeof(OptionsDialogViewModel), DataContext?.GetType()); + + public required ICUEConfigFacade Config { get; init; } + public OptionsDialog() + { + InitializeComponent(); + DataContextChanged += OnDataContextChanged; + } + + private void OnDataContextChanged(object? sender, EventArgs e) + { + new ObservableCollection { + new StringOptionProxy("CTDB Server", "db.cuetools.net" + , new(() => Config.CTDBServer)) + , new EnumOptionProxy("Metadata search", CTDBMetadataSearch.Default + , new(() => Config.MetadataSearch)) + , new EnumOptionProxy("Album art size", CUEConfigAdvanced.CTDBCoversSize.Large + , new(() => Config.CoversSize)) + , new EnumOptionProxy("Album art search", CUEConfigAdvanced.CTDBCoversSearch.Primary + , new(() => Config.CoversSearch)) + , new BoolOptionProxy("Detailed log", false + , new(() => Config.DetailedCTDBLog)) + }.MoveAll(ViewModel.CTDBOptions); + + new ObservableCollection { + new BoolOptionProxy("Preserve HTOA", true + , new(() => Config.PreserveHTOA)) + , new BoolOptionProxy("Detect Indexes", true + , new(() => Config.DetectGaps)) + , new BoolOptionProxy("EAC log style", true + , new(() => Config.CreateEACLog)) + , new BoolOptionProxy("Create M3U playlist", false + , new(() => Config.CreateM3U)) + , new BoolOptionProxy("Embed album art", true + , new(() => Config.EmbedAlbumArt)) + , new BoolOptionProxy("Eject after rip", false + , new(() => Config.EjectAfterRip)) + , new BoolOptionProxy("Disable eject disc", true + , new(() => Config.DisableEjectDisc)) + , new StringOptionProxy("Track filename", "%tracknumber%. %title%" + , new(() => Config.TrackFilenameFormat)) + , new BoolOptionProxy("Automatic rip", false + , new(() => Config.AutomaticRip)) + , new BoolOptionProxy("Skip repair", false + , new(() => Config.SkipRepair)) + }.MoveAll(ViewModel.ExtractionOptions); + + new ObservableCollection { + new EnumOptionProxy("Proxy mode", CUEConfigAdvanced.ProxyMode.System + , new(() => Config.UseProxyMode)) + , new StringOptionProxy("Host", "127.0.0.1" + , new(() => Config.ProxyServer)) + , new IntOptionProxy("Port", 8080 + , new(() => Config.ProxyPort)) + , new StringOptionProxy("Auth user", string.Empty + , new(() => Config.ProxyUser)) + , new StringOptionProxy("Auth password", string.Empty + , new(() => Config.ProxyPassword)) + + }.MoveAll(ViewModel.ProxyOptions); + + new ObservableCollection { + new StringOptionProxy("Freedb site address", "gnudb.gnudb.org" + , new(() => Config.FreedbSiteAddress)) + }.MoveAll(ViewModel.VariousOptions); + } + + public static async Task CreateAsync(Window owner, ICUEConfigFacade config) + { + var optionsWindow = new OptionsDialog() + { + Owner = owner + , Config = config + , DataContext = new OptionsDialogViewModel() + }; + + await optionsWindow.ShowDialog(owner, lockParent: true); + } +} \ No newline at end of file diff --git a/CUERipper.Avalonia/Views/PathFormatDialog.axaml b/CUERipper.Avalonia/Views/PathFormatDialog.axaml new file mode 100644 index 00000000..c37ecd25 --- /dev/null +++ b/CUERipper.Avalonia/Views/PathFormatDialog.axaml @@ -0,0 +1,63 @@ + + + + + + + + + + + Burst is the least secure ripping option, paranoid the most. Only consider using paranoid when a previous ripping attempt was not accurate. + + + + + diff --git a/CUERipper.Avalonia/Views/UserControls/DriveSettingSection.axaml.cs b/CUERipper.Avalonia/Views/UserControls/DriveSettingSection.axaml.cs new file mode 100644 index 00000000..0323815d --- /dev/null +++ b/CUERipper.Avalonia/Views/UserControls/DriveSettingSection.axaml.cs @@ -0,0 +1,69 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Controls; +using CUERipper.Avalonia.Configuration.Abstractions; +using CUERipper.Avalonia.Exceptions; +using CUERipper.Avalonia.Services.Abstractions; +using CUERipper.Avalonia.ViewModels.UserControls; +using Microsoft.Extensions.Localization; +using System; + +namespace CUERipper.Avalonia.Views.UserControls; + +public partial class DriveSettingSection : UserControl +{ + public DriveSettingSectionViewModel ViewModel => DataContext as DriveSettingSectionViewModel + ?? throw new ViewModelMismatchException(typeof(DriveSettingSectionViewModel), DataContext?.GetType()); + + private ICUERipperService? _ripperService; + private IIconService? _iconService; + + public DriveSettingSection() + { + InitializeComponent(); + } + + public void Init(ICUEConfigFacade config + , ICUERipperService ripperService + , IStringLocalizer localizer + , IIconService iconService) + { + _ripperService = ripperService; + _iconService = iconService; + + var viewModel = new DriveSettingSectionViewModel(config, ripperService, localizer); + viewModel.SetInitState(); + DataContext = viewModel; + + Image BindImage(AppIcon icon) => new() { Source = _iconService.GetIcon(icon), Width = 18, Height = 18 }; + + buttonResetDriveSettings.Click += OnResetDriveSettingsClicked; + buttonResetDriveSettings.Content = BindImage(AppIcon.Cross); + } + + private void OnResetDriveSettingsClicked(object? sender, EventArgs e) + { + ViewModel.DriveOffset = _ripperService?.GetDriveOffset() + ?? throw new NotInitializedException(nameof(_ripperService)); + + ViewModel.SelectedSecureMode = Constants.SecureModeDefault; + ViewModel.SelectedC2ErrorMode = ViewModel.C2ErrorMode[ViewModel.C2ErrorMode.Count - 1]; + ViewModel.TestAndCopyEnabled = false; + } +} \ No newline at end of file diff --git a/CUERipper.Avalonia/Views/UserControls/EncodingSection.axaml b/CUERipper.Avalonia/Views/UserControls/EncodingSection.axaml new file mode 100644 index 00000000..89c4d336 --- /dev/null +++ b/CUERipper.Avalonia/Views/UserControls/EncodingSection.axaml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CUERipper.Avalonia/Views/UserControls/EncodingSection.axaml.cs b/CUERipper.Avalonia/Views/UserControls/EncodingSection.axaml.cs new file mode 100644 index 00000000..2ac8b423 --- /dev/null +++ b/CUERipper.Avalonia/Views/UserControls/EncodingSection.axaml.cs @@ -0,0 +1,91 @@ +#region Copyright (C) 2025 Max Visser +/* + Copyright (C) 2025 Max Visser + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . +*/ +#endregion +using Avalonia.Controls; +using CUERipper.Avalonia.Configuration.Abstractions; +using CUERipper.Avalonia.Exceptions; +using CUERipper.Avalonia.Services.Abstractions; +using CUERipper.Avalonia.ViewModels.UserControls; +using Microsoft.Extensions.Localization; +using System; +using System.Linq; + +namespace CUERipper.Avalonia.Views.UserControls; + +public partial class EncodingSection : UserControl +{ + public EncodingSectionViewModel ViewModel => DataContext as EncodingSectionViewModel + ?? throw new ViewModelMismatchException(typeof(EncodingSectionViewModel), DataContext?.GetType()); + + private ICUEConfigFacade? _config; + private IIconService? _iconService; + public EncodingSection() + { + InitializeComponent(); + } + + public void Init(ICUEConfigFacade config + , IStringLocalizer localizer + , IIconService iconService) + { + _config = config; + _iconService = iconService; + + var viewModel = new EncodingSectionViewModel(config, localizer); + viewModel.SetInitState(); + DataContext = viewModel; + + Image BindImage(AppIcon icon) => new() { Source = _iconService.GetIcon(icon), Width = 18, Height = 18 }; + + buttonEncoderSettings.Click += OnEncoderSettingsClicked; + buttonEncoderSettings.Content = BindImage(AppIcon.Bolt); + buttonSettings.Click += OnSettingsClicked; + buttonSettings.Content = BindImage(AppIcon.Cog); + } + + private async void OnEncoderSettingsClicked(object? sender, EventArgs e) + { + if (_config == null) return; + + var encoderSettings = _config.Encoders + .Where(e => string.Compare(e.Name, ViewModel.SelectedEncoder, true) == 0 + && string.Compare(e.Extension, ViewModel.SelectedEncoding, true) == 0) + .Select(e => e.Settings) + .FirstOrDefault(); + + if (encoderSettings != null) + { + var parent = TopLevel.GetTopLevel(this); + + await EncoderOptionsDialog.CreateAsync( + parent as Window ?? throw new UnexpectedParentException(typeof(Window), parent?.GetType()) + , encoderSettings + ); + } + } + + private async void OnSettingsClicked(object? sender, EventArgs e) + { + var parent = TopLevel.GetTopLevel(this); + + await OptionsDialog.CreateAsync( + parent as Window ?? throw new UnexpectedParentException(typeof(Window), parent?.GetType()) + , _config ?? throw new NotInitializedException(nameof(_config)) + ); + } +} \ No newline at end of file diff --git a/CUERipper.Avalonia/app.manifest b/CUERipper.Avalonia/app.manifest new file mode 100644 index 00000000..fe0efd33 --- /dev/null +++ b/CUERipper.Avalonia/app.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/CUERipper/CUERipper.csproj b/CUERipper/CUERipper.csproj index 1b0f4105..1df29511 100644 --- a/CUERipper/CUERipper.csproj +++ b/CUERipper/CUERipper.csproj @@ -81,7 +81,6 @@ - Form diff --git a/CUERipper/CUERipperConfig.cs b/CUETools.Processor/CUERipperConfig.cs similarity index 79% rename from CUERipper/CUERipperConfig.cs rename to CUETools.Processor/CUERipperConfig.cs index 5b515c3e..912a46e5 100644 --- a/CUERipper/CUERipperConfig.cs +++ b/CUETools.Processor/CUERipperConfig.cs @@ -1,112 +1,130 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Text; -using System.Xml.Serialization; - -namespace CUERipper -{ - [XmlRoot("dictionary")] - public class SerializableDictionary - : Dictionary, IXmlSerializable - { - #region IXmlSerializable Members - public System.Xml.Schema.XmlSchema GetSchema() - { - return null; - } - - public void ReadXml(System.Xml.XmlReader reader) - { - XmlSerializer keySerializer = new XmlSerializer(typeof(TKey)); - XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue)); - - bool wasEmpty = reader.IsEmptyElement; - reader.Read(); - - if (wasEmpty) - return; - - while (reader.NodeType != System.Xml.XmlNodeType.EndElement) - { - reader.ReadStartElement("item"); - - reader.ReadStartElement("key"); - TKey key = (TKey)keySerializer.Deserialize(reader); - reader.ReadEndElement(); - - reader.ReadStartElement("value"); - TValue value = (TValue)valueSerializer.Deserialize(reader); - reader.ReadEndElement(); - - this.Add(key, value); - - reader.ReadEndElement(); - reader.MoveToContent(); - } - reader.ReadEndElement(); - } - - public void WriteXml(System.Xml.XmlWriter writer) - { - XmlSerializer keySerializer = new XmlSerializer(typeof(TKey)); - XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue)); - - foreach (TKey key in this.Keys) - { - writer.WriteStartElement("item"); - - writer.WriteStartElement("key"); - keySerializer.Serialize(writer, key); - writer.WriteEndElement(); - - writer.WriteStartElement("value"); - TValue value = this[key]; - valueSerializer.Serialize(writer, value); - writer.WriteEndElement(); - - writer.WriteEndElement(); - } - } - #endregion - } - - [Serializable] - public class CUERipperConfig - { - public CUERipperConfig() - { - // Iterate through each property and call ResetValue() - foreach (PropertyDescriptor property in TypeDescriptor.GetProperties(this)) - { - property.ResetValue(this); - } - - this.DriveOffsets = new SerializableDictionary(); - this.DriveC2ErrorModes = new SerializableDictionary(); - this.ReadCDCommands = new SerializableDictionary(); - } - - internal static XmlSerializer serializer = new XmlSerializer(typeof(CUERipperConfig)); - - [DefaultValue("flac")] - public string DefaultLosslessFormat { get; set; } - - [DefaultValue("mp3")] - public string DefaultLossyFormat { get; set; } - - [DefaultValue("lossy.flac")] - public string DefaultHybridFormat { get; set; } - - public string DefaultDrive { get; set; } - - public SerializableDictionary DriveOffsets { get; set; } - - // 0 (None), 1 (Mode294), 2 (Mode296), 3 (Auto) - public SerializableDictionary DriveC2ErrorModes { get; set; } - - // 0 (ReadCdBEh), 1 (ReadCdD8h), 2 (Unknown/AutoDetect) - public SerializableDictionary ReadCDCommands { get; set; } - - } -} +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Xml.Serialization; + +namespace CUETools.Processor +{ + [XmlRoot("dictionary")] + public class SerializableDictionary + : Dictionary, IXmlSerializable + { + #region IXmlSerializable Members + public System.Xml.Schema.XmlSchema GetSchema() + { + return null; + } + + public void ReadXml(System.Xml.XmlReader reader) + { + XmlSerializer keySerializer = new XmlSerializer(typeof(TKey)); + XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue)); + + bool wasEmpty = reader.IsEmptyElement; + reader.Read(); + + if (wasEmpty) + return; + + while (reader.NodeType != System.Xml.XmlNodeType.EndElement) + { + reader.ReadStartElement("item"); + + reader.ReadStartElement("key"); + TKey key = (TKey)keySerializer.Deserialize(reader); + reader.ReadEndElement(); + + reader.ReadStartElement("value"); + TValue value = (TValue)valueSerializer.Deserialize(reader); + reader.ReadEndElement(); + + this.Add(key, value); + + reader.ReadEndElement(); + reader.MoveToContent(); + } + reader.ReadEndElement(); + } + + public void WriteXml(System.Xml.XmlWriter writer) + { + XmlSerializer keySerializer = new XmlSerializer(typeof(TKey)); + XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue)); + + foreach (TKey key in this.Keys) + { + writer.WriteStartElement("item"); + + writer.WriteStartElement("key"); + keySerializer.Serialize(writer, key); + writer.WriteEndElement(); + + writer.WriteStartElement("value"); + TValue value = this[key]; + valueSerializer.Serialize(writer, value); + writer.WriteEndElement(); + + writer.WriteEndElement(); + } + } + #endregion + } + + [Serializable] + public class CUERipperConfig + { + public CUERipperConfig() + { + // Iterate through each property and call ResetValue() + foreach (PropertyDescriptor property in TypeDescriptor.GetProperties(this)) + { + property.ResetValue(this); + } + + this.DriveOffsets = new SerializableDictionary(); + this.DriveC2ErrorModes = new SerializableDictionary(); + this.ReadCDCommands = new SerializableDictionary(); + } + + public static XmlSerializer serializer = new XmlSerializer(typeof(CUERipperConfig)); + + [DefaultValue("flac")] + public string DefaultLosslessFormat { get; set; } + + [DefaultValue("mp3")] + public string DefaultLossyFormat { get; set; } + + [DefaultValue("lossy.flac")] + public string DefaultHybridFormat { get; set; } + + public string DefaultDrive { get; set; } + + public SerializableDictionary DriveOffsets { get; set; } + + // 0 (None), 1 (Mode294), 2 (Mode296), 3 (Auto) + public SerializableDictionary DriveC2ErrorModes { get; set; } + + // 0 (ReadCdBEh), 1 (ReadCdD8h), 2 (Unknown/AutoDetect) + public SerializableDictionary ReadCDCommands { get; set; } + + /// + /// Automatically initiates ripping and bypasses non-error popups. (CUERipper new) + /// + public bool AutomaticRip { get; set; } + + /// + /// Stops the repair process upon detecting inaccuracies in the rip. (CUERipper new) + /// + public bool SkipRepair { get; set; } + + /// + /// Indicates whether the detail pane was open in the last recorded state. (CUERipper new) + /// + public bool DetailPaneOpened { get; set; } + + /// + /// JSON-string containing encoding configuration. (CUERipper new) + /// + public string EncodingConfiguration { get; set; } + } +} diff --git a/CUETools.Ripper.SCSI/SCSIDrive.cs b/CUETools.Ripper.SCSI/SCSIDrive.cs index 1289d70f..503ebf3a 100644 --- a/CUETools.Ripper.SCSI/SCSIDrive.cs +++ b/CUETools.Ripper.SCSI/SCSIDrive.cs @@ -31,6 +31,7 @@ using CUETools.Codecs; using CUETools.Ripper; using System.Threading; +using CUETools.Ripper.Exceptions; namespace CUETools.Ripper.SCSI { @@ -216,7 +217,7 @@ public bool Open(char Drive) // Get device info st = m_device.Inquiry(out m_inqury_result); if (st != Device.CommandStatus.Success) - throw new SCSIException(Resource1.DeviceInquiryError, m_device, st); + throw SCSIExceptionFactory.Create(Resource1.DeviceInquiryError, m_device, st); if (!m_inqury_result.Valid || m_inqury_result.PeripheralQualifier != 0 || m_inqury_result.PeripheralDeviceType != Device.MMCDeviceType) throw new ReadCDException(Resource1.DeviceNotMMC); @@ -262,8 +263,8 @@ public bool Open(char Drive) IList toc; st = m_device.ReadToc((byte)0, false, out toc); if (st != Device.CommandStatus.Success) - throw new SCSIException(Resource1.ReadTOCError, m_device, st); - //throw new Exception("ReadTOC: " + (st == Device.CommandStatus.DeviceFailed ? Device.LookupSenseError(m_device.GetSenseAsc(), m_device.GetSenseAscq()) : st.ToString())); + throw SCSIExceptionFactory.Create(Resource1.ReadTOCError, m_device, st); + //throw new Exception("ReadTOC: " + (st == Device.CommandStatus.DeviceFailed ? Device.LookupSenseError(m_device.GetSenseAsc(), m_device.GetSenseAscq()) : st.ToString())); //byte[] qdata = null; //st = m_device.ReadPMA(out qdata); @@ -1032,7 +1033,7 @@ private unsafe Device.CommandStatus FetchSectors(int sector, int Sectors2Read, b if (!abort) return st; - SCSIException ex = new SCSIException(Resource1.ReadCDError, m_device, st); + SCSIException ex = SCSIExceptionFactory.Create(Resource1.ReadCDError, m_device, st); if (sector != 0 && Sectors2Read > 1 && st == Device.CommandStatus.DeviceFailed && m_device.GetSenseAsc() == 0x64 && m_device.GetSenseAscq() == 0x00) { if (_debugMessages) @@ -1370,19 +1371,18 @@ enum ReadCDCommand Unknown }; - public sealed class SCSIException : Exception + public static class SCSIExceptionFactory { - public SCSIException(string args, Device device, Device.CommandStatus st) - : base(args + ": " + (st == Device.CommandStatus.DeviceFailed ? device.GetErrorString() : st.ToString())) + public static SCSIException Create(string args, Device device, Device.CommandStatus st) { - } - } + if (args == Resource1.ReadTOCError) + { + return new TOCException(args + ": " + + (st == Device.CommandStatus.DeviceFailed ? device.GetErrorString() : st.ToString())); + } - public sealed class ReadCDException : Exception - { - public ReadCDException(string args, Exception inner) - : base(args + ": " + inner.Message, inner) { } - public ReadCDException(string args) - : base(args) { } + return new SCSIException(args + ": " + + (st == Device.CommandStatus.DeviceFailed ? device.GetErrorString() : st.ToString())); + } } } diff --git a/CUETools.Ripper/Exceptions/ReadCDException.cs b/CUETools.Ripper/Exceptions/ReadCDException.cs new file mode 100644 index 00000000..70628522 --- /dev/null +++ b/CUETools.Ripper/Exceptions/ReadCDException.cs @@ -0,0 +1,10 @@ +using System; + +namespace CUETools.Ripper.Exceptions +{ + public class ReadCDException : Exception + { + public ReadCDException(string exceptionMessage) : base(exceptionMessage) { } + public ReadCDException(string exceptionMessage, Exception innerException) : base(exceptionMessage, innerException) { } + } +} diff --git a/CUETools.Ripper/Exceptions/SCSIException.cs b/CUETools.Ripper/Exceptions/SCSIException.cs new file mode 100644 index 00000000..3acc5c67 --- /dev/null +++ b/CUETools.Ripper/Exceptions/SCSIException.cs @@ -0,0 +1,9 @@ +using System; + +namespace CUETools.Ripper.Exceptions +{ + public class SCSIException : Exception + { + public SCSIException(string exceptionMessage) : base(exceptionMessage) { } + } +} diff --git a/CUETools.Ripper/Exceptions/TOCException.cs b/CUETools.Ripper/Exceptions/TOCException.cs new file mode 100644 index 00000000..6810cdb4 --- /dev/null +++ b/CUETools.Ripper/Exceptions/TOCException.cs @@ -0,0 +1,7 @@ +namespace CUETools.Ripper.Exceptions +{ + public class TOCException : SCSIException + { + public TOCException(string exceptionMessage) : base(exceptionMessage) { } + } +} diff --git a/CUETools.Updater/CUETools.Updater.csproj b/CUETools.Updater/CUETools.Updater.csproj new file mode 100644 index 00000000..5db42b86 --- /dev/null +++ b/CUETools.Updater/CUETools.Updater.csproj @@ -0,0 +1,10 @@ + + + + Exe + net47;net8.0 + 12 + enable + + + diff --git a/CUETools.Updater/Program.cs b/CUETools.Updater/Program.cs new file mode 100644 index 00000000..5a42ee13 --- /dev/null +++ b/CUETools.Updater/Program.cs @@ -0,0 +1,341 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Threading; + +namespace CUETools.Updater +{ + internal class Program + { + const string UpdateFolder = ".cueupdate"; + static readonly string CurrentProcess = Path.GetFileNameWithoutExtension(Assembly.GetExecutingAssembly().Location); + + enum ResultCode + { + UnsupportedOutput = -6 + , OutputDirExists = -5 + , VerifyFailed = -4 + , FileLock = -3 + , IncorrectArguments = -2 + , WrongDirectory = -1 + , Success = 0 + } + + static void PrintHelp() + { + Console.WriteLine("Usage: "); + Console.WriteLine("CUETools.Updater Apply Update-X.Y.Z"); + Console.WriteLine(" - Do not prepend a path to the file."); + Console.WriteLine("CUETools.Updater SelfUpdate"); + } + + static void Main(string[] args) + { + var updateDirectory = Path.Combine(Directory.GetCurrentDirectory(), UpdateFolder); + if (!Directory.Exists(updateDirectory)) + { + Directory.CreateDirectory(updateDirectory); + } + + if (args.Length == 0) + { + PrintHelp(); + Environment.Exit((int)ResultCode.IncorrectArguments); + } + + switch (args[0].ToLower()) + { + case "apply": + try + { + var result = ApplyRoutine(args, updateDirectory); + if (result != ResultCode.Success) Environment.Exit((int)result); + + TriggerSelfUpdate(); + } + catch(Exception ex) + { + Console.WriteLine(ex.Message); + Console.ReadKey(); + } + break; + case "selfupdate": + try + { + SelfUpdateRoutine(); + } + catch(Exception ex) + { + Console.WriteLine(ex.Message); + Console.ReadKey(); + } + break; + default: + PrintHelp(); + break; + } + } + + static ResultCode ApplyRoutine(string[] args, string updateDirectory) + { + if (args.Length != 2) + { + PrintHelp(); + return ResultCode.IncorrectArguments; + } + + Console.WriteLine("Checking if folder is writable."); + + int retry = 0; + while(IsAnyFileLocked(Directory.GetCurrentDirectory())) + { + Thread.Sleep(500); + + Console.WriteLine("Some files are locked, please close them before updating."); + if(++retry >= 10) return ResultCode.FileLock; + } + + string zipPath = Path.Combine(updateDirectory, $"{args[1]}.zip"); + string hashPath = Path.Combine(updateDirectory, $"{args[1]}.sha256"); + + if (!VerifyFile(zipPath, hashPath)) + { + Console.WriteLine("Patch files are invalid."); + return ResultCode.VerifyFailed; + } + + Console.WriteLine("Patch has been verified."); + Console.WriteLine("Extracting archive..."); + + var extractPath = Path.Combine(updateDirectory, Path.GetFileNameWithoutExtension(zipPath)); + if (Directory.Exists(extractPath)) + { + Console.WriteLine($"Folder '{extractPath}' already exists; Press Y to delete and continue."); + if (Console.ReadKey().Key == ConsoleKey.Y) + { + Console.WriteLine(); + Console.WriteLine("Deleting folder..."); + Directory.Delete(extractPath, true); + } + else + { + Console.WriteLine("Can't extract archive."); + + return ResultCode.OutputDirExists; + } + } + + Directory.CreateDirectory(extractPath); + ZipFile.ExtractToDirectory(zipPath, extractPath); + + Console.WriteLine("Verifying output"); + + var contentPath = GetContentPath(extractPath); + if (string.IsNullOrWhiteSpace(contentPath)) + { + Console.WriteLine("Extracted files are not in expected format."); + + return ResultCode.UnsupportedOutput; + } + + Console.WriteLine("Applying files..."); + + CopyFilesToDestination(contentPath, Directory.GetCurrentDirectory()); + + Console.WriteLine("Cleaning up"); + + Directory.Delete(extractPath, true); + + DeleteUnsupportedFiles(Directory.GetCurrentDirectory()); + + return ResultCode.Success; + } + + static void TriggerSelfUpdate() + { + var exeLocation = Path.Combine(Directory.GetCurrentDirectory(), UpdateFolder, CurrentProcess + ".exe"); + if (!File.Exists(exeLocation)) + { + Console.WriteLine("Self Update not found."); + return; + } + + Console.WriteLine("Start self updating"); + + string arguments = "SelfUpdate"; + + Process.Start(new ProcessStartInfo + { + FileName = exeLocation, + Arguments = arguments, + UseShellExecute = true, + RedirectStandardOutput = false, + RedirectStandardError = false, + CreateNoWindow = false + }); + } + + static void SelfUpdateRoutine() + { + var currentDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + if (currentDir == null) return; + + var parentDir = Directory.GetParent(currentDir); + if (parentDir == null) return; + + var targetExecutable = Path.Combine(parentDir.FullName, CurrentProcess + ".exe"); + + if(!File.Exists(targetExecutable)) return; // Are we running in an unexpected directory? + + int retry = 0; + while (IsFileLocked(targetExecutable)) + { + Thread.Sleep(500); + + if (++retry >= 10) + { + Console.WriteLine("Can't replace file as it's opened by another program."); + Console.ReadKey(); + return; + } + } + + var files = Directory.GetFiles(currentDir, $"{CurrentProcess}*", SearchOption.AllDirectories); + foreach(var file in files) + { + File.Copy(file, Path.Combine(parentDir.FullName, Path.GetFileName(file)), true); + } + } + + static string GetSHA256Hash(string filePath) + { + using SHA256 sha256 = SHA256.Create(); + using FileStream stream = File.OpenRead(filePath); + + byte[] hashBytes = sha256.ComputeHash(stream); + + var hashBuilder = new StringBuilder(); + for (int i = 0; i < hashBytes.Length; ++i) + { + hashBuilder.Append(hashBytes[i].ToString("x2")); + } + + return hashBuilder.ToString(); + } + + static string ReadSHA256FromHashFile(string hashFile) + { + var fileContent = File.ReadAllLines(hashFile); + if (fileContent.Length == 0) return string.Empty; + + return fileContent[0].Split(' ')[0]; + } + + static bool VerifyFile(string zipFile, string hashFile) + { + var actualHash = GetSHA256Hash(zipFile); + var validationHash = ReadSHA256FromHashFile(hashFile); + + return string.Compare(actualHash, validationHash, true) == 0; + } + + static bool IsFileLocked(string filePath) + { + if (!File.Exists(filePath)) return false; + + try + { + using FileStream stream = File.Open(filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + return false; + } + catch (IOException) + { + return true; + } + } + + static bool IsAnyFileLocked(string dir) + { + bool result = false; + foreach (var file in Directory.GetFiles(dir, "*", SearchOption.AllDirectories)) + { + if (IsFileLocked(file)) + { + if (Path.GetDirectoryName(file) != Directory.GetCurrentDirectory()) + { + Console.WriteLine($"File locked: {file}"); + result = true; + } + } + } + + return result; + } + + static string GetContentPath(string output) + { + var dirs = Directory.GetDirectories(output, "*", SearchOption.TopDirectoryOnly); + if (dirs.Length != 1) return string.Empty; + var files = Directory.GetFiles(dirs[0], "*", SearchOption.TopDirectoryOnly); + + return files.Any(f => f.EndsWith("License.txt")) + ? dirs[0] + : string.Empty; + } + + static string GetRelativePath(string basePath, string filePath) + { + var baseUri = new Uri(basePath.TrimEnd('\\') + '\\'); + var fileUri = new Uri(filePath); + + var relativeUri = baseUri.MakeRelativeUri(fileUri) + .ToString() + .Replace('/', Path.DirectorySeparatorChar); + + return Uri.UnescapeDataString(relativeUri); + } + + static void CopyFilesToDestination(string srcPath, string destPath) + { + var files = Directory.GetFiles(srcPath, "*", SearchOption.AllDirectories); + + foreach (var file in files) + { + bool isSelf = Path.GetFileName(file) + .StartsWith(CurrentProcess, StringComparison.InvariantCultureIgnoreCase); + + string relativePath = GetRelativePath(srcPath, file); + string destFilePath = isSelf + ? Path.Combine(destPath, UpdateFolder, relativePath) + : Path.Combine(destPath, relativePath); + + var path = Path.GetDirectoryName(destFilePath) ?? throw new IOException("Can't determine directory name."); + Directory.CreateDirectory(path); + + if (File.Exists(destFilePath)) + { + File.Delete(destFilePath); + } + + File.Move(file, destFilePath); + } + } + + static void DeleteUnsupportedFiles(string path) + { + // Not implemented. + +/* + var files = Directory.GetFiles(path, "*", SearchOption.AllDirectories); + foreach (var file in files) + { + } +*/ + } + } +} diff --git a/CUETools.sln b/CUETools.sln index 859ac8fa..8f9e9a3b 100644 --- a/CUETools.sln +++ b/CUETools.sln @@ -197,9 +197,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CUETools.Codecs.ffmpeg", "C EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CUETools.ChaptersToCue", "CUETools.ChaptersToCue\CUETools.ChaptersToCue.csproj", "{6ADBBF4B-AD3A-4782-A694-18662196780B}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CUETools.Interop", "CUETools.Interop\CUETools.Interop.csproj", "{B5B719B2-A2A6-4BC5-80C2-6CE79658E318}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CUERipper.Avalonia", "CUERipper.Avalonia\CUERipper.Avalonia.csproj", "{E29C97F7-21E0-46F5-89D3-0A822249C57A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CUERipper.Avalonia.Tests", "CUERipper.Avalonia.Tests\CUERipper.Avalonia.Tests.csproj", "{EEA181FE-77BD-412E-99A4-FBF1D0C5A126}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Freedb", "Freedb\Freedb.csproj", "{4D42A992-952B-41F0-A594-C98359865B0A}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CUETools.Interop", "CUETools.Interop\CUETools.Interop.csproj", "{B5B719B2-A2A6-4BC5-80C2-6CE79658E318}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CUETools.Updater", "CUETools.Updater\CUETools.Updater.csproj", "{75A98F71-6735-4E7C-8C9D-B205BC13D134}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -767,18 +773,6 @@ Global {6ADBBF4B-AD3A-4782-A694-18662196780B}.Release|Win32.Build.0 = Release|Any CPU {6ADBBF4B-AD3A-4782-A694-18662196780B}.Release|x64.ActiveCfg = Release|Any CPU {6ADBBF4B-AD3A-4782-A694-18662196780B}.Release|x64.Build.0 = Release|Any CPU - {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|Win32.ActiveCfg = Debug|Any CPU - {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|Win32.Build.0 = Debug|Any CPU - {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|x64.ActiveCfg = Debug|Any CPU - {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|x64.Build.0 = Debug|Any CPU - {4D42A992-952B-41F0-A594-C98359865B0A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4D42A992-952B-41F0-A594-C98359865B0A}.Release|Any CPU.Build.0 = Release|Any CPU - {4D42A992-952B-41F0-A594-C98359865B0A}.Release|Win32.ActiveCfg = Release|Any CPU - {4D42A992-952B-41F0-A594-C98359865B0A}.Release|Win32.Build.0 = Release|Any CPU - {4D42A992-952B-41F0-A594-C98359865B0A}.Release|x64.ActiveCfg = Release|Any CPU - {4D42A992-952B-41F0-A594-C98359865B0A}.Release|x64.Build.0 = Release|Any CPU {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Debug|Any CPU.Build.0 = Debug|Any CPU {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Debug|Win32.ActiveCfg = Debug|Any CPU @@ -791,6 +785,54 @@ Global {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Release|Win32.Build.0 = Release|Any CPU {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Release|x64.ActiveCfg = Release|Any CPU {B5B719B2-A2A6-4BC5-80C2-6CE79658E318}.Release|x64.Build.0 = Release|Any CPU + {E29C97F7-21E0-46F5-89D3-0A822249C57A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E29C97F7-21E0-46F5-89D3-0A822249C57A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E29C97F7-21E0-46F5-89D3-0A822249C57A}.Debug|Win32.ActiveCfg = Debug|Any CPU + {E29C97F7-21E0-46F5-89D3-0A822249C57A}.Debug|Win32.Build.0 = Debug|Any CPU + {E29C97F7-21E0-46F5-89D3-0A822249C57A}.Debug|x64.ActiveCfg = Debug|Any CPU + {E29C97F7-21E0-46F5-89D3-0A822249C57A}.Debug|x64.Build.0 = Debug|Any CPU + {E29C97F7-21E0-46F5-89D3-0A822249C57A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E29C97F7-21E0-46F5-89D3-0A822249C57A}.Release|Any CPU.Build.0 = Release|Any CPU + {E29C97F7-21E0-46F5-89D3-0A822249C57A}.Release|Win32.ActiveCfg = Release|Any CPU + {E29C97F7-21E0-46F5-89D3-0A822249C57A}.Release|Win32.Build.0 = Release|Any CPU + {E29C97F7-21E0-46F5-89D3-0A822249C57A}.Release|x64.ActiveCfg = Release|Any CPU + {E29C97F7-21E0-46F5-89D3-0A822249C57A}.Release|x64.Build.0 = Release|Any CPU + {EEA181FE-77BD-412E-99A4-FBF1D0C5A126}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EEA181FE-77BD-412E-99A4-FBF1D0C5A126}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EEA181FE-77BD-412E-99A4-FBF1D0C5A126}.Debug|Win32.ActiveCfg = Debug|Any CPU + {EEA181FE-77BD-412E-99A4-FBF1D0C5A126}.Debug|Win32.Build.0 = Debug|Any CPU + {EEA181FE-77BD-412E-99A4-FBF1D0C5A126}.Debug|x64.ActiveCfg = Debug|Any CPU + {EEA181FE-77BD-412E-99A4-FBF1D0C5A126}.Debug|x64.Build.0 = Debug|Any CPU + {EEA181FE-77BD-412E-99A4-FBF1D0C5A126}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EEA181FE-77BD-412E-99A4-FBF1D0C5A126}.Release|Any CPU.Build.0 = Release|Any CPU + {EEA181FE-77BD-412E-99A4-FBF1D0C5A126}.Release|Win32.ActiveCfg = Release|Any CPU + {EEA181FE-77BD-412E-99A4-FBF1D0C5A126}.Release|Win32.Build.0 = Release|Any CPU + {EEA181FE-77BD-412E-99A4-FBF1D0C5A126}.Release|x64.ActiveCfg = Release|Any CPU + {EEA181FE-77BD-412E-99A4-FBF1D0C5A126}.Release|x64.Build.0 = Release|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|Win32.ActiveCfg = Debug|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|Win32.Build.0 = Debug|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|x64.ActiveCfg = Debug|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Debug|x64.Build.0 = Debug|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Release|Any CPU.Build.0 = Release|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Release|Win32.ActiveCfg = Release|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Release|Win32.Build.0 = Release|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Release|x64.ActiveCfg = Release|Any CPU + {4D42A992-952B-41F0-A594-C98359865B0A}.Release|x64.Build.0 = Release|Any CPU + {75A98F71-6735-4E7C-8C9D-B205BC13D134}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {75A98F71-6735-4E7C-8C9D-B205BC13D134}.Debug|Any CPU.Build.0 = Debug|Any CPU + {75A98F71-6735-4E7C-8C9D-B205BC13D134}.Debug|Win32.ActiveCfg = Debug|Any CPU + {75A98F71-6735-4E7C-8C9D-B205BC13D134}.Debug|Win32.Build.0 = Debug|Any CPU + {75A98F71-6735-4E7C-8C9D-B205BC13D134}.Debug|x64.ActiveCfg = Debug|Any CPU + {75A98F71-6735-4E7C-8C9D-B205BC13D134}.Debug|x64.Build.0 = Debug|Any CPU + {75A98F71-6735-4E7C-8C9D-B205BC13D134}.Release|Any CPU.ActiveCfg = Release|Any CPU + {75A98F71-6735-4E7C-8C9D-B205BC13D134}.Release|Any CPU.Build.0 = Release|Any CPU + {75A98F71-6735-4E7C-8C9D-B205BC13D134}.Release|Win32.ActiveCfg = Release|Any CPU + {75A98F71-6735-4E7C-8C9D-B205BC13D134}.Release|Win32.Build.0 = Release|Any CPU + {75A98F71-6735-4E7C-8C9D-B205BC13D134}.Release|x64.ActiveCfg = Release|Any CPU + {75A98F71-6735-4E7C-8C9D-B205BC13D134}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -852,8 +894,11 @@ Global {8FE405A0-E837-4088-88AD-B63652E34790} = {93B7AE1D-DEF6-4A04-A222-5CDE09DF262D} {A2C09014-C430-4E58-A323-306CCDF313C5} = {93B7AE1D-DEF6-4A04-A222-5CDE09DF262D} {6ADBBF4B-AD3A-4782-A694-18662196780B} = {4B59E09C-A51F-4B80-91BE-987904DCEF7D} - {4D42A992-952B-41F0-A594-C98359865B0A} = {7E402406-7E51-4F0D-8209-60824C1CD6E8} {B5B719B2-A2A6-4BC5-80C2-6CE79658E318} = {86BBE3FC-E4E5-4190-B675-C6745EAF4E64} + {E29C97F7-21E0-46F5-89D3-0A822249C57A} = {BC0C1801-0212-4ECC-92D5-8F2D6F69E888} + {EEA181FE-77BD-412E-99A4-FBF1D0C5A126} = {BC0C1801-0212-4ECC-92D5-8F2D6F69E888} + {4D42A992-952B-41F0-A594-C98359865B0A} = {7E402406-7E51-4F0D-8209-60824C1CD6E8} + {75A98F71-6735-4E7C-8C9D-B205BC13D134} = {4B59E09C-A51F-4B80-91BE-987904DCEF7D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C634D169-5814-4203-94B6-6A11371DDA95} diff --git a/collect_files.bat b/collect_files.bat index f65e179e..385ad040 100644 --- a/collect_files.bat +++ b/collect_files.bat @@ -137,4 +137,171 @@ REM CUETools.LossyWAV.exe was not in 2.1.7 release, added xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.Codecs.LossyWAV.dll %release_dir% xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.LossyWAV.exe %release_dir% +REM CUERipper Avalonia +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.Base.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.Controls.DataGrid.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.Controls.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.DesignerSupport.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.Desktop.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.Dialogs.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.Fonts.Inter.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.FreeDesktop.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.Markup.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.Markup.Xaml.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.Metal.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.MicroCom.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.Native.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.OpenGL.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.Remote.Protocol.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.Skia.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.Themes.Fluent.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.Vulkan.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.Win32.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Avalonia.X11.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\av_libglesv2.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CommunityToolkit.Mvvm.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUERipper.Avalonia.exe %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUERipper.Avalonia.exe.config %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.Interop.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\DeviceId.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Freedb.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\HarfBuzzSharp.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\libHarfBuzzSharp.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\libSkiaSharp.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\MicroCom.Runtime.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Microsoft.Bcl.AsyncInterfaces.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Microsoft.Extensions.DependencyInjection.Abstractions.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Microsoft.Extensions.DependencyInjection.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Microsoft.Extensions.Localization.Abstractions.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Microsoft.Extensions.Localization.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Microsoft.Extensions.Logging.Abstractions.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Microsoft.Extensions.Logging.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Microsoft.Extensions.Options.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Microsoft.Extensions.Primitives.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Microsoft.Win32.Primitives.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\netstandard.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Newtonsoft.Json.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Serilog.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Serilog.Extensions.Logging.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Serilog.Sinks.File.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\SkiaSharp.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.AppContext.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Buffers.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Collections.Concurrent.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Collections.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Collections.Immutable.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Collections.NonGeneric.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Collections.Specialized.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.ComponentModel.Annotations.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.ComponentModel.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.ComponentModel.EventBasedAsync.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.ComponentModel.Primitives.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.ComponentModel.TypeConverter.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Console.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Data.Common.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Diagnostics.Contracts.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Diagnostics.Debug.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Diagnostics.DiagnosticSource.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Diagnostics.FileVersionInfo.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Diagnostics.Process.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Diagnostics.StackTrace.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Diagnostics.TextWriterTraceListener.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Diagnostics.Tools.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Diagnostics.TraceSource.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Diagnostics.Tracing.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Drawing.Primitives.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Dynamic.Runtime.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Globalization.Calendars.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Globalization.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Globalization.Extensions.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.IO.Compression.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.IO.Compression.ZipFile.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.IO.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.IO.FileSystem.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.IO.FileSystem.DriveInfo.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.IO.FileSystem.Primitives.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.IO.FileSystem.Watcher.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.IO.IsolatedStorage.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.IO.MemoryMappedFiles.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.IO.Pipelines.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.IO.Pipes.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.IO.UnmanagedMemoryStream.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Linq.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Linq.Expressions.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Linq.Parallel.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Linq.Queryable.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Memory.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Net.Http.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Net.NameResolution.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Net.NetworkInformation.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Net.Ping.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Net.Primitives.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Net.Requests.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Net.Security.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Net.Sockets.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Net.WebHeaderCollection.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Net.WebSockets.Client.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Net.WebSockets.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Numerics.Vectors.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.ObjectModel.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Reflection.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Reflection.Extensions.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Reflection.Primitives.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Resources.Reader.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Resources.ResourceManager.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Resources.Writer.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Runtime.CompilerServices.Unsafe.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Runtime.CompilerServices.VisualC.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Runtime.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Runtime.Extensions.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Runtime.Handles.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Runtime.InteropServices.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Runtime.InteropServices.RuntimeInformation.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Runtime.Numerics.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Runtime.Serialization.Formatters.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Runtime.Serialization.Json.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Runtime.Serialization.Primitives.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Runtime.Serialization.Xml.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Security.Claims.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Security.Cryptography.Algorithms.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Security.Cryptography.Csp.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Security.Cryptography.Encoding.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Security.Cryptography.Primitives.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Security.Cryptography.X509Certificates.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Security.Principal.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Security.SecureString.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Text.Encoding.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Text.Encoding.Extensions.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Text.RegularExpressions.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Threading.Channels.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Threading.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Threading.Overlapped.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Threading.Tasks.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Threading.Tasks.Extensions.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Threading.Tasks.Parallel.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Threading.Thread.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Threading.ThreadPool.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Threading.Timer.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.ValueTuple.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Xml.ReaderWriter.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Xml.XDocument.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Xml.XmlDocument.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Xml.XmlSerializer.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Xml.XPath.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\System.Xml.XPath.XDocument.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\TagLibSharp.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Tmds.DBus.Protocol.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\arm64\libHarfBuzzSharp.dll %release_dir%\arm64\ +xcopy /Y /D %base_dir%\bin\Release\net47\arm64\libSkiaSharp.dll %release_dir%\arm64\ +xcopy /Y /D %base_dir%\bin\Release\net47\de-DE\Bwg.Scsi.resources.dll %release_dir%\de-DE\ +xcopy /Y /D %base_dir%\bin\Release\net47\de-DE\CUETools.Ripper.SCSI.resources.dll %release_dir%\de-DE\ +xcopy /Y /D %base_dir%\bin\Release\net47\nl-NL\CUERipper.Avalonia.resources.dll %release_dir%\nl-NL\ +xcopy /Y /D %base_dir%\bin\Release\net47\ru-RU\Bwg.Scsi.resources.dll %release_dir%\ru-RU\ +xcopy /Y /D %base_dir%\bin\Release\net47\ru-RU\CUETools.Ripper.SCSI.resources.dll %release_dir%\ru-RU\ +xcopy /Y /D %base_dir%\bin\Release\net47\x64\libHarfBuzzSharp.dll %release_dir%\x64\ +xcopy /Y /D %base_dir%\bin\Release\net47\x64\libSkiaSharp.dll %release_dir%\x64\ +xcopy /Y /D %base_dir%\bin\Release\net47\x86\libHarfBuzzSharp.dll %release_dir%\x86\ +xcopy /Y /D %base_dir%\bin\Release\net47\x86\libSkiaSharp.dll %release_dir%\x86\ + popd diff --git a/collect_files_lite.bat b/collect_files_lite.bat new file mode 100644 index 00000000..04b27e6e --- /dev/null +++ b/collect_files_lite.bat @@ -0,0 +1,140 @@ +@ECHO OFF +REM This script collects the built .exe, .dll etc. files required for running CUETools +REM Wolfgang Stöggl , 2020-2025. + +REM The script is located in the subdirectory CUETools +echo %~dp0 +pushd %~dp0 +SET base_dir=. + +REM Get version of CUETools +for /f "tokens=7 delims= " %%a in ('find "CUEToolsVersion =" %base_dir%\CUETools.Processor\CUESheet.cs') do set PRODUCTVER=%%a +REM echo %PRODUCTVER% +REM "2.1.7"; + +REM Remove double quotes and semicolon +set PRODUCTVER=%PRODUCTVER:"=% +set PRODUCTVER=%PRODUCTVER:;=% +echo CUETools version: %PRODUCTVER% + +SET release_dir=%base_dir%\bin\Release\CUETools.Lite_%PRODUCTVER% + +mkdir %release_dir% + +REM use xcopy instead of copy. xcopy creates directories if necessary and outputs the copied file +REM /Y Suppresses prompting to confirm that you want to overwrite an existing destination file. +REM /D xcopy copies all Source files that are newer than existing Destination files + +REM 32 files from net47 +xcopy /Y /D %base_dir%\bin\Release\net47\BluTools.exe %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\BluTools.exe.config %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUEControls.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUERipper.exe %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUERipper.exe.config %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.exe %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.AccurateRip.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.ALACEnc.exe %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.ALACEnc.exe.config %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.ARCUE.exe %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.CDImage.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.Codecs.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.Compression.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.Converter.exe %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.CTDB.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.CTDB.Types.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.eac3to.exe %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.eac3to.exe.config %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.exe.config %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.FLACCL.cmd.exe %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.FLACCL.cmd.exe.config %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.Flake.exe %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.Flake.exe.config %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.Parity.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.Processor.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.Ripper.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.Ripper.Console.exe %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.Ripper.Console.exe.config %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\DeviceId.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\Freedb.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\ProgressODoom.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\TagLibSharp.dll %release_dir% + +xcopy /Y /D %base_dir%\License.txt %release_dir% +xcopy /Y /D %base_dir%\CUETools\user_profiles_enabled %release_dir% + +xcopy /Y /D %base_dir%\bin\Release\net47\de-DE\* %release_dir%\de-DE\ +xcopy /Y /D %base_dir%\bin\Release\net47\ru-RU\* %release_dir%\ru-RU\ + +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\Bwg.Hardware.dll %release_dir%\plugins\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\Bwg.Logging.dll %release_dir%\plugins\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\Bwg.Scsi.dll %release_dir%\plugins\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\CUETools.Codecs.ALAC.dll %release_dir%\plugins\ +REM No more win32\CUETools.Codecs.BDLPCM.dll and win32\CUETools.Codecs.BDLPCM.dll. Instead: CUETools.Codecs.MPEG.dll +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\CUETools.Codecs.MPEG.dll %release_dir%\plugins\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\CUETools.Codecs.FLACCL.dll %release_dir%\plugins\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\CUETools.Codecs.Flake.dll %release_dir%\plugins\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\CUETools.Codecs.HDCD.dll %release_dir%\plugins\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\CUETools.Codecs.libFLAC.dll %release_dir%\plugins\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\CUETools.Codecs.libmp3lame.dll %release_dir%\plugins\ +REM No more win32\CUETools.Codecs.WavPack.dll and x64\CUETools.Codecs.WavPack.dll. Instead: CUETools.Codecs.libwavpack.dll +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\CUETools.Codecs.libwavpack.dll %release_dir%\plugins\ +REM Nor more win32\CUETools.Codecs.APE.dll and x64\CUETools.Codecs.APE.dll. Instead: CUETools.Codecs.MACLib.dll +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\CUETools.Codecs.MACLib.dll %release_dir%\plugins\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\CUETools.Codecs.WMA.dll %release_dir%\plugins\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\CUETools.Compression.Zip.dll %release_dir%\plugins\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\CUETools.Ripper.SCSI.dll %release_dir%\plugins\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\FFmpeg.AutoGen.dll %release_dir%\plugins\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\flac.cl %release_dir%\plugins\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\OpenCLNet.dll %release_dir%\plugins\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\WindowsMediaLib.dll %release_dir%\plugins\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\CUETools.Codecs.ffmpegdll.dll %release_dir%\plugins\ + +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\win32\CUETools.Codecs.TTA.dll %release_dir%\plugins\win32\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\win32\CUETools.Compression.Rar.dll %release_dir%\plugins\win32\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\x64\CUETools.Codecs.TTA.dll %release_dir%\plugins\x64\ +REM CUETools.Compression.Rar.dll is the same in the win32 and x64 directory of 2.1.7. Copy from win32 +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\win32\CUETools.Compression.Rar.dll %release_dir%\plugins\x64\ + +REM plugins translation files +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\de-DE\* %release_dir%\plugins\de-DE\ +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\ru-RU\* %release_dir%\plugins\ru-RU\ + +REM ThirdParty +xcopy /Y /D %base_dir%\ThirdParty\ICSharpCode.SharpZipLib.dll %release_dir%\plugins\ + +REM ThirdParty\Win32 plugins +xcopy /Y /D %base_dir%\ThirdParty\Win32\hdcd.dll %release_dir%\plugins\win32\ +xcopy /Y /D %base_dir%\ThirdParty\Win32\libFLAC_dynamic.dll %release_dir%\plugins\win32\ +xcopy /Y /D %base_dir%\ThirdParty\Win32\libmp3lame.dll %release_dir%\plugins\win32\ +xcopy /Y /D %base_dir%\ThirdParty\Win32\MACLibDll.dll %release_dir%\plugins\win32\ +xcopy /Y /D %base_dir%\ThirdParty\Win32\unrar.dll %release_dir%\plugins\win32\ +xcopy /Y /D %base_dir%\ThirdParty\Win32\wavpackdll.dll %release_dir%\plugins\win32\ + +REM ThirdParty\x64 plugins +xcopy /Y /D %base_dir%\ThirdParty\x64\hdcd.dll %release_dir%\plugins\x64\ +xcopy /Y /D %base_dir%\ThirdParty\x64\libFLAC_dynamic.dll %release_dir%\plugins\x64\ +xcopy /Y /D %base_dir%\ThirdParty\x64\libmp3lame.dll %release_dir%\plugins\x64\ +xcopy /Y /D %base_dir%\ThirdParty\x64\MACLibDll.dll %release_dir%\plugins\x64\ +xcopy /Y /D %base_dir%\ThirdParty\x64\Unrar.dll %release_dir%\plugins\x64\ +xcopy /Y /D %base_dir%\ThirdParty\x64\wavpackdll.dll %release_dir%\plugins\x64\ + +REM EAC Plugin +REM CUETools.CTDB.Types.dll is also required now +xcopy /Y /D %base_dir%\bin\Release\interop\EAC\CUETools.AccurateRip.dll %release_dir%\interop\EAC\ +xcopy /Y /D %base_dir%\bin\Release\interop\EAC\CUETools.CDImage.dll %release_dir%\interop\EAC\ +xcopy /Y /D %base_dir%\bin\Release\interop\EAC\CUETools.Codecs.dll %release_dir%\interop\EAC\ +xcopy /Y /D %base_dir%\bin\Release\interop\EAC\CUETools.CTDB.dll %release_dir%\interop\EAC\ +xcopy /Y /D %base_dir%\bin\Release\interop\EAC\CUETools.CTDB.EACPlugin.dll %release_dir%\interop\EAC\ +xcopy /Y /D %base_dir%\bin\Release\interop\EAC\CUETools.CTDB.Types.dll %release_dir%\interop\EAC\ +xcopy /Y /D %base_dir%\bin\Release\interop\EAC\CUETools.Parity.dll %release_dir%\interop\EAC\ +xcopy /Y /D %base_dir%\bin\Release\interop\EAC\Newtonsoft.Json.dll %release_dir%\interop\EAC\ + +REM required for running CUERipper: +REM Newtonsoft.Json.dll +xcopy /Y /D %base_dir%\bin\Release\net47\plugins\Newtonsoft.Json.dll %release_dir% + +REM CUETools.LossyWAV.exe was not in 2.1.7 release, added +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.Codecs.LossyWAV.dll %release_dir% +xcopy /Y /D %base_dir%\bin\Release\net47\CUETools.LossyWAV.exe %release_dir% + +popd From 10fb6d9e4d78daf93f1e9172a779b7deb59bbbcf Mon Sep 17 00:00:00 2001 From: UnknownException <25252012+UnknownException@users.noreply.github.com> Date: Sun, 9 Mar 2025 07:46:24 +0100 Subject: [PATCH 04/25] Enable Linux-specific code for drive detection Enabled the Linux-specific drive detection in CUERipper.Avalonia. Added a new release artifact, postfixed with 'Lite', which excludes CUERipper.Avalonia and the Avalonia runtime. --- .github/workflows/release-windows.yml | 7 ++++++- CUERipper.Avalonia/App.axaml.cs | 2 -- CUERipper.Avalonia/CUERipper.Avalonia.csproj | 1 + CUERipper.Avalonia/Constants.cs | 2 +- .../Services/LinuxDriveNotificationService.cs | 4 +--- CUETools.Processor/CUESheet.cs | 2 +- collect_files.bat | 2 +- collect_files_lite.bat | 2 +- git_init.bat | 7 +++++++ packaging.ps1 | 14 ++++++++++++++ 10 files changed, 33 insertions(+), 10 deletions(-) create mode 100644 git_init.bat diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 561eff74..a5e8c5b3 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -53,12 +53,15 @@ jobs: - name: Collect files run: | collect_files.bat + - name: Collect files (Lite) + run: | + collect_files_lite.bat - name: Package files run: pwsh -File ./packaging.ps1 - name: Get CUETools Version id: get_version run: | - for /f "tokens=7 delims= " %%a in ('find "CUEToolsVersion =" .\CUETools.Processor\CUESheet.cs') do set PRODUCTVER=%%a + for /f "tokens=6 delims= " %%a in ('find "CUEToolsVersion =" .\CUETools.Processor\CUESheet.cs') do set PRODUCTVER=%%a set PRODUCTVER=%PRODUCTVER:"=% set PRODUCTVER=%PRODUCTVER:;=% echo CUETools version: %PRODUCTVER% @@ -75,5 +78,7 @@ jobs: files: | CUETools_${{ env.CUETOOLS_VERSION }}.zip CUETools_${{ env.CUETOOLS_VERSION }}.zip.sha256 + CUETools.Lite_${{ env.CUETOOLS_VERSION }}.zip + CUETools.Lite_${{ env.CUETOOLS_VERSION }}.zip.sha256 CUETools.CTDB.EACPlugin.${{ env.CUETOOLS_VERSION }}.zip CUETools.CTDB.EACPlugin.${{ env.CUETOOLS_VERSION }}.zip.sha256 \ No newline at end of file diff --git a/CUERipper.Avalonia/App.axaml.cs b/CUERipper.Avalonia/App.axaml.cs index 5da99ec5..92c1133b 100644 --- a/CUERipper.Avalonia/App.axaml.cs +++ b/CUERipper.Avalonia/App.axaml.cs @@ -97,10 +97,8 @@ public void ConfigureServices(IServiceCollection services) if (OS.IsWindows()) services.AddSingleton(); -#if false else if(OS.IsLinux()) services.AddSingleton(); -#endif else services.AddSingleton(); diff --git a/CUERipper.Avalonia/CUERipper.Avalonia.csproj b/CUERipper.Avalonia/CUERipper.Avalonia.csproj index 759f648b..d05002d0 100644 --- a/CUERipper.Avalonia/CUERipper.Avalonia.csproj +++ b/CUERipper.Avalonia/CUERipper.Avalonia.csproj @@ -48,6 +48,7 @@ + diff --git a/CUERipper.Avalonia/Constants.cs b/CUERipper.Avalonia/Constants.cs index b7a31712..ee639756 100644 --- a/CUERipper.Avalonia/Constants.cs +++ b/CUERipper.Avalonia/Constants.cs @@ -42,7 +42,7 @@ public static class Constants ]; public const int MaxPathFormats = 10; // Based on the original CUERipper limit - public readonly static string ApplicationName = $"CUERipper.Avalonia {CUESheet.CUEToolsVersion}"; + public const string ApplicationName = $"CUERipper.Avalonia {CUESheet.CUEToolsVersion}"; public const string PathNoto = "avares://CUERipper.Avalonia/Assets/noto-emoji/32/"; public const string PathImageCache = "./CUERipper/.AlbumCache/"; diff --git a/CUERipper.Avalonia/Services/LinuxDriveNotificationService.cs b/CUERipper.Avalonia/Services/LinuxDriveNotificationService.cs index 69aef3cd..58288996 100644 --- a/CUERipper.Avalonia/Services/LinuxDriveNotificationService.cs +++ b/CUERipper.Avalonia/Services/LinuxDriveNotificationService.cs @@ -17,7 +17,6 @@ You should have received a copy of the GNU General Public License along */ #endregion -#if false // The implementation in NullDriveNotificationService doesn't function correctly on .NET 8 under Linux. // As a temporary solution, I've implemented a quick workaround to ensure the drive notification works. // Further investigation needed... @@ -153,5 +152,4 @@ public void Dispose() GC.SuppressFinalize(this); } } -} -#endif \ No newline at end of file +} \ No newline at end of file diff --git a/CUETools.Processor/CUESheet.cs b/CUETools.Processor/CUESheet.cs index 20754d70..1ad799ef 100644 --- a/CUETools.Processor/CUESheet.cs +++ b/CUETools.Processor/CUESheet.cs @@ -28,7 +28,7 @@ public class CUESheet { #region Fields - public readonly static string CUEToolsVersion = "2.2.6"; + public const string CUEToolsVersion = "2.2.6"; private bool _stop, _pause; private List _attributes; diff --git a/collect_files.bat b/collect_files.bat index 385ad040..320dd8fc 100644 --- a/collect_files.bat +++ b/collect_files.bat @@ -8,7 +8,7 @@ pushd %~dp0 SET base_dir=. REM Get version of CUETools -for /f "tokens=7 delims= " %%a in ('find "CUEToolsVersion =" %base_dir%\CUETools.Processor\CUESheet.cs') do set PRODUCTVER=%%a +for /f "tokens=6 delims= " %%a in ('find "CUEToolsVersion =" %base_dir%\CUETools.Processor\CUESheet.cs') do set PRODUCTVER=%%a REM echo %PRODUCTVER% REM "2.1.7"; diff --git a/collect_files_lite.bat b/collect_files_lite.bat index 04b27e6e..fb9cbece 100644 --- a/collect_files_lite.bat +++ b/collect_files_lite.bat @@ -8,7 +8,7 @@ pushd %~dp0 SET base_dir=. REM Get version of CUETools -for /f "tokens=7 delims= " %%a in ('find "CUEToolsVersion =" %base_dir%\CUETools.Processor\CUESheet.cs') do set PRODUCTVER=%%a +for /f "tokens=6 delims= " %%a in ('find "CUEToolsVersion =" %base_dir%\CUETools.Processor\CUESheet.cs') do set PRODUCTVER=%%a REM echo %PRODUCTVER% REM "2.1.7"; diff --git a/git_init.bat b/git_init.bat new file mode 100644 index 00000000..c51e877c --- /dev/null +++ b/git_init.bat @@ -0,0 +1,7 @@ +git submodule update --init --recursive +git apply --directory=ThirdParty/flac ThirdParty/submodule_flac_CUETools.patch --whitespace=nowarn +powershell -c "Expand-Archive ThirdParty/MAC_SDK/MAC_1086_SDK.zip -DestinationPath ThirdParty/MAC_SDK/" +git apply --directory=ThirdParty/MAC_SDK ThirdParty/ThirdParty_MAC_SDK_CUETools.patch +git apply --directory=ThirdParty/taglib-sharp ThirdParty/submodule_taglib-sharp_CUETools.patch +git apply --directory=ThirdParty/WavPack ThirdParty/submodule_WavPack_CUETools.patch +git apply --directory=ThirdParty/WindowsMediaLib ThirdParty/submodule_WindowsMediaLib_CUETools.patch \ No newline at end of file diff --git a/packaging.ps1 b/packaging.ps1 index 2d0be6d6..0d03b23c 100644 --- a/packaging.ps1 +++ b/packaging.ps1 @@ -15,6 +15,7 @@ Write-Output "Detected CUETools version: $version" $eacZip = "CUETools.CTDB.EACPlugin.$version.zip" $cuetoolsZip = "CUETools_$version.zip" +$cuetoolsLiteZip = "CUETools.Lite_$version.zip" Write-Output "Creating EAC Zip" Compress-Archive -Path bin/Release/$($cueToolsDir.Name)/interop/ -DestinationPath $eacZip -Force @@ -22,6 +23,14 @@ Compress-Archive -Path bin/Release/$($cueToolsDir.Name)/interop/ -DestinationPat Write-Output "Creating CUETools Zip" Compress-Archive -Path bin/Release/$($cueToolsDir.Name)/ -DestinationPath $cuetoolsZip -Force +$cuetoolsLiteFolder = "bin/Release/CUETools.Lite_$version/" +if (Test-Path $cuetoolsLiteFolder) { + Write-Output "Creating CUETools Lite Zip" + Compress-Archive -Path $cuetoolsLiteFolder -DestinationPath $cuetoolsLiteZip -Force +} else { + Write-Output "CUETools.Lite_$version not found, skip packaging." +} + # Generate a hashfile using the same format as previous releases Write-Output "Generating SHA256 hashes..." $eacHash = (Get-FileHash $eacZip -Algorithm SHA256).Hash.ToLower() @@ -30,4 +39,9 @@ $eacHash = (Get-FileHash $eacZip -Algorithm SHA256).Hash.ToLower() $cuetoolsHash = (Get-FileHash $cuetoolsZip -Algorithm SHA256).Hash.ToLower() "$cuetoolsHash *$cuetoolsZip" | Out-File -Encoding ASCII "$cuetoolsZip.sha256" +if (Test-Path $cuetoolsLiteFolder) { + $cuetoolsLiteHash = (Get-FileHash $cuetoolsLiteZip -Algorithm SHA256).Hash.ToLower() + "$cuetoolsLiteHash *$cuetoolsLiteZip" | Out-File -Encoding ASCII "$cuetoolsLiteZip.sha256" +} + Write-Output "Packaging complete." \ No newline at end of file From 922ae55bc2d2868ac27930bd39c6f151dc1c3b53 Mon Sep 17 00:00:00 2001 From: UnknownException <25252012+UnknownException@users.noreply.github.com> Date: Sun, 9 Mar 2025 07:58:34 +0100 Subject: [PATCH 05/25] Update CHANGELOG.md --- CHANGELOG.md | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dfa4c0d..0431e8f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -Release date: 2024-06-28 +Release date: 2025-03-09 **Version 2.2.6 Prerequisites** - Microsoft .NET Framework 4.7 - preinstalled in Windows 10 (since version 1703) @@ -7,25 +7,18 @@ Release date: 2024-06-28 Further information concerning installation: http://cue.tools/wiki/CUETools_Download -**CUETools 2.2.6 Changelog** +**CUETools 2.2.* Changelog** -- Update MAC_SDK from 10.37 to 10.74 -- EACPlugin: Allow coverart search to be stopped -- Fix C2 mode for further drives: - LG GH24NSD5, LITEON DH-20A4P -- Preserve encoding of localized EAC log files -- Add setting for UTF-8-BOM - settings.txt: WriteUTF8BOM - The setting is enabled by default, to preserve previous behavior -- CUETools, Correct filenames: Support UTF-8 -- CUERipper: Fix incorrect TOC entry of first track -- CUERipper: Add setting to force ReadCDCommand - settings.txt: 0 (ReadCdBEh), 1 (ReadCdD8h), 2 (Unknown/AutoDetect) - Default: 2 -- Add WavPack 5.7.0 encoder multithreading support -- Update WavPack from 5.6.0 to 5.7.0 -- Avoid short HTOA files -- CUERipper: Detect too large album art earlier +CUERipper UI Reimplemented in Avalonia +A brand-new application with additional functionalities: +- Album cover selector – Choose which album cover to embed. +- Output path management – Customize where ripped files are saved. +- Multi-encoding – Encode tracks in multiple formats simultaneously. +- Automatic ripping – Enables bulk ripping with no user input unless an error is detected. +- Repair functionality – Same as in CUETools, for fixing errors in a rip. +- Minimal native Linux support – Early compatibility for Linux users (no automated builds yet). +- Track progress – Displays per-track ripping progress. +- In-app updater – Update the application directly via GitHub, without manual downloads. **Links to ffmpeg dlls (from [v2.2.5](https://github.com/gchudov/cuetools.net/releases/tag/v2.2.5)):** - [ffmpeg_6.1_dlls_win32.zip](https://github.com/gchudov/cuetools.net/releases/download/v2.2.5/ffmpeg_6.1_dlls_win32.zip) From 0b4cbd9ad3abe24b7ac1dd6a82c550af3b5a89b3 Mon Sep 17 00:00:00 2001 From: UnknownException <25252012+UnknownException@users.noreply.github.com> Date: Sun, 9 Mar 2025 19:42:20 +0100 Subject: [PATCH 06/25] Minor accessibility improvements Set 'AutomationProperties.Name' for various components. Added a warning sound when a message box opens (.NET 4.7 only). Fixed a bug that allowed tabbing to components outside of the visible area. Added a warning before closing the application while ripping. --- CHANGELOG.md | 2 +- .../Resources/Language.nl-NL.resx | 12 ++++ CUERipper.Avalonia/Resources/Language.resx | 12 ++++ CUERipper.Avalonia/Views/MainWindow.axaml | 71 ++++++++++++++----- CUERipper.Avalonia/Views/MainWindow.axaml.cs | 38 ++++++++-- CUERipper.Avalonia/Views/MessageBox.axaml | 16 +++-- CUERipper.Avalonia/Views/MessageBox.axaml.cs | 14 ++++ 7 files changed, 134 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0431e8f3..600b85a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Release date: 2025-03-09 Further information concerning installation: http://cue.tools/wiki/CUETools_Download -**CUETools 2.2.* Changelog** +**CUETools 2.2.- Changelog** CUERipper UI Reimplemented in Avalonia A brand-new application with additional functionalities: diff --git a/CUERipper.Avalonia/Resources/Language.nl-NL.resx b/CUERipper.Avalonia/Resources/Language.nl-NL.resx index 1ea190c1..7c9ee590 100644 --- a/CUERipper.Avalonia/Resources/Language.nl-NL.resx +++ b/CUERipper.Avalonia/Resources/Language.nl-NL.resx @@ -264,4 +264,16 @@ (MultiEncodingNotLossless) Eerste coderingsmethode moet verliesloos zijn bij het gebruik van meerdere coderingen. + + De applicatie is nog bezig met rippen. Wil je het proces stoppen? + + + Kan de applicatie niet sluiten + + + De gekozen map is niet leeg. Deze applicatie kan de bestaande inhoud wijzigen of verwijderen. Wil je doorgaan? + + + De map bestaat al + \ No newline at end of file diff --git a/CUERipper.Avalonia/Resources/Language.resx b/CUERipper.Avalonia/Resources/Language.resx index 790021a2..d9761b61 100644 --- a/CUERipper.Avalonia/Resources/Language.resx +++ b/CUERipper.Avalonia/Resources/Language.resx @@ -264,4 +264,16 @@ (MultiEncodingNotLossless) First encoder must be lossless when using multiple encoders. + + The application is still ripping. Do you want to cancel the process? + + + Can't close the application + + + The destination folder is not empty. This application will modify its contents by adding and removing files. Do you want to continue? + + + The directory already exists + \ No newline at end of file diff --git a/CUERipper.Avalonia/Views/MainWindow.axaml b/CUERipper.Avalonia/Views/MainWindow.axaml index 54f6f61b..d2ac2885 100644 --- a/CUERipper.Avalonia/Views/MainWindow.axaml +++ b/CUERipper.Avalonia/Views/MainWindow.axaml @@ -40,12 +40,14 @@ - - - - + + AutomationProperties.Name="Disc drives" + ItemsSource="{Binding DiscDrives}" + SelectedItem="{Binding SelectedDrive}" + HorizontalAlignment="Stretch" + Margin="5,5" + Grid.Row="0" + Grid.Column="0"/>