diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml new file mode 100644 index 000000000..713c6bac9 --- /dev/null +++ b/.github/workflows/release-linux.yml @@ -0,0 +1,49 @@ +--- +name: release-linux + +# yamllint disable-line rule:truthy +on: + push: + tags: + - '*' + workflow_dispatch: + +defaults: + run: + shell: bash + +jobs: + build: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install build tools + run: | + sudo apt-get update + sudo apt-get install -y autoconf automake libtool-bin + - name: Apply Patches + run: ./git_init.sh + - name: Publish CUERipper.Avalonia + run: ./publish_linux64.sh + - name: Get CUETools Version + id: get_version + run: | + PRODUCTVER=$(grep 'CUEToolsVersion =' CUETools.Processor/CUESheet.cs | awk '{print $6}' | tr -d '";\r') + echo "CUETools version: $PRODUCTVER" + echo "CUETOOLS_VERSION=$PRODUCTVER" >> $GITHUB_ENV + - name: Package CUERipper.Avalonia + run: ./package_linux64.sh + - name: Add artifacts to Release + uses: softprops/action-gh-release@v2 + with: + name: CUETools ${{ env.CUETOOLS_VERSION }} + body_path: CHANGELOG.md + draft: false + prerelease: false + tag_name: ${{ github.ref_name }} + files: | + CUERipper.Linux64_${{ env.CUETOOLS_VERSION }}.tar.gz + CUERipper.Linux64_${{ env.CUETOOLS_VERSION }}.tar.gz.sha256 + diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index fd3284e87..befc39468 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -29,6 +29,8 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + - name: Install Inno Setup + run: pwsh -File ./install_inno.ps1 - name: Apply patches # yamllint disable-line rule:line-length run: | @@ -53,7 +55,45 @@ jobs: - name: Collect files run: | collect_files.bat - - uses: actions/upload-artifact@v4 + - 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=6 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: Build Windows installer + run: | + "C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe" "installer.iss" + shell: cmd + - name: Generate hash for Windows installer + run: | + $installer = "CUETools_Setup_${{ env.CUETOOLS_VERSION }}.exe" + $installerHash = (Get-FileHash $installer -Algorithm SHA256).Hash.ToLower() + "$installerHash *$installer" | Out-File -Encoding ASCII "$installer.sha256" + shell: pwsh + - 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_Setup_${{ env.CUETOOLS_VERSION }}.exe + CUETools_Setup_${{ env.CUETOOLS_VERSION }}.exe.sha256 + 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 + diff --git a/Bwg.Logging/Bwg.Logging.csproj b/Bwg.Logging/Bwg.Logging.csproj index 0527ed5f1..e1c6b61d3 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 87a3b9b77..a2814add0 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 4126724e4..7f44552b9 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 e2eec4508..49a278d21 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 a244a7ec8..aa200d05f 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 000000000..6a58b09b2 --- /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 000000000..bb95be0f7 --- /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 1c0be7d44..f78674760 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..14a3e63dc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +Release date: 2025-03-09 + +**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.- Changelog** + +CUERipper UI Reimplemented in Avalonia +A brand-new application with additional functionalities: +- Album cover selector – Choose which album cover to embed. +- 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. +- Installer – Install CUETools/CUERipper to Program Files or user-specific directories without manually extracting zip files. +- 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) +- [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/CUERipper.Avalonia.Tests/CUERipper.Avalonia.Tests.csproj b/CUERipper.Avalonia.Tests/CUERipper.Avalonia.Tests.csproj new file mode 100644 index 000000000..f63dad0ee --- /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 000000000..1e1f786cb --- /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 000000000..43a4c6aa3 --- /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 000000000..dcda1ac14 --- /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 000000000..3ea881470 --- /dev/null +++ b/CUERipper.Avalonia/App.axaml.cs @@ -0,0 +1,198 @@ +#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.Utilities; +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", Constants.ApplicationName) + .WriteTo.File(Path.Combine(Constants.ProfileDir, "logs/log-.txt") + , rollingInterval: RollingInterval.Day + , retainedFileCountLimit: 10 + ).CreateLogger(); + } + + LibraryResolver.Init(); + } + + public override void OnFrameworkInitializationCompleted() + { + var services = new ServiceCollection(); + + EnsureUserDirectoryExists(); + + // 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(); + else if(OS.IsLinux()) + services.AddSingleton(); + 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 && Directory.Exists(Constants.PathImageCache)) + { + 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); + } + } + + private void EnsureUserDirectoryExists() + { + if (!Directory.Exists(Constants.ProfileDir)) + { + Directory.CreateDirectory(Constants.ProfileDir); + } + } + } +} \ 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 000000000..cc794c06e Binary files /dev/null and b/CUERipper.Avalonia/Assets/album-placeholder.bmp differ diff --git a/CUERipper.Avalonia/Assets/cue2.ico b/CUERipper.Avalonia/Assets/cue2.ico new file mode 100644 index 000000000..5212447b3 Binary files /dev/null and b/CUERipper.Avalonia/Assets/cue2.ico differ diff --git a/CUERipper.Avalonia/Assets/discogs.png b/CUERipper.Avalonia/Assets/discogs.png new file mode 100644 index 000000000..c5cb26ff6 Binary files /dev/null and b/CUERipper.Avalonia/Assets/discogs.png differ diff --git a/CUERipper.Avalonia/Assets/freedb16.png b/CUERipper.Avalonia/Assets/freedb16.png new file mode 100644 index 000000000..a7ba1386e Binary files /dev/null and b/CUERipper.Avalonia/Assets/freedb16.png differ diff --git a/CUERipper.Avalonia/Assets/musicbrainz.ico b/CUERipper.Avalonia/Assets/musicbrainz.ico new file mode 100644 index 000000000..7942139f7 Binary files /dev/null and b/CUERipper.Avalonia/Assets/musicbrainz.ico differ 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 000000000..48adb1ae2 Binary files /dev/null and b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f195.png differ diff --git a/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f4bf.png b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f4bf.png new file mode 100644 index 000000000..a728d8486 Binary files /dev/null and b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f4bf.png differ 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 000000000..cc6a4a3eb Binary files /dev/null and b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f4c4.png differ 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 000000000..7d2d7911f Binary files /dev/null and b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f50d.png differ 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 000000000..0aa50a8b3 Binary files /dev/null and b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f529.png differ 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 000000000..ebfc6bed2 Binary files /dev/null and b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u1f9e9.png differ diff --git a/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u23cf.png b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u23cf.png new file mode 100644 index 000000000..ce8358703 Binary files /dev/null and b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u23cf.png differ diff --git a/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u2699.png b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u2699.png new file mode 100644 index 000000000..119be0125 Binary files /dev/null and b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u2699.png differ diff --git a/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u2716.png b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u2716.png new file mode 100644 index 000000000..80290cfc5 Binary files /dev/null and b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u2716.png differ diff --git a/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u274c.png b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u274c.png new file mode 100644 index 000000000..9613da7fb Binary files /dev/null and b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u274c.png differ 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 000000000..c9608c7a9 Binary files /dev/null and b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u2795.png differ 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 000000000..4b671b619 Binary files /dev/null and b/CUERipper.Avalonia/Assets/noto-emoji/32/emoji_u2796.png differ diff --git a/CUERipper.Avalonia/Assets/noto-emoji/LICENSE b/CUERipper.Avalonia/Assets/noto-emoji/LICENSE new file mode 100644 index 000000000..d09d3d0e0 --- /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 000000000..ddb6544d1 --- /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 000000000..dc7b4094e --- /dev/null +++ b/CUERipper.Avalonia/CUERipper.Avalonia.csproj @@ -0,0 +1,63 @@ + + + Max Visser + Copyright (c) 2025 Max Visser + GPL-2.0-or-later + 2.2.6.0 + WinExe + net47;net8.0 + 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 000000000..be4ed9893 --- /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 000000000..3524a4bb2 --- /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 000000000..4027df50e --- /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 000000000..d15734dfc --- /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 000000000..912b5974e --- /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 000000000..c31bd3086 --- /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 000000000..9063f3b12 --- /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/Compatibility/StringExtensions.cs b/CUERipper.Avalonia/Compatibility/StringExtensions.cs new file mode 100644 index 000000000..eee06bef4 --- /dev/null +++ b/CUERipper.Avalonia/Compatibility/StringExtensions.cs @@ -0,0 +1,12 @@ +using System; + +namespace CUERipper.Avalonia.Compatibility +{ +#if NET47 + internal static class StringExtensions + { + public static string[] Split(this string input, string separator) + => input.Split([separator], StringSplitOptions.None); + } +#endif +} diff --git a/CUERipper.Avalonia/Configuration/Abstractions/ICUEConfigFacade.cs b/CUERipper.Avalonia/Configuration/Abstractions/ICUEConfigFacade.cs new file mode 100644 index 000000000..c6bdc5d43 --- /dev/null +++ b/CUERipper.Avalonia/Configuration/Abstractions/ICUEConfigFacade.cs @@ -0,0 +1,88 @@ +#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; } + int MaxAlbumArtSize { 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; } + bool CheckForUpdates { 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 000000000..859e5b474 --- /dev/null +++ b/CUERipper.Avalonia/Configuration/CUEConfigFacade.cs @@ -0,0 +1,186 @@ +#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 int MaxAlbumArtSize { get => _cueConfig.maxAlbumArtSize; set => _cueConfig.maxAlbumArtSize = 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; } + public bool CheckForUpdates { get => _cueConfig.advanced.CheckForUpdates; set => _cueConfig.advanced.CheckForUpdates = 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) + { + var settingsReader = new SettingsReader(Constants.ApplicationShortName, "settings.txt", Constants.ApplicationPath); + 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() + { + var sw = new SettingsWriter(Constants.ApplicationShortName, "settings.txt", Constants.ApplicationPath); + _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 000000000..4fdba778d --- /dev/null +++ b/CUERipper.Avalonia/Constants.cs @@ -0,0 +1,75 @@ +#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; +using CUETools.Processor.Settings; +using System; +using System.IO; + +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 const string ApplicationShortName = "CUERipper"; + public const string ApplicationName = $"{ApplicationShortName}.Avalonia {CUESheet.CUEToolsVersion}"; + + public const string PathNoto = "avares://CUERipper.Avalonia/Assets/noto-emoji/32/"; + + public const int HiResImageMaxDimension = 2048; + + public const string UserAgent = $"{ApplicationShortName}/{CUESheet.CUEToolsVersion} ( https://github.com/gchudov/cuetools.net )"; + public const string GithubApiUri = "https://api.github.com/repos/UnknownException/cuetools.net/releases"; + // "https://api.github.com/repos/gchudov/cuetools.net/releases" + public const string GithubBranch = "cueripper-avalonia"; // "master" + + public const int MaxCoverFetchConcurrency = 4; + + public const char NullDrive = '\0'; + +#if NET47 + public static readonly string ApplicationPath = AppDomain.CurrentDomain.BaseDirectory; +#else + public static readonly string ApplicationPath = Environment.ProcessPath ?? throw new NullReferenceException("Can't determine path."); +#endif + public static readonly string ProfileDir = SettingsShared.GetProfileDir(ApplicationShortName, ApplicationPath); + + public static readonly string PathImageCache = Path.Combine(ProfileDir, ".AlbumCache/"); + public static readonly string PathUpdateFolder = Path.Combine(ProfileDir, ".cueupdate/"); + public static readonly string PathUpdateCacheFile = Path.Combine(ProfileDir, "CT_LAST_UPDATE_CHECK"); + } +} diff --git a/CUERipper.Avalonia/Events/DirectoryConflictEventArgs.cs b/CUERipper.Avalonia/Events/DirectoryConflictEventArgs.cs new file mode 100644 index 000000000..ed8083f4d --- /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 000000000..3a3c6c84b --- /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 000000000..1487aa91b --- /dev/null +++ b/CUERipper.Avalonia/Events/GenericProgressEventArgs.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 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 000000000..e8dd6580a --- /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/Events/SelectedMetadataChangedEventArgs.cs b/CUERipper.Avalonia/Events/SelectedMetadataChangedEventArgs.cs new file mode 100644 index 000000000..b1276b4bc --- /dev/null +++ b/CUERipper.Avalonia/Events/SelectedMetadataChangedEventArgs.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; +using CUERipper.Avalonia.Models; + +namespace CUERipper.Avalonia.Events +{ + public class SelectedMetadataChangedEventArgs : EventArgs + { + public AlbumMetadata? AlbumMetadata { get; set;} + + public SelectedMetadataChangedEventArgs(AlbumMetadata? albumMetadata) + { + AlbumMetadata = albumMetadata; + } + } +} diff --git a/CUERipper.Avalonia/Exceptions/CUEToolsCoreException.cs b/CUERipper.Avalonia/Exceptions/CUEToolsCoreException.cs new file mode 100644 index 000000000..405803a74 --- /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 000000000..c60b8141a --- /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 000000000..6f24d5f45 --- /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 000000000..dca2da658 --- /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 000000000..ccabdac2b --- /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 000000000..aa6dac4c4 --- /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 000000000..91c775c2c --- /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 000000000..ac3117ffd --- /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 000000000..ad50a2765 --- /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 000000000..983a98661 --- /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 000000000..73fe2576f --- /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 000000000..5a5f91acd --- /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 000000000..efeb7692f --- /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 000000000..1bf823724 --- /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, int Index); +} diff --git a/CUERipper.Avalonia/Models/DriveInformation.cs b/CUERipper.Avalonia/Models/DriveInformation.cs new file mode 100644 index 000000000..220379542 --- /dev/null +++ b/CUERipper.Avalonia/Models/DriveInformation.cs @@ -0,0 +1,23 @@ +#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 DriveInformation(char Letter, string Name, string ARName, bool IsAccessible); +} diff --git a/CUERipper.Avalonia/Models/EncodingConfiguration.cs b/CUERipper.Avalonia/Models/EncodingConfiguration.cs new file mode 100644 index 000000000..e817a3a8d --- /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 000000000..db8a9e35a --- /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 000000000..4913d0f8a --- /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/GithubReleaseContainer.cs b/CUERipper.Avalonia/Models/Github/GithubReleaseContainer.cs new file mode 100644 index 000000000..1d5f06f93 --- /dev/null +++ b/CUERipper.Avalonia/Models/Github/GithubReleaseContainer.cs @@ -0,0 +1,2 @@ +namespace CUERipper.Avalonia.Models.Github; +public record GithubReleaseContainer (bool IsFromCache, GithubRelease? Content, string? Author); diff --git a/CUERipper.Avalonia/Models/Github/GithubReleaseUser.cs b/CUERipper.Avalonia/Models/Github/GithubReleaseUser.cs new file mode 100644 index 000000000..34db9ae7a --- /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 000000000..c52276475 --- /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/GridColumnDefinition.cs b/CUERipper.Avalonia/Models/GridColumnDefinition.cs new file mode 100644 index 000000000..24277cd63 --- /dev/null +++ b/CUERipper.Avalonia/Models/GridColumnDefinition.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; +using Avalonia.Controls; + +namespace CUERipper.Avalonia.Models +{ + public class GridColumnDefinition + { + public required string Header { get; init; } + public bool HeaderBinding { get; init; } + public string? Binding { get; init; } + public bool ReadOnly { get; init; } + public bool Clipboard { get; init; } + public required Func, DataGridColumn> Create; + } +} diff --git a/CUERipper.Avalonia/Models/MessageBoxDefinition.cs b/CUERipper.Avalonia/Models/MessageBoxDefinition.cs new file mode 100644 index 000000000..8ae8382ed --- /dev/null +++ b/CUERipper.Avalonia/Models/MessageBoxDefinition.cs @@ -0,0 +1,23 @@ +#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 MessageBoxDefinition(string Title, string Message, MessageBoxType Type); +} diff --git a/CUERipper.Avalonia/Models/MessageBoxType.cs b/CUERipper.Avalonia/Models/MessageBoxType.cs new file mode 100644 index 000000000..9d8f5b8c3 --- /dev/null +++ b/CUERipper.Avalonia/Models/MessageBoxType.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 + +namespace CUERipper.Avalonia.Models +{ + public enum MessageBoxType + { + Ok + , YesNo + , OkCancel + } +} diff --git a/CUERipper.Avalonia/Models/MetaGridColumnKey.cs b/CUERipper.Avalonia/Models/MetaGridColumnKey.cs new file mode 100644 index 000000000..61b1bfcba --- /dev/null +++ b/CUERipper.Avalonia/Models/MetaGridColumnKey.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 + +namespace CUERipper.Avalonia.Models +{ + public enum MetaGridColumnKey + { + Field + , Value + } +} diff --git a/CUERipper.Avalonia/Models/MetaSource.cs b/CUERipper.Avalonia/Models/MetaSource.cs new file mode 100644 index 000000000..433592e0e --- /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 000000000..f2b024a1f --- /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/TrackGridColumnKey.cs b/CUERipper.Avalonia/Models/TrackGridColumnKey.cs new file mode 100644 index 000000000..1946a15de --- /dev/null +++ b/CUERipper.Avalonia/Models/TrackGridColumnKey.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 + +namespace CUERipper.Avalonia.Models +{ + public enum TrackGridColumnKey + { + TrackNo + , Title + , Length + , Progress + , Artist + } +} diff --git a/CUERipper.Avalonia/Models/UpdateMetadata.cs b/CUERipper.Avalonia/Models/UpdateMetadata.cs new file mode 100644 index 000000000..c23c98b10 --- /dev/null +++ b/CUERipper.Avalonia/Models/UpdateMetadata.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 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 + { + public static bool UpdateAvailable(this UpdateMetadata? updateMetadata) + => updateMetadata != null && updateMetadata.Version != updateMetadata.CurrentVersion; + } +} diff --git a/CUERipper.Avalonia/Program.cs b/CUERipper.Avalonia/Program.cs new file mode 100644 index 000000000..175f748c2 --- /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 000000000..7c9ee5900 --- /dev/null +++ b/CUERipper.Avalonia/Resources/Language.nl-NL.resx @@ -0,0 +1,279 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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. + + + 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 new file mode 100644 index 000000000..d9761b61f --- /dev/null +++ b/CUERipper.Avalonia/Resources/Language.resx @@ -0,0 +1,279 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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. + + + 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/Services/Abstractions/ICUEMetaService.cs b/CUERipper.Avalonia/Services/Abstractions/ICUEMetaService.cs new file mode 100644 index 000000000..38b735de3 --- /dev/null +++ b/CUERipper.Avalonia/Services/Abstractions/ICUEMetaService.cs @@ -0,0 +1,47 @@ +#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.Models; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; + +namespace CUERipper.Avalonia.Services.Abstractions +{ + public interface ICUEMetaService + { + AlbumMetadata? SelectedMetadata { get; set; } + + /// + /// Fired from UI thread + /// + public event EventHandler? OnSelectedMetadataChanged; + + IImmutableList GetAlbumMetaInformation(bool advancedSearch); + void ResetAlbumMetaInformation(); + + IEnumerable GetTracksLength(); + + Task FetchImageAsync(string uri, CancellationToken ct); + void FinalizeMetadata(); + } +} diff --git a/CUERipper.Avalonia/Services/Abstractions/ICUERipperService.cs b/CUERipper.Avalonia/Services/Abstractions/ICUERipperService.cs new file mode 100644 index 000000000..23a889696 --- /dev/null +++ b/CUERipper.Avalonia/Services/Abstractions/ICUERipperService.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.Events; +using CUERipper.Avalonia.Models; +using CUETools.CDImage; +using CUETools.Processor; +using CUETools.Ripper; +using System; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; + +namespace CUERipper.Avalonia.Services.Abstractions +{ + public interface ICUERipperService + { + public 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; + + IImmutableDictionary QueryAvailableDriveInformation(); + bool IsDriveAccessible(); + 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 000000000..0a90737f1 --- /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 000000000..c174e4d9d --- /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 000000000..5de6f6640 --- /dev/null +++ b/CUERipper.Avalonia/Services/Abstractions/IUpdateService.cs @@ -0,0 +1,16 @@ +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); + void Install(); + } +} diff --git a/CUERipper.Avalonia/Services/CUEMetaService.cs b/CUERipper.Avalonia/Services/CUEMetaService.cs new file mode 100644 index 000000000..e06ea2d4b --- /dev/null +++ b/CUERipper.Avalonia/Services/CUEMetaService.cs @@ -0,0 +1,232 @@ +#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.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 ICUEConfigFacade _cueConfig; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + private readonly Dictionary> _cache = []; + + private CDImageLayout _toc = new(); + + private AlbumMetadata? _selectedMetadata; + public AlbumMetadata? SelectedMetadata + { + get => _selectedMetadata; + set + { + _selectedMetadata = value; + + var eventArgs = new SelectedMetadataChangedEventArgs(value); + OnSelectedMetadataChanged?.Invoke(this, eventArgs); + } + } + public event EventHandler? OnSelectedMetadataChanged; + + public CUEMetaService(ICUERipperService ripperService + , ICUEConfigFacade cueConfig + , HttpClient httpClient + , ILogger logger) + { + _cueConfig = cueConfig; + _httpClient = httpClient; + _logger = logger; + + ripperService.OnSelectedDriveChanged += (object? _, DriveChangedEventArgs e) => + { + _toc = ripperService.GetDiscTOC() ?? new(); + }; + } + + private static CUEMetadataEntry CreateDummy(CDImageLayout toc) + { + var dummy = new CTDBResponseMeta + { + artist = Constants.UnknownArtist + , album = Constants.UnknownTitle + , track = new CTDBResponseMetaTrack[toc.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 + }; + } + + var meta = new CUEMetadata(toc.TOCID, (int)toc.AudioTracks); + meta.FillFromCtdb(dummy, toc.FirstAudio - 1); + + return new CUEMetadataEntry(meta, toc, string.Empty); + } + + public IImmutableList GetAlbumMetaInformation(bool advancedSearch) + { + if (_toc.AudioTracks == 0) return []; + + if (!advancedSearch && _cache.TryGetValue(_toc.TOCID, out var cached)) + { + _logger.LogInformation("Album is available in cache for {TOCID}", _toc.TOCID); + return cached; + } + + _logger.LogInformation("Retrieving album information {TOCID}", _toc.TOCID); + + CUEMetadata? userEntry = null; + try + { + userEntry = CUEMetadata.Load(_toc.TOCID); + _logger.LogInformation("Found user entry for {TOCID}", _toc.TOCID); + } + catch (FileNotFoundException) + { + _logger.LogInformation("No user entry for {TOCID}", _toc.TOCID); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Non fatal error parsing CUE Metadata cache."); + } + + var remoteResult = CUESheet.LookupRemoteAlbumInfo(Constants.ApplicationShortName + , _toc + , _cueConfig.ToCUEConfig() + , useCTDB: true + , advancedSearch ? CTDBMetadataSearch.Extensive : CTDBMetadataSearch.Fast + , showProgress: (_, _) => { } + , checkStop: () => { } + ); + + _logger.LogInformation("{Count} remote results for {TOCID}", remoteResult.Count, _toc.TOCID); + + var result = remoteResult.Concat([CreateDummy(_toc)]) + .Select(entry => new AlbumMetadata(MetaSourceHelper.FromString(entry.ImageKey), entry.metadata)) + .PrependIf(userEntry != null, new AlbumMetadata(MetaSource.Local, userEntry!)) + .ToImmutableList(); + + // Only cache if remote call was successful + if (remoteResult.Count > 0) + { + _cache.Remove(_toc.TOCID); + _cache.Add(_toc.TOCID, result); + } + + return result; + } + + public void ResetAlbumMetaInformation() + => _cache.Remove(_toc.TOCID); + + public IEnumerable GetTracksLength() + { + 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) + { + _logger.LogInformation("Fetching image from {Uri}.", uri); + + 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() + => SelectedMetadata?.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 000000000..a3a715a1c --- /dev/null +++ b/CUERipper.Avalonia/Services/CUERipperService.cs @@ -0,0 +1,749 @@ +#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.Compatibility; +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.Collections.Immutable; +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 = Constants.NullDrive; + 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 ICDRipper? CreateCDRipperInstance() + { + const string failedToCreateInstance = "Failed to create an instance of CD ripper, is the library missing?"; + + if (CUEProcessorPlugins.ripper == null) + { + _logger.LogError(failedToCreateInstance); + return null; + } + + var cdRipper = Activator.CreateInstance(CUEProcessorPlugins.ripper) as ICDRipper; + if (cdRipper == null) + { + _logger.LogError(failedToCreateInstance); + } + + return cdRipper; + } + + private DriveInformation QueryDriveName(char drive) + { + var nullResult = new DriveInformation(drive, $"{drive}:", string.Empty, false); + + using var audioSource = CreateCDRipperInstance(); + if (audioSource == null) return nullResult; + + try + { + return audioSource.Open(drive) + ? new DriveInformation(drive, audioSource.Path, audioSource.ARName, true) + : nullResult; + } + catch (TOCException) + { + // Not clean but it's safe at this point + return new DriveInformation(drive, audioSource.Path, audioSource.ARName, true); + } + catch (ReadCDException ex) + { + _logger.LogError(ex, "Failed to read disc '{DriveLetter}'.", drive); + if (OS.IsWindows() && (uint?)ex.InnerException?.HResult == 0x80070020) + { + var drivePath = $"{audioSource.Path}(Warning: drive is in use)"; + return new DriveInformation(drive, drivePath, string.Empty, false); + } + + return new DriveInformation(drive, $"{audioSource.Path}(Error: {ex.Message})", string.Empty, false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to access drive '{DriveLetter}'.", drive); + return nullResult; + } + } + + public IImmutableDictionary QueryAvailableDriveInformation() + { + var result = new Dictionary(); + + var drives = CDDrivesList.DrivesAvailable(); + foreach (var drive in drives) + { + result.Add(drive, QueryDriveName(drive)); + } + + _driveList = result; + return result.ToImmutableDictionary(x => x.Key, x => x.Value); + } + + public bool IsDriveAccessible() + => _driveList.TryGetValue(SelectedDrive, out var result) + ? result.IsAccessible + : throw new KeyNotFoundException($"Couldn't find drive key '{SelectedDrive}'."); + + 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() + { + if (!IsDriveAccessible()) return null; + + using var audioSource = CreateCDRipperInstance(); + if (audioSource == null) return null; + + 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() + { + if (!IsDriveAccessible()) return; + + using var audioSource = CreateCDRipperInstance(); + if (audioSource == null) return; + + 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(() => + { + _logger.LogInformation("Rip task has been started."); + + 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; + } + + var initialEncoding = ripSettings.EncodingConfiguration[0]; + + if (ripSettings.EncodingConfiguration.Length > 1 + && !initialEncoding.IsLossless) + { + _logger.LogError("Ripping has failed! First encoding must be lossless"); + + OnFinish?.Invoke(this, new(false, _localizer["Status:RipFail"], _localizer["Error:MultiEncodingNotLossless"])); + return; + } + + SetEncodingVariables(initialEncoding); + + 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 = initialEncoding.Encoding; + var encoderType = initialEncoding.IsLossless + ? AudioEncoderType.Lossless + : AudioEncoderType.Lossy; + + cueSheet.OutputStyle = initialEncoding.CUEStyleIndex == 0 + ? CanEmbedCUE(initialEncoding) ? CUEStyle.SingleFileWithCUE : CUEStyle.SingleFile + : 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) + { + _logger.LogInformation("Disabling disc ejecting."); + audioSource.DisableEjectDisc(true); + } + + if (ripSettings.TestAndCopy) + { + _logger.LogInformation("Testing before copy."); + cueSheet.TestBeforeCopy(); + } + else + { + cueSheet.ArTestVerify = null; + } + + _logger.LogInformation("Ripping has started."); + + cueSheet.Go(); + + _logger.LogInformation("Ripping has finished."); + +#if !DEBUG + _logger.LogInformation("Submitting to CUETools Database."); + + 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 (ripSettings.EncodingConfiguration[0].IsLossless + && cueSheet.CTDB.QueryExceptionStatus == WebExceptionStatus.Success + && audioSource.FailedSectors.PopulationCount() != 0) + { + foreach (DBEntry entry in cueSheet.CTDB.Entries) + { + recoveryPossible = entry.hasErrors && entry.canRecover; + _logger.LogInformation("Found recovery record."); + break; + } + } + + if (audioSource.FailedSectors.PopulationCount() != 0) + { + if (recoveryPossible && !_config.SkipRepair) + { + _logger.LogInformation("Start repairing tracks."); + var repairCue = RepairTracks(encoderType, encodingFormat, cueSheet.OutputStyle, metadataEntry, pathOut, ct); + if (repairCue != null) + { + cueSheet.Close(); + cueSheet = repairCue; + recoveryPossible = false; + } + } + else if (!_config.SkipRepair) + { + _logger.LogWarning("Recovery is currently not possible for this disc."); + } + + 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) + { + _logger.LogInformation("Enabling disc ejecting."); + audioSource.DisableEjectDisc(false); + } + + if (_config.EjectAfterRip) + { + _logger.LogInformation("Ejecting disc from drive."); + EjectTray(); + } + } + }, ct, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + private ICDRipper? CreateCDRipper(char selectedDrive, RipSettings ripSettings, CancellationToken ct) + { + var audioSource = CreateCDRipperInstance(); + if (audioSource == null) return null; + + 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 > _config.MaxAlbumArtSize + || albumCover.PixelSize.Height > _config.MaxAlbumArtSize) + { + embeddedArtwork = albumCover.ContainedResize(_config.MaxAlbumArtSize); + } + + 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}"; + + _logger.LogInformation("Start {Encoding} encoding, preset #{Number}.", encodingConfig.Encoding, i); + + EncodeTracks(cuePath, destination, destinationCuePath, encodingConfig, metadataEntry, i, encodingConfiguration.Length - 1, ct); + + _logger.LogInformation("Finished {Encoding} encoding, preset #{Number}.", encodingConfig.Encoding, i); + } + + 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 + ? CanEmbedCUE(encodingConfig) ? CUEStyle.SingleFileWithCUE : CUEStyle.SingleFile + : 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; + } + } + + private bool CanEmbedCUE(EncodingConfiguration encodingConfig) + => _config.Formats + .Where(f => f.Key == encodingConfig.Encoding) + .Select(e => e.Value) + .Single() + .allowEmbed; + } +} diff --git a/CUERipper.Avalonia/Services/IconService.cs b/CUERipper.Avalonia/Services/IconService.cs new file mode 100644 index 000000000..4c06cd0be --- /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 000000000..6ab31c87f --- /dev/null +++ b/CUERipper.Avalonia/Services/LinuxDriveNotificationService.cs @@ -0,0 +1,164 @@ +#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 + +// 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.Collections.Immutable; +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 + }; + _thread.Start(); + } + + 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)) + .ToImmutableList(); + + var unmountedDrives = knownDrives.Where(k => !currentDrives + .Any(c => c.Key == k.Key)) + .ToImmutableList(); + + if (!mountedDrives.IsEmpty || !unmountedDrives.IsEmpty) + { + mountedDrives.ForEach(d => _logger.LogInformation("Drive {DriveKey} mounted.", d.Key)); + unmountedDrives.ForEach(d => _logger.LogInformation("Drive {DriveKey} unmounted.", d.Key)); + + _onDriveRefresh?.Invoke(); + } + + var driveStateChange = currentDrives.Where( + c => knownDrives.TryGetValue(c.Key, out var knownDrive) && knownDrive != c.Value + ); + + foreach (var drive in driveStateChange) + { + _logger.LogInformation("Drive state has changed for drive {DriveKey}.", drive.Key); + + 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); + } + } +} \ 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 000000000..16215e2fe --- /dev/null +++ b/CUERipper.Avalonia/Services/NullDriveNotificationService.cs @@ -0,0 +1,114 @@ +#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 + }; + _thread.Start(); + } + + 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 000000000..0fe141b12 --- /dev/null +++ b/CUERipper.Avalonia/Services/UpdateService.cs @@ -0,0 +1,384 @@ +#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.Configuration.Abstractions; +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.Diagnostics; +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 readonly HttpClient _httpClient; + private readonly ICUEConfigFacade _config; + private readonly ILogger _logger; + + public UpdateMetadata? UpdateMetadata { get; private set; } + + public UpdateService(HttpClient httpClient + , ICUEConfigFacade config + , ILogger logger) + { + _httpClient = httpClient; + _config = config; + _logger = logger; + } + + public async Task FetchAsync() + { + if (UpdateMetadata != null) return true; + + if (!_config.CheckForUpdates) + { + _logger.LogWarning("Skip checking for updates."); + return false; + } + + var latestRelease = await GetLatestReleaseAsync(); + if (latestRelease.Content == null) + { + _logger.LogWarning("No releases found."); + return false; + } + + string versionPattern = @"^v\d+\.\d+\.\d+[a-zA-Z]?$"; + Regex regex = new(versionPattern); + if (!regex.IsMatch(latestRelease.Content.TagName)) + { + _logger.LogError("Release tag '{TAG}' doesn't match expected format.", latestRelease.Content.TagName); + return false; + } + + var setupAsset = GetSetupAsset(latestRelease.Content); + var hashAsset = GetHashAsset(latestRelease.Content); + if (setupAsset == null || hashAsset == null) + { + _logger.LogWarning("Github assets are incomplete."); + return false; + } + + UpdateMetadata = new UpdateMetadata( + Version: latestRelease.Content.TagName.Substring(1) + , CurrentVersion: CUESheet.CUEToolsVersion + , Author: string.IsNullOrWhiteSpace(latestRelease.Author) + ? Constants.ApplicationName + : latestRelease.Author! + , Description: latestRelease.Content.Body + , Uri: setupAsset.BrowserDownloadUrl + , Size: setupAsset.Size + , HashUri: hashAsset.BrowserDownloadUrl + , HashSize: hashAsset.Size + , Date: latestRelease.Content.PublishedAt + ); + + _logger.LogInformation("Update is {UpdateAvailability}." + , UpdateMetadata.UpdateAvailable() ? "available" : "not available"); + + return true; + } + + private async Task GetLatestReleaseAsync() + { + GithubReleaseContainer latestRelease = GetLatestReleaseFromDiskCache(); + if (latestRelease.IsFromCache) return latestRelease; + + try + { + using var result = await _httpClient.GetAsync(Constants.GithubApiUri); + result.EnsureSuccessStatusCode(); + + string response = await result.Content.ReadAsStringAsync(); + var githubReleases = JsonConvert.DeserializeObject(response) + ?? throw new NullReferenceException("Failed to deserialize object..."); + + var recentRelease = githubReleases + .Where(r => !r.Draft && !r.PreRelease && r.TargetCommitish == Constants.GithubBranch) + .OrderByDescending(r => r.PublishedAt) + .FirstOrDefault(); + + string? author = null; + if (recentRelease != null) + { + author = await GetAuthorAsync(recentRelease); + } + + WriteReleasesToDiskCache(recentRelease, author); + return new GithubReleaseContainer(false, recentRelease, author); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve latest release."); + } + + return new GithubReleaseContainer(false, null, null); + } + + + /// + /// Mechanism that prevents spamming the GitHub API by limiting automated requests to once every 3 days. + /// + /// + private GithubReleaseContainer GetLatestReleaseFromDiskCache() + { + if (!File.Exists(Constants.PathUpdateCacheFile)) return new(false, null, null); + + string[] content = File.ReadAllLines(Constants.PathUpdateCacheFile); + if (content.Length != 4) + { + _logger.LogError("Content of {File} is incorrect.", Constants.PathUpdateCacheFile); + return new(false, null, null); + } + + 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.", Constants.PathUpdateCacheFile); + return new(false, null, null); + } + + bool isCacheValid = (DateTime.Now - lastUpdateCheck).Days < 3; + _logger.LogInformation("{GitHubState} check remote for update, cache {CacheState}." + , isCacheValid ? "Should not" : "Should" + , isCacheValid ? "is still valid" : "has expired"); + if (!isCacheValid) return new(false, null, null); + + try + { + var jsonBytes = Convert.FromBase64String(content[1]); + var githubRelease = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(jsonBytes)); + + _logger.LogInformation("Update information in cache is valid."); + return new(true, githubRelease, content[2]); + } + catch(Exception ex) + { + _logger.LogError(ex, "Failed to parse Github JSON from disk."); + return new(true, null, null); + } + } + + private void WriteReleasesToDiskCache(GithubRelease? githubRelease, string? author) + { + try + { + var jsonBytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(githubRelease)); + var base64String = Convert.ToBase64String(jsonBytes); + + var fileContent = new StringBuilder(); + fileContent.Append(DateTime.Now.ToString("yyyyMMdd")); + fileContent.Append(Environment.NewLine); + fileContent.Append(base64String); + fileContent.Append(Environment.NewLine); + fileContent.Append(author ?? string.Empty); + fileContent.Append(Environment.NewLine); + fileContent.Append("EOF"); + + File.WriteAllText(Constants.PathUpdateCacheFile, fileContent.ToString()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to write Github JSON to disk."); + } + } + + private static GithubAsset? GetSetupAsset(GithubRelease latestRelease) + { + const string EXE_PATTERN = @"^CUETools_Setup_\d+\.\d+\.\d+[a-zA-Z]?\.exe"; + Regex regex = new(EXE_PATTERN); + + return latestRelease.Assets + .Where(a => regex.IsMatch(a.Name)) + .FirstOrDefault(); + } + + private static GithubAsset? GetHashAsset(GithubRelease latestRelease) + { + const string HASH_PATTERN = @"^CUETools_Setup_\d+\.\d+\.\d+[a-zA-Z]?\.exe\.sha256$"; + Regex regex = new(HASH_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 !NET47 + if (!OperatingSystem.IsWindows()) + { + _logger.LogWarning("Updater is not implemented for this operating system."); + return false; + } +#endif + + if (!UpdateMetadata.UpdateAvailable()) return false; + + if (!Directory.Exists(Constants.PathUpdateFolder)) + { + Directory.CreateDirectory(Constants.PathUpdateFolder); + } + + try + { + var setupFile = $"{Constants.PathUpdateFolder}Update-{UpdateMetadata!.Version}.exe"; + var hashFile = $"{Constants.PathUpdateFolder}Update-{UpdateMetadata.Version}.sha256"; + + await DownloadFile(UpdateMetadata!.Uri + , contentSize: UpdateMetadata.Size + , filePath: setupFile + , progressEvent); + + await DownloadFile(UpdateMetadata.HashUri + , contentSize: UpdateMetadata.HashSize + , filePath: hashFile + , progressEvent: null); + + return VerifyFile(setupFile, 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 setupFile, string hashFile) + { + try + { + var actualHash = GetSHA256Hash(setupFile); + var validationHash = ParseSHA256FromHashFile(hashFile); + + return string.Compare(actualHash, validationHash, true) == 0; + } + catch(Exception ex) + { + _logger.LogError(ex, "Failed to verify hash."); + return false; + } + } + + public void Install() + { +#if !NET47 + if (!OperatingSystem.IsWindows()) + { + _logger.LogWarning("Updater is not implemented for this operating system."); + return; + } +#endif + + var setupFile = $"{Constants.PathUpdateFolder}Update-{UpdateMetadata!.Version}.exe"; + Process.Start(new ProcessStartInfo + { + FileName = Path.GetFullPath(setupFile), + UseShellExecute = true, + RedirectStandardOutput = false, + RedirectStandardError = false, + CreateNoWindow = false + }); + } + } +} diff --git a/CUERipper.Avalonia/Services/WindowsDriveNotificationService.cs b/CUERipper.Avalonia/Services/WindowsDriveNotificationService.cs new file mode 100644 index 000000000..1decdea7d --- /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 000000000..4edaaf7eb --- /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 000000000..2567a9087 --- /dev/null +++ b/CUERipper.Avalonia/Utilities/InterruptibleJob.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 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) + { + Interrupt(); + + _wrappedTask = Task.Factory.StartNew(async () => + { + await function(_cts.Token); + }, _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default) + .Unwrap() + .ContinueWith((t) => + { + if (t.IsFaulted) + { + ExceptionDispatchInfo.Capture(t.Exception.InnerException + ?? t.Exception).Throw(); + }; + }); + } + + public void Interrupt() + { + 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/Utilities/LibraryResolver.cs b/CUERipper.Avalonia/Utilities/LibraryResolver.cs new file mode 100644 index 000000000..ad0f67f56 --- /dev/null +++ b/CUERipper.Avalonia/Utilities/LibraryResolver.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 System; +using System.Runtime.InteropServices; +using System.Reflection; +using System.IO; + +namespace CUERipper.Avalonia.Utilities; + +/// +/// Helper class that'll allow loading libraries from the plugins folder on Linux. +/// +public static class LibraryResolver +{ + public static void Init() + { +#if NET8_0_OR_GREATER + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return; + + AppDomain.CurrentDomain.AssemblyLoad += (sender, args) => + { + try + { + if (args.LoadedAssembly.Location?.StartsWith(Path.Combine(AppContext.BaseDirectory, "plugins")) + ?? false) + { + NativeLibrary.SetDllImportResolver(args.LoadedAssembly, Resolve); + } + } + catch { } + }; +#endif + } + +#if NET8_0_OR_GREATER + private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) + { + if (libraryName.EndsWith(".so")) + { + libraryName = libraryName[..^3]; + } + + if (string.IsNullOrWhiteSpace(libraryName)) return IntPtr.Zero; + if (string.Compare(libraryName, "libc", StringComparison.OrdinalIgnoreCase) == 0) return IntPtr.Zero; + + var libraryPath = Path.Combine(AppContext.BaseDirectory, "plugins", "x64", libraryName); + libraryPath += ".so"; + + if (File.Exists(libraryPath)) + { + return NativeLibrary.Load(libraryPath); + } + + return IntPtr.Zero; + } +#endif +} diff --git a/CUERipper.Avalonia/ViewLocator.cs b/CUERipper.Avalonia/ViewLocator.cs new file mode 100644 index 000000000..e06721bf0 --- /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 000000000..e95d22318 --- /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 000000000..e285c0461 --- /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 000000000..d6098edcd --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/Abstractions/OptionProxy.cs @@ -0,0 +1,98 @@ +#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); + protected virtual T ContainWithinRange(T value) => value; + + 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; + + try + { + T actualValue = GetValueFromString(value); + actualValue = ContainWithinRange(actualValue); + + _viewValue = GetStringFromValue(actualValue); + 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 000000000..9392f18ab --- /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 000000000..ae8e754b5 --- /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 000000000..9157ea217 --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/IntOptionProxy.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 CUERipper.Avalonia.Compatibility; +using CUERipper.Avalonia.Utilities; +using CUERipper.Avalonia.ViewModels.Bindings.OptionProxies.Abstractions; + +namespace CUERipper.Avalonia.ViewModels.Bindings.OptionProxies +{ + public class IntOptionProxy : OptionProxy + { + private readonly int? _minValue; + private readonly int? _maxValue; + + public IntOptionProxy(string name, int defaultValue, Accessor accessor) + : base(name, defaultValue, accessor) + { + } + + public IntOptionProxy(string name, int defaultValue, int minValue, int maxValue, Accessor accessor) + : base(name, defaultValue, accessor) + { + _minValue = minValue; + _maxValue = maxValue; + } + + protected override string GetStringFromValue(int val) + => val.ToString(); + + protected override int GetValueFromString(string str) + => int.TryParse(str, out int result) ? result : Default; + + protected override int ContainWithinRange(int val) + => _minValue != null && _maxValue != null + ? MathClamp.Clamp(val, _minValue.Value, _maxValue.Value) + : val; + } +} diff --git a/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/OptionProxyFactory.cs b/CUERipper.Avalonia/ViewModels/Bindings/OptionProxies/OptionProxyFactory.cs new file mode 100644 index 000000000..3cce07627 --- /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 000000000..7ba3b30d0 --- /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 000000000..3c125c7d6 --- /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 000000000..a07ccf542 --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,209 @@ +#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.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; +using System.Collections.ObjectModel; +using System.Linq; + +namespace CUERipper.Avalonia.ViewModels +{ + public partial class MainWindowViewModel : ViewModelBase + { + 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; + } + + public ObservableCollection AlbumReleases { get; set; } = []; + + [ObservableProperty] + private AlbumRelease? selectedAlbum; + + partial void OnSelectedAlbumChanged(AlbumRelease? oldValue, AlbumRelease? newValue) + { + if (string.IsNullOrWhiteSpace(newValue?.Name)) return; + if (oldValue == newValue) return; + + var meta = GetSelectedAlbumMeta(); + _metaService.SelectedMetadata = meta; + + OutputPath = meta?.PathStringFromFormat(_config.PathFormat, _config) ?? "Output Path"; + } + + [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"]; } + + 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, index) => + { + 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) + , Index: index); + } + + return new AlbumRelease($"{(string.IsNullOrWhiteSpace(year) ? string.Empty : year + YEAR_SEPERATOR)}{artist} - {title} ({country} - {labelName} {barcode} - {releaseDate})" + , Icon: _iconService.GetIcon(meta.Source) + , Index: index); + }) + ).MoveAll(AlbumReleases); + + SelectedAlbum = AlbumReleases.Any() ? AlbumReleases[0] : null; + } + + private AlbumMetadata? GetSelectedAlbumMeta() + { + if (!CDDriveAvailable || SelectedAlbum == null) return null; + + var index = Math.Min(Math.Max(0, SelectedAlbum.Index), AlbumReleases.Count - 1); + var albumMetaInformation = _metaService.GetAlbumMetaInformation(false); + return index < albumMetaInformation.Count ? albumMetaInformation.ElementAt(index) : null; + } + + private void Clear() + { + AlbumReleases.Clear(); + DiscDrives.Clear(); + + ReadingProgress = 0; + ErrorProgress = 0; + TotalProgress = 0; + } + + internal bool SetInitState(Bitmap? albumCover) + { + Clear(); + + foreach (var driveName in _ripperService.QueryAvailableDriveInformation()) + { + DiscDrives.Add(driveName.Value.Name); + } + + if (DiscDrives.Count == 0) + { + DiscDrives.Add(Constants.NoCDDriveFound); + } + + SplitPaneOpen = _config.DetailPaneOpened; + AlbumCoverImage = albumCover; + + SelectedDrive = !string.IsNullOrWhiteSpace(_config.DefaultDrive) + && DiscDrives.Contains(_config.DefaultDrive) + ? _config.DefaultDrive + : DiscDrives[0]; + + if (DiscDrives[0] != Constants.NoCDDriveFound) + { + RefreshAlbums(); + } + + return true; + } + } +} diff --git a/CUERipper.Avalonia/ViewModels/MessageBoxViewModel.cs b/CUERipper.Avalonia/ViewModels/MessageBoxViewModel.cs new file mode 100644 index 000000000..dd2696763 --- /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 000000000..73421c5b2 --- /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 000000000..733d6da86 --- /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 000000000..099a96de0 --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/UserControls/CoverViewAlbumViewModel.cs @@ -0,0 +1,60 @@ +#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 bool IsPrimary { 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, bool isPrimary) + { + Uri = uri; + Uri150 = uri150; + IsPrimary = isPrimary; + } + + 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 000000000..7c285ce33 --- /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 000000000..a864008f9 --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/UserControls/DriveSettingSectionViewModel.cs @@ -0,0 +1,136 @@ +#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; + if (!_ripperService.IsDriveAccessible()) 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 (!_ripperService.IsDriveAccessible()) 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) => { + if (_ripperService.IsDriveAccessible()) + { + 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(); + } + else + { + SelectedC2ErrorMode = C2ErrorMode[C2ErrorMode.Count - 1]; + DriveOffset = 0; + } + }; + } + + 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 000000000..e55990292 --- /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/UserControls/MetaGridViewModel.cs b/CUERipper.Avalonia/ViewModels/UserControls/MetaGridViewModel.cs new file mode 100644 index 000000000..6b1edd5d8 --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/UserControls/MetaGridViewModel.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.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CUERipper.Avalonia.Events; +using CUERipper.Avalonia.Extensions; +using CUERipper.Avalonia.Models; +using CUERipper.Avalonia.Services.Abstractions; +using CUERipper.Avalonia.ViewModels.Bindings; +using Microsoft.Extensions.Localization; + +namespace CUERipper.Avalonia.ViewModels.UserControls +{ + public partial class MetaGridViewModel : ViewModelBase + { + public ObservableCollection Metadata { get; } = []; + + [ObservableProperty] + private string albumTitle = string.Empty; + + [ObservableProperty] + private string albumArtist = string.Empty; + + [ObservableProperty] + private string albumYear = string.Empty; + + [ObservableProperty] + private string albumDisc = string.Empty; + + + private readonly ICUEMetaService _metaService; + private readonly IStringLocalizer _localizer; + public MetaGridViewModel(ICUEMetaService metaService + , IStringLocalizer stringLocalizer) + { + _metaService = metaService; + _localizer = stringLocalizer; + + _metaService.OnSelectedMetadataChanged += OnSelectedMetadataChanged; + } + + public void Clear() + { + Metadata.Clear(); + } + + public void OnSelectedMetadataChanged(object? sender, SelectedMetadataChangedEventArgs e) + { + Clear(); + + var meta = e.AlbumMetadata; + if (meta == null) return; + + 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"}"; + + 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; + + 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); + } + } +} diff --git a/CUERipper.Avalonia/ViewModels/UserControls/TrackGridViewModel.cs b/CUERipper.Avalonia/ViewModels/UserControls/TrackGridViewModel.cs new file mode 100644 index 000000000..4a7ace32c --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/UserControls/TrackGridViewModel.cs @@ -0,0 +1,79 @@ +#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; +using CUERipper.Avalonia.Events; +using CUERipper.Avalonia.Models; +using CUERipper.Avalonia.Services.Abstractions; +using Microsoft.Extensions.Localization; + +namespace CUERipper.Avalonia.ViewModels.UserControls +{ + public partial class TrackGridViewModel : ViewModelBase + { + public ObservableCollection Tracks { get; set; } = []; + 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 ICUEMetaService _metaService; + private readonly IStringLocalizer _localizer; + public TrackGridViewModel(ICUEMetaService metaService + , IStringLocalizer stringLocalizer) + { + _metaService = metaService; + _localizer = stringLocalizer; + + _metaService.OnSelectedMetadataChanged += OnSelectedMetadataChanged; + } + + public void Clear() + { + Tracks.Clear(); + } + + public void OnSelectedMetadataChanged(object? sender, SelectedMetadataChangedEventArgs e) + { + Clear(); + + var meta = e.AlbumMetadata; + if (meta == null) return; + + var tracksLength = _metaService.GetTracksLength(); + for (int i = 0; i < meta.Data.Tracks.Count; ++i) + { + var trackInfo = meta.Data.Tracks[i]; + Tracks.Add(new TrackViewModel + { + Title = trackInfo?.Title ?? $"{Constants.UnknownTrack} {i + 1}" + , TrackNo = i + 1 + , Artist = trackInfo?.Artist ?? meta.Data.Artist + , Length = tracksLength.ElementAtOrDefault(i) ?? Constants.TrackNullLength + , OnUpdate = (TrackViewModel model) => + { + meta.Data.Tracks[model.TrackNo - 1].Title = model.Title; + meta.Data.Tracks[model.TrackNo - 1].Artist = model.Artist; + } + }); + } + } + } +} diff --git a/CUERipper.Avalonia/ViewModels/UserControls/TrackViewModel.cs b/CUERipper.Avalonia/ViewModels/UserControls/TrackViewModel.cs new file mode 100644 index 000000000..a2c9bef2d --- /dev/null +++ b/CUERipper.Avalonia/ViewModels/UserControls/TrackViewModel.cs @@ -0,0 +1,59 @@ +#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.ViewModels.UserControls +{ + public partial class TrackViewModel : ObservableObject + { + public int TrackNo { get; init; } + + private string title = string.Empty; + public string Title + { + get => title; + set + { + title = value; + OnUpdate?.Invoke(this); + OnPropertyChanged(nameof(Title)); + } + } + + public string Length { get; init; } = string.Empty; + + private string artist = string.Empty; + public string Artist + { + get => artist; + set + { + artist = value; + OnUpdate?.Invoke(this); + OnPropertyChanged(nameof(Artist)); + } + } + + public Action? OnUpdate { private get; init; } + + [ObservableProperty] + private int progress; + } +} diff --git a/CUERipper.Avalonia/ViewModels/ViewModelBase.cs b/CUERipper.Avalonia/ViewModels/ViewModelBase.cs new file mode 100644 index 000000000..5cd7e7ee2 --- /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/Abstractions/ICUEDialog.cs b/CUERipper.Avalonia/Views/Abstractions/ICUEDialog.cs new file mode 100644 index 000000000..cab3f14ef --- /dev/null +++ b/CUERipper.Avalonia/Views/Abstractions/ICUEDialog.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 System; +using System.Threading.Tasks; +using Avalonia.Controls; + +namespace CUERipper.Avalonia.Views.Abstractions +{ + public interface ICUEDialog + { +#if !NET47 + abstract static Task CreateAsync(Window owner, IServiceProvider serviceProvider); +#endif + } + + public interface ICUEDialog + { +#if !NET47 + abstract static Task CreateAsync(Window owner, IServiceProvider serviceProvider, T parameters); +#endif + } + + public interface ICUEDialog + { +#if !NET47 + abstract static Task CreateAsync(Window owner, IServiceProvider serviceProvider, T parameters); +#endif + } +} diff --git a/CUERipper.Avalonia/Views/EncoderOptionsDialog.axaml b/CUERipper.Avalonia/Views/EncoderOptionsDialog.axaml new file mode 100644 index 000000000..2f64c1ca5 --- /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 000000000..3f177e50b --- /dev/null +++ b/CUERipper.Avalonia/Views/EncoderOptionsDialog.axaml.cs @@ -0,0 +1,122 @@ +#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 CUERipper.Avalonia.Views.Abstractions; +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, ICUEDialog +{ + 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, IServiceProvider serviceProvider, 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 000000000..211784269 --- /dev/null +++ b/CUERipper.Avalonia/Views/MainWindow.axaml @@ -0,0 +1,304 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 000000000..a3164a0ef --- /dev/null +++ b/CUERipper.Avalonia/Views/OptionsDialog.axaml.cs @@ -0,0 +1,127 @@ +#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 CUERipper.Avalonia.Views.Abstractions; +using CUETools.CTDB; +using CUETools.Processor; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; + +namespace CUERipper.Avalonia; + +public partial class OptionsDialog : Window, ICUEDialog +{ + 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 IntOptionProxy("Max album art size" + , defaultValue: CUEConfig.Constants.MaxAlbumArtSize + , minValue: CUEConfig.Constants.MaxAlbumArtSizeLowerBound + , maxValue: CUEConfig.Constants.MaxAlbumArtSizeUpperBound + , new(() => Config.MaxAlbumArtSize)) + , 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" + , defaultValue: 8080 + , minValue: 0 + , maxValue: 65535 + , 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)) + , new BoolOptionProxy("Check for updates", true + , new(() => Config.CheckForUpdates)) + }.MoveAll(ViewModel.VariousOptions); + } + + public static async Task CreateAsync(Window owner, IServiceProvider serviceProvider) + { + var optionsWindow = new OptionsDialog() + { + Owner = owner + , Config = serviceProvider.GetRequiredService() + , 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 000000000..c37ecd250 --- /dev/null +++ b/CUERipper.Avalonia/Views/PathFormatDialog.axaml @@ -0,0 +1,63 @@ + + + + + + +