From ce88aa76259d93c98201a4f3084dfb85fa4fa2e9 Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Tue, 27 Oct 2026 18:23:26 +0400 Subject: [PATCH 01/52] wip: backup of local v0.6.5 work (keystore v2, smart playlists, M3U, registry, docs) - not for merge --- Directory.Packages.props | 1 + Helpers/NetworkHosts.cs | 22 ++ Models/Settings/DownloaderConsent.cs | 13 + Models/Settings/Preferences.cs | 1 + Models/UI/NavItem.cs | 5 +- NullWave.csproj | 1 + PRIVACY.md | 41 ++- Services/Download/DownloadService.cs | 74 +++- Services/Library/M3uImporter.cs | 141 ++++++++ Services/Library/M3uParser.cs | 99 ++++++ Services/Library/M3uWriter.cs | 62 ++++ Services/Library/SmartPlaylistIds.cs | 23 ++ Services/Library/SmartPlaylistService.cs | 91 +++++ Services/Plugins/IPlugin.cs | 8 +- Services/Plugins/IProvider.cs | 10 + Services/Plugins/LastFmMetadataProvider.cs | 6 + Services/Plugins/OllamaAIProvider.cs | 6 + Services/Plugins/OpenWeatherProvider.cs | 6 + Services/Plugins/ProviderCaller.cs | 42 +++ Services/Plugins/ProviderDescriptor.cs | 11 + Services/Plugins/ProviderKind.cs | 33 ++ Services/Plugins/ProviderRegistry.cs | 163 +++++++++ Services/Plugins/YtDlpDownloadProvider.cs | 6 + Services/Security/ISecretProtector.cs | 43 +++ Services/Security/KeyStoreService.cs | 166 ++++++--- Services/Security/SecretServiceProtector.cs | 125 +++++++ .../Dialogs/DownloaderConsentViewModel.cs | 43 +++ .../Dialogs/M3uImportDialogViewModel.cs | 57 ++++ ViewModels/Library/SmartPlaylistViewModel.cs | 68 ++++ ViewModels/Playlists/PlaylistViewModel.cs | 191 ++++++----- ViewModels/Shell/MainViewModel.Integration.cs | 20 +- ViewModels/Shell/MainViewModel.cs | 4 +- ViewModels/Shell/NavigationViewModel.cs | 85 ++++- ViewModels/Tools/TrackInputViewModel.cs | 19 +- Views/Controls/PlaylistsView.axaml | 318 +++++++++++++----- Views/Controls/PlaylistsView.axaml.cs | 123 ++++++- Views/Dialogs/DownloaderConsentView.axaml | 54 +++ Views/Dialogs/DownloaderConsentView.axaml.cs | 12 + Views/Dialogs/M3uImportDialog.axaml | 60 ++++ Views/Dialogs/M3uImportDialog.axaml.cs | 12 + tests/NullWave.Tests/DocsTruthTests.cs | 66 ++++ tests/NullWave.Tests/DownloadServiceTests.cs | 16 +- tests/NullWave.Tests/M3uWriterTests.cs | 61 ++++ .../SmartPlaylistServiceTests.cs | 86 +++++ 44 files changed, 2220 insertions(+), 274 deletions(-) create mode 100644 Helpers/NetworkHosts.cs create mode 100644 Models/Settings/DownloaderConsent.cs create mode 100644 Services/Library/M3uImporter.cs create mode 100644 Services/Library/M3uParser.cs create mode 100644 Services/Library/M3uWriter.cs create mode 100644 Services/Library/SmartPlaylistIds.cs create mode 100644 Services/Library/SmartPlaylistService.cs create mode 100644 Services/Plugins/IProvider.cs create mode 100644 Services/Plugins/ProviderCaller.cs create mode 100644 Services/Plugins/ProviderDescriptor.cs create mode 100644 Services/Plugins/ProviderKind.cs create mode 100644 Services/Plugins/ProviderRegistry.cs create mode 100644 Services/Security/ISecretProtector.cs create mode 100644 Services/Security/SecretServiceProtector.cs create mode 100644 ViewModels/Dialogs/DownloaderConsentViewModel.cs create mode 100644 ViewModels/Dialogs/M3uImportDialogViewModel.cs create mode 100644 ViewModels/Library/SmartPlaylistViewModel.cs create mode 100644 Views/Dialogs/DownloaderConsentView.axaml create mode 100644 Views/Dialogs/DownloaderConsentView.axaml.cs create mode 100644 Views/Dialogs/M3uImportDialog.axaml create mode 100644 Views/Dialogs/M3uImportDialog.axaml.cs create mode 100644 tests/NullWave.Tests/DocsTruthTests.cs create mode 100644 tests/NullWave.Tests/M3uWriterTests.cs create mode 100644 tests/NullWave.Tests/SmartPlaylistServiceTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index fec0eb0..14e55e4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/Helpers/NetworkHosts.cs b/Helpers/NetworkHosts.cs new file mode 100644 index 0000000..a8a2e40 --- /dev/null +++ b/Helpers/NetworkHosts.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace NullWave.Helpers; + +public record NetworkHost(string Host, string Purpose, string? PluginId = null, bool RequiresKey = false); + +public static class NetworkHosts +{ + public static readonly IReadOnlyList All = new NetworkHost[] + { + new("api.openweathermap.org", "Weather data for mood playlists", "openweather", RequiresKey: true), + new("ws.audioscrobbler.com", "Last.fm metadata and scrobbling", "lastfm", RequiresKey: true), + new("www.googleapis.com", "YouTube metadata", "youtube", RequiresKey: true), + new("img.youtube.com", "YouTube thumbnails", "youtube"), + new("api.deezer.com", "Fallback metadata", "deezer"), + new("radio-browser.info", "Radio station directory", "radio"), + new("somafm.com", "SomaFM radio streams", "radio"), + new("open.spotify.com", "Spotify page metadata (scraping)", "spotify"), + new("github.com", "Update checks", null), + new("ollama.ai", "Local AI model downloads", "ollama"), + }; +} \ No newline at end of file diff --git a/Models/Settings/DownloaderConsent.cs b/Models/Settings/DownloaderConsent.cs new file mode 100644 index 0000000..c6749f1 --- /dev/null +++ b/Models/Settings/DownloaderConsent.cs @@ -0,0 +1,13 @@ +using System; + +namespace NullWave.Models; + +public class DownloaderConsent +{ + public const int CurrentVersion = 1; + + public int AcceptedVersion { get; set; } + public DateTime AcceptedAtUtc { get; set; } + + public bool IsCurrent => AcceptedVersion >= CurrentVersion; +} \ No newline at end of file diff --git a/Models/Settings/Preferences.cs b/Models/Settings/Preferences.cs index 24f22e7..b65efee 100644 --- a/Models/Settings/Preferences.cs +++ b/Models/Settings/Preferences.cs @@ -82,4 +82,5 @@ public class Preferences public string WallpaperBuiltInId { get; set; } = string.Empty; public List UnlockedExclusiveWallpapers { get; set; } = new(); public bool EnableExperimentalFeatures { get; set; } = false; + public DownloaderConsent? DownloaderConsent { get; set; } } \ No newline at end of file diff --git a/Models/UI/NavItem.cs b/Models/UI/NavItem.cs index 29f1f30..27a2e71 100644 --- a/Models/UI/NavItem.cs +++ b/Models/UI/NavItem.cs @@ -6,7 +6,7 @@ namespace NullWave.Models; -public enum NavItemType { Core, PinnedPlaylist, SavedSearch } +public enum NavItemType { Core, PinnedPlaylist, SavedSearch, SmartPlaylist } public partial class NavItem : ObservableObject { @@ -26,7 +26,8 @@ public partial class NavItem : ObservableObject [ObservableProperty] private Playlist? _playlist; public bool HasSubtitle => !string.IsNullOrWhiteSpace(Subtitle); - public bool CanUnpin => Type != NavItemType.Core; + // Smart playlists can't be unpinned (they're always visible) + public bool CanUnpin => Type != NavItemType.Core && Type != NavItemType.SmartPlaylist; public ICommand? Command { get; set; } diff --git a/NullWave.csproj b/NullWave.csproj index 90eb1e4..6598462 100644 --- a/NullWave.csproj +++ b/NullWave.csproj @@ -39,6 +39,7 @@ + diff --git a/PRIVACY.md b/PRIVACY.md index 7a45c54..a852b43 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,23 +1,42 @@ # Privacy Policy ## Local-First Architecture -NullWave is a local-first application. Your music library, playlists, listening history, and preferences are stored exclusively on your device in `~/.nullwave/`. We do not operate servers, collect analytics, or transmit your personal data. +NullWave is a local-first application. Your music library, playlists, listening history, and preferences are stored exclusively on your device in `~/.nullwave/` (Linux/macOS) or `%APPDATA%\NullWave\` (Windows). We do not operate servers, collect analytics, or transmit your personal data. -## Third-Party Integrations -When you enable optional plugins, the following data may be shared with third-party services: +## Third-Party Network Contacts -- **YouTube Data API** (Google): Video IDs, search queries, and metadata requests. -- **Last.fm**: Track titles, artists, and scrobble timestamps (opt-in, requires your API key). -- **OpenWeather**: Latitude/longitude coordinates for weather-based mood playlists (requires your API key). -- **yt-dlp**: Contacts various platforms to download audio. No data is sent to NullWave's servers (we have none). +When you use certain features, NullWave may contact these external services: -All API keys are stored locally in an encrypted keystore on your device. +### Music Metadata & Streaming +- **api.openweathermap.org**: Weather data for mood-based playlists (requires your API key) +- **ws.audioscrobbler.com**: Last.fm scrobbling and metadata (requires your API key) +- **www.googleapis.com**: YouTube Data API for video metadata (requires your API key) +- **img.youtube.com**: YouTube video thumbnails +- **api.deezer.com**: Fallback metadata service +- **open.spotify.com**: Spotify page metadata (public scraping, no API key) + +### Radio & Streaming +- **radio-browser.info**: Community radio station directory +- **somafm.com**: SomaFM internet radio streams + +### Downloads (via yt-dlp) +- **Various platforms**: YouTube, SoundCloud, Bandcamp, and 1000+ other sites supported by yt-dlp +- **github.com**: yt-dlp version checks and updates + +### Local AI +- **ollama.ai**: Downloads AI models for local mood playlist generation (runs entirely on your device) + +All API keys are stored locally in an encrypted keystore on your device. Third-party services have their own privacy policies and see your IP address when contacted. ## AI Features -AI-powered features (mood playlists, smart sorting) run locally on your device via Ollama. No audio, metadata, or listening habits are sent to external AI providers. AI-generated content is based on your local library; NullWave is not responsible for user-provided prompts. +AI-powered features (mood playlists, smart sorting) run locally on your device via Ollama. No audio, metadata, or listening habits are sent to external AI providers. All processing happens on your machine. ## Data Deletion -You have full control over your local files. Use the "Remove" context menu, "Sweep orphaned files" maintenance tool, or delete the `~/.nullwave/` directory to permanently remove all data. +You have full control over your local files: +- Use the "Remove" context menu on individual tracks +- Run "Sweep orphaned files" in Settings → Maintenance +- Delete the data folder (`~/.nullwave/` or `%APPDATA%\NullWave\`) to remove everything ## Contact -NullWave is open source. Review the code, report issues, or contribute at [GitHub repo URL]. \ No newline at end of file +NullWave is open source. Review the code, report issues, or contribute at: +https://github.com/ZenQuantDev/NullWave \ No newline at end of file diff --git a/Services/Download/DownloadService.cs b/Services/Download/DownloadService.cs index da4115a..17b7d54 100644 --- a/Services/Download/DownloadService.cs +++ b/Services/Download/DownloadService.cs @@ -115,6 +115,9 @@ void Prune() private static readonly Regex SiParamRegex1 = new(@"&si=[^&]*", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex SiParamRegex2 = new(@"\?si=[^&]*&", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex SiParamRegex3 = new(@"\?si=[^&]*$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // NEW: Regex to strip list= parameter from URLs + private static readonly Regex ListParamRegex = new(@"[?&]list=[^&]*", RegexOptions.Compiled); private readonly HashSet _activeDownloads = new(); private int _activeDownloadCount; @@ -190,8 +193,7 @@ private static async Task IsFfmpegAvailableAsync() return false; } - - + // UPDATED: JS runtime detection - removed Bun (deprecated), added QuickJS private static string? GetJsRuntimeArg() { if (_jsRuntimeProbed) return _cachedJsRuntime; @@ -209,9 +211,10 @@ static bool OnPath(string name) string? result = null; bool found = false; + // Order: Deno (preferred) → Node → QuickJS if (OnPath("deno")) { result = null; found = true; } // yt-dlp's default else if (OnPath("node")) { result = "node"; found = true; } - else if (OnPath("bun")) { result = "bun"; found = true; } + else if (OnPath("qjs") || OnPath("quickjs")) { result = "qjs"; found = true; } if (found) { @@ -221,6 +224,7 @@ static bool OnPath(string name) return result; } + // UPDATED: Removed Bun from detection private static bool HasAnyJsRuntime() { static bool OnPath(string name) @@ -233,7 +237,7 @@ static bool OnPath(string name) } return false; } - return OnPath("deno") || OnPath("node") || OnPath("bun"); + return OnPath("deno") || OnPath("node") || OnPath("qjs") || OnPath("quickjs"); } private void AppendSpeedAndAuthArgs(List args, bool useAria2c) @@ -266,6 +270,21 @@ private int GetThrottleDelayMs() ? Random.Shared.Next(3000, 8000) : Random.Shared.Next(600, 1800); } + + private bool CheckConsent() + { + var consent = _prefsService.Current.DownloaderConsent; + if (consent == null || !consent.IsCurrent) + { + Log.Warning("[DownloadService] Download blocked: consent not given or outdated"); + ToastService.Instance.Show( + "Please accept the downloader terms in Settings → Downloads", + ToastType.Warning, + scope: "download"); + return false; + } + return true; + } public async Task DownloadAsync( string trackId, @@ -278,6 +297,13 @@ public async Task DownloadAsync( string? artist = null, CancellationToken ct = default) { + // CRITICAL FIX: Check consent FIRST + if (!CheckConsent()) + { + DownloadFailed?.Invoke(trackId, "Download terms not accepted", isInteractive); + return; + } + if (!Uri.TryCreate(url, UriKind.Absolute, out var uriResult) || (uriResult.Scheme != Uri.UriSchemeHttp && uriResult.Scheme != Uri.UriSchemeHttps)) { @@ -293,7 +319,16 @@ public async Task DownloadAsync( url = SiParamRegex3.Replace(url, ""); } - if (!allowPlaylist && (url.Contains("list=") || url.Contains("playlist?"))) + // FIXED: Strip list= parameter instead of rejecting + if (!allowPlaylist && url.Contains("list=")) + { + url = ListParamRegex.Replace(url, ""); + // Clean up any trailing ? or & after removal + url = url.TrimEnd('?', '&'); + Log.Information("[DownloadService] Stripped list= parameter from URL: {Url}", url); + } + + if (!allowPlaylist && url.Contains("playlist?")) { Log.Warning("[DownloadService] Blocked playlist URL in single-track pipeline: {Url}", url); DownloadFailed?.Invoke(trackId, "Playlist URLs are not supported in single-track mode", isInteractive); @@ -310,10 +345,6 @@ public async Task DownloadAsync( var useFfmpeg = await IsFfmpegAvailableAsync(); - // If ffmpeg is missing, or if the user selected "best" (which skips --extract-audio), - // yt-dlp will download the native container (often WebM/Opus on YouTube). - // Our pruned Windows VLC build lacks the Matroska/WebM demuxer. - // Force m4a (AAC in MP4 container) on Windows to guarantee playback. var formatSelector = ChooseFormatSelector(useFfmpeg, audioFormat, OperatingSystem.IsWindows()); var args = new List @@ -352,7 +383,7 @@ public async Task DownloadAsync( if (_activeDownloads.Contains(url)) { Log.Debug("[DownloadService] Skipping duplicate download for {Url}", url); - return; // Silent return — duplicate clicks aren't errors, no toast needed + return; } _activeDownloads.Add(url); } @@ -405,7 +436,6 @@ public async Task DownloadAsync( string? outputFilePath = null; - // --- STEP E: Unified ProcessRunner Execution --- var recentStdErr = new List(); var result = await ProcessRunner.RunWithStreamingAsync( "yt-dlp", args, @@ -438,7 +468,9 @@ public async Task DownloadAsync( if (recentStdErr.Count >= 20) recentStdErr.RemoveAt(0); recentStdErr.Add(line); - if (line.Contains("429") || line.Contains("Too Many Requests", StringComparison.OrdinalIgnoreCase)) + // FIXED: More precise 429 detection + if (line.Contains("HTTP Error 429") || + line.Contains("Too Many Requests", StringComparison.OrdinalIgnoreCase)) { lock (_aria2cLock) { _rateLimitTriggered = true; } } @@ -498,11 +530,12 @@ public async Task DownloadAsync( var errText = string.Join("\n", recentStdErr); string friendlyError = $"yt-dlp exited with code {result.ExitCode}"; + // UPDATED: Error message now mentions QuickJS instead of Bun if ((errText.Contains("n challenge", StringComparison.OrdinalIgnoreCase) || errText.Contains("Requested format is not available", StringComparison.OrdinalIgnoreCase)) && !HasAnyJsRuntime()) { - friendlyError = "YouTube signature error: Install Node.js, Deno, or Bun."; + friendlyError = "YouTube signature error: Install Deno, Node.js, or QuickJS."; } else if (errText.Contains("not a bot", StringComparison.OrdinalIgnoreCase)) { @@ -548,10 +581,19 @@ public async Task DownloadPlaylistAsync( Action? onTrackFailed = null, CancellationToken ct = default) { + // CRITICAL FIX: Check consent FIRST + if (!CheckConsent()) + { + ToastService.Instance.Show( + "Please accept the downloader terms in Settings → Downloads", + ToastType.Warning, + scope: "download"); + return; + } + Log.Information("Starting playlist download: {Url}", playlistUrl); try { - // --- STEP E: Unified ProcessRunner Playlist Metadata --- var metadataResult = await ProcessRunner.RunAsync( "yt-dlp", new List { "--flat-playlist", "--dump-json", "--ignore-errors", "--no-download", playlistUrl }, @@ -790,7 +832,7 @@ await DownloadAsync(trackId.ToString(), cleanUrl, _prefsService.Current.AudioFor int backoffMultiplier; lock (_aria2cLock) backoffMultiplier = _backoffMultiplier; var delayMs = baseDelay * backoffMultiplier; - Log.Debug("Throttling download to avoid rate limits... sleeping for {Delay}ms (Multiplier: {Mult}x)", delayMs, _backoffMultiplier); + Log.Debug("Throttling download to avoid rate limits... sleeping for {Delay}ms (Multiplier: {Mult}x)", delayMs, backoffMultiplier); await Task.Delay(delayMs, ct); } } @@ -841,7 +883,7 @@ await DownloadAsync(trackId.ToString(), cleanUrl, _prefsService.Current.AudioFor { if (!new[] { ".mp3", ".m4a", ".ogg", ".opus", ".wav", ".flac" }.Contains(file.Extension.ToLowerInvariant())) continue; if (linkedPaths.Contains(file.FullName)) continue; - if (newest == null || file.LastWriteTimeUtc > newest.LastWriteTimeUtc) newest = file; // Step C UTC fix applied + if (newest == null || file.LastWriteTimeUtc > newest.LastWriteTimeUtc) newest = file; } if (newest == null) return null; diff --git a/Services/Library/M3uImporter.cs b/Services/Library/M3uImporter.cs new file mode 100644 index 0000000..c436edd --- /dev/null +++ b/Services/Library/M3uImporter.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using NullWave.Helpers; +using NullWave.Models; + +namespace NullWave.Services; + +public sealed record M3uImportMatch( + M3uEntry Entry, + Track? MatchedTrack, + M3uMatchType MatchType +); + +public enum M3uMatchType +{ + NotFound, + PathMatch, + UrlMatch, + ArtistTitleMatch +} + +public sealed record M3uImportPreview( + IReadOnlyList Matches, + int TotalEntries, + int MatchedCount, + int MissingCount, + string? PlaylistName +); + +public class M3uImporter +{ + private readonly LibraryService _library; + + public M3uImporter(LibraryService library) + { + _library = library; + } + + public M3uImportPreview Preview(string m3uPath) + { + var bytes = File.ReadAllBytes(m3uPath); + var baseDir = Path.GetDirectoryName(m3uPath); + var parsed = M3uParser.ParseBytes(bytes, baseDir); + + if (!parsed.Ok) + return new M3uImportPreview(Array.Empty(), 0, 0, 0, null); + + var matches = new List(); + var allTracks = _library.GetAll(); + + foreach (var entry in parsed.Entries) + { + var match = FindMatch(entry, allTracks); + matches.Add(match); + } + + var matchedCount = matches.Count(m => m.MatchType != M3uMatchType.NotFound); + var missingCount = matches.Count(m => m.MatchType == M3uMatchType.NotFound); + + return new M3uImportPreview(matches, parsed.Entries.Count, matchedCount, missingCount, parsed.PlaylistName); + } + + private M3uImportMatch FindMatch(M3uEntry entry, IReadOnlyList allTracks) + { + if (!entry.IsUrl && !string.IsNullOrEmpty(entry.Target)) + { + var normalizedTarget = Path.GetFullPath(entry.Target); + var pathMatch = allTracks.FirstOrDefault(t => + !string.IsNullOrEmpty(t.FilePath) && + Path.GetFullPath(t.FilePath).Equals(normalizedTarget, StringComparison.OrdinalIgnoreCase)); + + if (pathMatch != null) + return new M3uImportMatch(entry, pathMatch, M3uMatchType.PathMatch); + } + + if (entry.IsUrl && !string.IsNullOrEmpty(entry.Target)) + { + var normalizedUrl = NormalizeUrl(entry.Target); + var urlMatch = allTracks.FirstOrDefault(t => + !string.IsNullOrEmpty(t.Url) && + NormalizeUrl(t.Url).Equals(normalizedUrl, StringComparison.OrdinalIgnoreCase)); + + if (urlMatch != null) + return new M3uImportMatch(entry, urlMatch, M3uMatchType.UrlMatch); + } + + if (!string.IsNullOrEmpty(entry.Title) || !string.IsNullOrEmpty(entry.Artist)) + { + // TrackNormalizer is static - call methods directly + var titleNorm = entry.Title != null ? TrackNormalizer.NormalizeForCompare(entry.Title) : ""; + var artistNorm = entry.Artist != null ? TrackNormalizer.NormalizeForCompare(entry.Artist) : ""; + + var fuzzyMatch = allTracks.FirstOrDefault(t => + { + var tTitle = TrackNormalizer.NormalizeForCompare(t.Title); + var tArtist = TrackNormalizer.NormalizeForCompare(t.Artist); + + var titleMatch = !string.IsNullOrEmpty(titleNorm) && tTitle.Contains(titleNorm, StringComparison.Ordinal); + var artistMatch = !string.IsNullOrEmpty(artistNorm) && tArtist.Contains(artistNorm, StringComparison.Ordinal); + + return titleMatch && artistMatch; + }); + + if (fuzzyMatch != null) + return new M3uImportMatch(entry, fuzzyMatch, M3uMatchType.ArtistTitleMatch); + } + + return new M3uImportMatch(entry, null, M3uMatchType.NotFound); + } + + private static string NormalizeUrl(string url) + { + if (url.Contains("youtube.com") || url.Contains("youtu.be")) + { + try + { + var uri = new Uri(url); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + var videoId = query["v"]; + + if (string.IsNullOrEmpty(videoId) && url.Contains("youtu.be/")) + { + var parts = url.Split("youtu.be/"); + if (parts.Length > 1) + videoId = parts[1].Split('?')[0]; + } + + return videoId != null ? $"youtube:{videoId}" : url; + } + catch + { + return url; + } + } + + return url; + } +} \ No newline at end of file diff --git a/Services/Library/M3uParser.cs b/Services/Library/M3uParser.cs new file mode 100644 index 0000000..b8f47c4 --- /dev/null +++ b/Services/Library/M3uParser.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace NullWave.Services; + +public sealed record M3uEntry(string Target, bool IsUrl, string? Title, string? Artist, int? DurationSeconds); +public sealed record M3uParseResult(IReadOnlyList Entries, string? PlaylistName, string? Error) +{ + public bool Ok => Error == null; +} + +public static class M3uParser +{ + public const int MaxBytes = 5 * 1024 * 1024; + public const int MaxEntries = 20_000; + private static readonly Regex UrlScheme = new(@"^[a-zA-Z][a-zA-Z0-9+.\-]+://", RegexOptions.Compiled); + + public static M3uParseResult ParseBytes(byte[] data, string? baseDir) + { + if (data.Length > MaxBytes) return Fail("Playlist file is too large."); + return Parse(Decode(data), baseDir); + } + + public static string Decode(byte[] d) + { + if (d.Length >= 3 && d[0] == 0xEF && d[1] == 0xBB && d[2] == 0xBF) return Encoding.UTF8.GetString(d, 3, d.Length - 3); + if (d.Length >= 2 && d[0] == 0xFF && d[1] == 0xFE) return Encoding.Unicode.GetString(d, 2, d.Length - 2); + if (d.Length >= 2 && d[0] == 0xFE && d[1] == 0xFF) return Encoding.BigEndianUnicode.GetString(d, 2, d.Length - 2); + try { return new UTF8Encoding(false, throwOnInvalidBytes: true).GetString(d); } + catch (DecoderFallbackException) { return Encoding.Latin1.GetString(d); } + } + + public static M3uParseResult Parse(string text, string? baseDir) + { + var entries = new List(); + string? playlistName = null; + string? pendingTitle = null, pendingArtist = null; + int? pendingDuration = null; + + foreach (var raw in text.Split('\n')) + { + var line = raw.Trim('\r', ' ', '\t', '\uFEFF'); + if (line.Length == 0) continue; + + if (line.StartsWith("#EXT-X-", StringComparison.OrdinalIgnoreCase)) + return Fail("This is a streaming (HLS) manifest, not a playlist of songs."); + + if (line.StartsWith('#')) + { + if (line.StartsWith("#PLAYLIST:", StringComparison.OrdinalIgnoreCase)) + playlistName = line[10..].Trim(); + else if (line.StartsWith("#EXTINF:", StringComparison.OrdinalIgnoreCase)) + (pendingDuration, pendingArtist, pendingTitle) = ParseExtInf(line[8..]); + continue; + } + + if (entries.Count >= MaxEntries) return Fail("Playlist has too many entries."); + entries.Add(MakeEntry(line, baseDir, pendingTitle, pendingArtist, pendingDuration)); + pendingTitle = pendingArtist = null; pendingDuration = null; + } + return new M3uParseResult(entries, playlistName, null); + } + + private static (int? Seconds, string? Artist, string? Title) ParseExtInf(string body) + { + var comma = body.IndexOf(','); + var head = comma >= 0 ? body[..comma] : body; + var display = comma >= 0 ? body[(comma + 1)..].Trim() : ""; + + int? seconds = null; + var m = Regex.Match(head.Trim(), @"^-?\d+"); + if (m.Success && int.TryParse(m.Value, out var s) && s >= 0) seconds = s; + + if (display.Length == 0) return (seconds, null, null); + var sep = display.IndexOf(" - ", StringComparison.Ordinal); + return sep > 0 ? (seconds, display[..sep].Trim(), display[(sep + 3)..].Trim()) : (seconds, null, display); + } + + private static M3uEntry MakeEntry(string target, string? baseDir, string? title, string? artist, int? seconds) + { + if (target.StartsWith("file://", StringComparison.OrdinalIgnoreCase) + && Uri.TryCreate(target, UriKind.Absolute, out var fileUri)) + return new M3uEntry(fileUri.LocalPath, false, title, artist, seconds); + + if (UrlScheme.IsMatch(target)) + return new M3uEntry(target, true, title, artist, seconds); + + var path = target.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); + if (!Path.IsPathRooted(path) && !string.IsNullOrEmpty(baseDir)) + path = Path.GetFullPath(Path.Combine(baseDir, path)); + return new M3uEntry(path, false, title, artist, seconds); + } + + private static M3uParseResult Fail(string error) => new(Array.Empty(), null, error); +} \ No newline at end of file diff --git a/Services/Library/M3uWriter.cs b/Services/Library/M3uWriter.cs new file mode 100644 index 0000000..7cd512e --- /dev/null +++ b/Services/Library/M3uWriter.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using NullWave.Helpers; +using NullWave.Models; + +namespace NullWave.Services; + +public sealed record M3uWriteOptions(bool RelativePaths = true, string? PlaylistName = null); +public sealed record M3uWriteResult(string Text, int Written, int SkippedMissing); + +public static class M3uWriter +{ + public static readonly UTF8Encoding Utf8NoBom = new(false); + + public static M3uWriteResult Build(IEnumerable tracks, string m3uFilePath, M3uWriteOptions? options = null, + Func? fileExists = null) + { + options ??= new M3uWriteOptions(); + fileExists ??= File.Exists; + var baseDir = Path.GetDirectoryName(Path.GetFullPath(m3uFilePath)) ?? string.Empty; + + var sb = new StringBuilder("#EXTM3U\n"); + if (!string.IsNullOrWhiteSpace(options.PlaylistName)) + sb.Append("#PLAYLIST:").Append(OneLine(options.PlaylistName)).Append('\n'); + + int written = 0, skipped = 0; + foreach (var t in tracks) + { + string? target; + if (t.MediaType == MediaType.Radio && !string.IsNullOrWhiteSpace(t.Url)) + target = t.Url!.Trim(); + else if (!string.IsNullOrEmpty(t.FilePath) && fileExists(t.FilePath)) + target = FormatPath(t.FilePath, baseDir, options.RelativePaths); + else { skipped++; continue; } + + var seconds = t.Duration.TotalSeconds > 0 ? (int)Math.Round(t.Duration.TotalSeconds) : -1; + + // TrackNormalizer is static in NullWave + var title = OneLine(TrackNormalizer.CleanTitle(t.Title)); + var artist = TrackNormalizer.IsPlaceholderArtist(t.Artist) ? "" : OneLine(TrackNormalizer.CleanArtist(t.Artist)); + var display = artist.Length > 0 ? $"{artist} - {title}" : title; + + sb.Append("#EXTINF:").Append(seconds).Append(',').Append(display).Append('\n'); + sb.Append(target).Append('\n'); + written++; + } + return new M3uWriteResult(sb.ToString(), written, skipped); + } + + public static void WriteFile(string path, string text) => File.WriteAllText(path, text, Utf8NoBom); + + private static string FormatPath(string filePath, string baseDir, bool relative) + { + var full = Path.GetFullPath(filePath); + var chosen = relative && baseDir.Length > 0 ? Path.GetRelativePath(baseDir, full) : full; + return chosen.Replace('\\', '/'); + } + + private static string OneLine(string s) => s.Replace("\r", " ").Replace("\n", " ").Trim(); +} \ No newline at end of file diff --git a/Services/Library/SmartPlaylistIds.cs b/Services/Library/SmartPlaylistIds.cs new file mode 100644 index 0000000..479c7e9 --- /dev/null +++ b/Services/Library/SmartPlaylistIds.cs @@ -0,0 +1,23 @@ +using System; + +namespace NullWave.Services; + +/// Reserved, never-persisted GUIDs that represent virtual smart playlists in the sidebar. +public static class SmartPlaylistIds +{ + public static Guid ToGuid(string defId) => defId switch + { + "smart.recently-added" => Guid.Parse("00000000-0000-0000-0000-000000000001"), + "smart.most-played" => Guid.Parse("00000000-0000-0000-0000-000000000002"), + "smart.forgotten-favorites" => Guid.Parse("00000000-0000-0000-0000-000000000003"), + _ => Guid.Empty + }; + + public static string? ToDefId(Guid id) => id.ToString() switch + { + "00000000-0000-0000-0000-000000000001" => "smart.recently-added", + "00000000-0000-0000-0000-000000000002" => "smart.most-played", + "00000000-0000-0000-0000-000000000003" => "smart.forgotten-favorites", + _ => null + }; +} \ No newline at end of file diff --git a/Services/Library/SmartPlaylistService.cs b/Services/Library/SmartPlaylistService.cs new file mode 100644 index 0000000..4657c0b --- /dev/null +++ b/Services/Library/SmartPlaylistService.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NullWave.Models; + +namespace NullWave.Services; + +public record SmartPlaylistDefinition( + string Id, + string NameKey, + Func, IEnumerable> Filter, + Func PrimarySort, + Func SecondarySort, + bool SecondaryDescending, + int Limit +); + +public class SmartPlaylistService +{ + private readonly LibraryService _library; + private readonly TimeProvider _clock; + + public SmartPlaylistService(LibraryService library, TimeProvider clock) + { + _library = library; + _clock = clock; + } + + public static readonly SmartPlaylistDefinition RecentlyAdded = new( + Id: "smart.recently-added", + NameKey: "SmartPlaylist_RecentlyAdded", + Filter: tracks => tracks.Where(t => t.MediaType != MediaType.Radio && t.MediaType != MediaType.Audiobook), + PrimarySort: t => t.DateAdded, + SecondarySort: t => t.Title, + SecondaryDescending: false, + Limit: 100 + ); + + public static readonly SmartPlaylistDefinition MostPlayed = new( + Id: "smart.most-played", + NameKey: "SmartPlaylist_MostPlayed", + Filter: tracks => tracks.Where(t => t.PlayCount >= 1 && t.MediaType != MediaType.Radio && t.MediaType != MediaType.Audiobook), + PrimarySort: t => t.PlayCount, + SecondarySort: t => t.LastPlayed, + SecondaryDescending: true, + Limit: 100 + ); + + public SmartPlaylistDefinition ForgottenFavorites => new( + Id: "smart.forgotten-favorites", + NameKey: "SmartPlaylist_ForgottenFavorites", + Filter: tracks => + { + var cutoff = _clock.GetUtcNow().AddDays(-90).UtcDateTime; + return tracks.Where(t => + t.PlayCount >= 2 && + t.LastPlayed.HasValue && + t.LastPlayed.Value < cutoff && + t.MediaType != MediaType.Radio && + t.MediaType != MediaType.Audiobook); + }, + PrimarySort: t => t.PlayCount, + SecondarySort: t => t.LastPlayed, + SecondaryDescending: false, + Limit: 100 + ); + + public IReadOnlyList All => new[] { RecentlyAdded, MostPlayed, ForgottenFavorites }; + + public IReadOnlyList Resolve(SmartPlaylistDefinition definition) + { + var all = _library.GetAll(); + return ResolveInternal(definition, all); + } + + // Exposed for unit testing without needing a real database + internal IReadOnlyList ResolveInternal(SmartPlaylistDefinition definition, IEnumerable library) + { + var filtered = definition.Filter(library.ToList()); + + return definition.SecondaryDescending + ? filtered.OrderByDescending(definition.PrimarySort) + .ThenByDescending(definition.SecondarySort) + .Take(definition.Limit) + .ToList() + : filtered.OrderByDescending(definition.PrimarySort) + .ThenBy(definition.SecondarySort) + .Take(definition.Limit) + .ToList(); + } +} \ No newline at end of file diff --git a/Services/Plugins/IPlugin.cs b/Services/Plugins/IPlugin.cs index 0860a0e..6c54913 100644 --- a/Services/Plugins/IPlugin.cs +++ b/Services/Plugins/IPlugin.cs @@ -16,23 +16,23 @@ public interface IPlugin Task ShutdownAsync(CancellationToken ct = default); } -public interface IDownloadProvider : IPlugin +public interface IDownloadProvider : IProvider { bool SupportsUrl(string url); Task DownloadAsync(string url, DownloadOptions options, CancellationToken ct = default); } -public interface IMetadataProvider : IPlugin +public interface IMetadataProvider : IProvider { Task FetchMetadataAsync(string identifier, TrackSource source, CancellationToken ct = default); } -public interface IAIProvider : IPlugin +public interface IAIProvider : IProvider { Task> RankTracksAsync(IEnumerable tracks, string context, CancellationToken ct = default); } -public interface IWeatherProvider : IPlugin +public interface IWeatherProvider : IProvider { Task GetCurrentWeatherAsync(double lat, double lon, CancellationToken ct = default); } diff --git a/Services/Plugins/IProvider.cs b/Services/Plugins/IProvider.cs new file mode 100644 index 0000000..b79a5f9 --- /dev/null +++ b/Services/Plugins/IProvider.cs @@ -0,0 +1,10 @@ +namespace NullWave.Services.Plugins; + +/// +/// Marker interface for anything that participates in the ProviderRegistry. +/// Kind-specific interfaces (IDownloadProvider, IMetadataProvider, etc.) extend this. +/// +public interface IProvider : IPlugin +{ + ProviderDescriptor Descriptor { get; } +} \ No newline at end of file diff --git a/Services/Plugins/LastFmMetadataProvider.cs b/Services/Plugins/LastFmMetadataProvider.cs index 6e4dba8..456e379 100644 --- a/Services/Plugins/LastFmMetadataProvider.cs +++ b/Services/Plugins/LastFmMetadataProvider.cs @@ -23,6 +23,12 @@ public class LastFmMetadataProvider : IMetadataProvider public PluginState State { get; set; } = PluginState.Unavailable; public bool IsEnabled { get; set; } = true; + public ProviderDescriptor Descriptor { get; } = new( + Id: "lastfm", + Kind: ProviderKind.Metadata, + DisplayName: "Last.fm" + ); + // I expose the inner service so existing consumers (TrackDetailViewModel, // LastFmEnrichmentService) can still access it directly during migration. public LastFmService Inner => _inner; diff --git a/Services/Plugins/OllamaAIProvider.cs b/Services/Plugins/OllamaAIProvider.cs index be514e2..1272b40 100644 --- a/Services/Plugins/OllamaAIProvider.cs +++ b/Services/Plugins/OllamaAIProvider.cs @@ -24,6 +24,12 @@ public class OllamaAIProvider : IAIProvider public PluginState State { get; set; } = PluginState.Unavailable; public bool IsEnabled { get; set; } = true; + public ProviderDescriptor Descriptor { get; } = new( + Id: "ollama", + Kind: ProviderKind.Mood, + DisplayName: "Ollama Local AI" + ); + public LocalAIService Inner => _inner; public OllamaAIProvider(LocalAIService inner, PreferencesService prefs) diff --git a/Services/Plugins/OpenWeatherProvider.cs b/Services/Plugins/OpenWeatherProvider.cs index 3a7e0d5..32dce99 100644 --- a/Services/Plugins/OpenWeatherProvider.cs +++ b/Services/Plugins/OpenWeatherProvider.cs @@ -27,6 +27,12 @@ public class OpenWeatherProvider : IWeatherProvider public PluginState State { get; set; } = PluginState.Unavailable; public bool IsEnabled { get; set; } = true; + public ProviderDescriptor Descriptor { get; } = new( + Id: "openweather", + Kind: ProviderKind.Weather, + DisplayName: "OpenWeather" + ); + public OpenWeatherProvider(WeatherService inner, PreferencesService prefs) { _inner = inner; diff --git a/Services/Plugins/ProviderCaller.cs b/Services/Plugins/ProviderCaller.cs new file mode 100644 index 0000000..4865455 --- /dev/null +++ b/Services/Plugins/ProviderCaller.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Serilog; + +namespace NullWave.Services.Plugins; + +/// +/// Wraps every call through a registered provider with timeout + try/catch + logging. +/// This is the real fault isolation boundary — not assembly loading. +/// +public static class ProviderCaller +{ + public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(8); + + public static async Task CallAsync( + IProvider provider, + Func> action, + TimeSpan? timeout = null, + CancellationToken ct = default) where T : class + { + var effectiveTimeout = timeout ?? DefaultTimeout; + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(effectiveTimeout); + + try + { + return await action(timeoutCts.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + Log.Warning("[ProviderCaller] Provider {Id} timed out after {Seconds}s", + provider.Descriptor.Id, effectiveTimeout.TotalSeconds); + return null; + } + catch (Exception ex) + { + Log.Error(ex, "[ProviderCaller] Provider {Id} threw", provider.Descriptor.Id); + return null; + } + } +} \ No newline at end of file diff --git a/Services/Plugins/ProviderDescriptor.cs b/Services/Plugins/ProviderDescriptor.cs new file mode 100644 index 0000000..752043f --- /dev/null +++ b/Services/Plugins/ProviderDescriptor.cs @@ -0,0 +1,11 @@ +namespace NullWave.Services.Plugins; + +/// Identity and activation metadata for a provider. +public sealed record ProviderDescriptor( + string Id, + ProviderKind Kind, + string DisplayName, + string? PluginId = null, + /// If set, activating this provider auto-locks the paired provider kind to the given ID. + string? PairedWithProviderId = null +); \ No newline at end of file diff --git a/Services/Plugins/ProviderKind.cs b/Services/Plugins/ProviderKind.cs new file mode 100644 index 0000000..3c7fba3 --- /dev/null +++ b/Services/Plugins/ProviderKind.cs @@ -0,0 +1,33 @@ +namespace NullWave.Services.Plugins; + +/// Categories of provider. Each kind has its own activation rules. +public enum ProviderKind +{ + /// Provides audio files from URLs. Single-active. + Download, + /// Provides track/artist/album metadata. Single-active. + Metadata, + /// Searches for tracks by query. Multi-active (fan-out). + Search, + /// Imports playlists from files/URLs. Multi-active. + PlaylistImport, + /// Provides radio stations/streams. Multi-active. + Radio, + /// Ranks/transforms tracks (AI). Single-active. + Mood, + /// Provides weather context for mood playlists. Single-active. + Weather, +} + +public static class ProviderKindExtensions +{ + /// True if only one provider of this kind can be active at a time. + public static bool IsSingleActive(this ProviderKind kind) => kind switch + { + ProviderKind.Download => true, + ProviderKind.Metadata => true, + ProviderKind.Mood => true, + ProviderKind.Weather => true, + _ => false, + }; +} \ No newline at end of file diff --git a/Services/Plugins/ProviderRegistry.cs b/Services/Plugins/ProviderRegistry.cs new file mode 100644 index 0000000..155c1fd --- /dev/null +++ b/Services/Plugins/ProviderRegistry.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Serilog; + +namespace NullWave.Services.Plugins; + +/// +/// Nuclear-style provider registry. Tracks which providers are registered +/// and which is active per kind. Thread-safe. Persists user choices via +/// the PreferencesService (injected). +/// +public sealed class ProviderRegistry +{ + private readonly ConcurrentDictionary _providers = new(); + private readonly ConcurrentDictionary _activeByKind = new(); + private readonly PreferencesService _prefs; + private readonly ILogger _logger; + + public event Action? ProvidersChanged; + + public ProviderRegistry(PreferencesService prefs, ILogger? logger = null) + { + _prefs = prefs; + _logger = logger ?? Log.ForContext(); + LoadPersistedChoices(); + } + + public string Register(IProvider provider) + { + var id = provider.Descriptor.Id; + if (!_providers.TryAdd(id, provider)) + { + _logger.Warning("Provider {Id} already registered, skipping", id); + return id; + } + + // First registration of a kind becomes active automatically. + // User's persisted choice wins if one exists. + var kind = provider.Descriptor.Kind; + var persisted = GetPersistedChoice(kind); + if (persisted != null && _providers.ContainsKey(persisted)) + { + _activeByKind[kind] = persisted; + } + else if (!_activeByKind.ContainsKey(kind)) + { + _activeByKind[kind] = id; + } + + _logger.Information("Registered provider: {Id} (kind={Kind}, name={Name})", + id, kind, provider.Descriptor.DisplayName); + + // Handle pairing locks: if this provider forces a paired provider, activate it. + if (provider.Descriptor.PairedWithProviderId is { } pairedId + && _providers.TryGetValue(pairedId, out _)) + { + // Find the kind of the paired provider and activate it + var paired = _providers[pairedId]; + _activeByKind[paired.Descriptor.Kind] = pairedId; + } + + ProvidersChanged?.Invoke(); + return id; + } + + public bool Unregister(string id) + { + if (!_providers.TryRemove(id, out var provider)) + return false; + + // If this was the active provider for its kind, clear the choice + var kind = provider.Descriptor.Kind; + if (_activeByKind.TryGetValue(kind, out var activeId) && activeId == id) + { + _activeByKind.TryRemove(kind, out _); + // Fall back to another registered provider of the same kind if one exists + var fallback = _providers.Values.FirstOrDefault(p => p.Descriptor.Kind == kind); + if (fallback != null) + _activeByKind[kind] = fallback.Descriptor.Id; + } + + ProvidersChanged?.Invoke(); + return true; + } + + public IReadOnlyList List(ProviderKind? kind = null) + { + var all = _providers.Values.AsEnumerable(); + if (kind.HasValue) + all = all.Where(p => p.Descriptor.Kind == kind.Value); + return all.ToList(); + } + + public IProvider? Get(string id) => + _providers.TryGetValue(id, out var p) ? p : null; + + public IProvider? GetActive(ProviderKind kind) => + _activeByKind.TryGetValue(kind, out var id) && _providers.TryGetValue(id, out var p) + ? p + : null; + + public IEnumerable GetAllActive(ProviderKind kind) => + kind.IsSingleActive() + ? (GetActive(kind) is { } single ? new[] { single } : Array.Empty()) + : _providers.Values.Where(p => p.Descriptor.Kind == kind && p.IsEnabled && p.State == PluginState.Available); + + public void SetActive(ProviderKind kind, string id) + { + if (!_providers.TryGetValue(id, out var provider)) + { + _logger.Warning("Cannot activate unknown provider {Id}", id); + return; + } + if (provider.Descriptor.Kind != kind) + { + _logger.Warning("Provider {Id} is kind {Actual}, not {Requested}", id, provider.Descriptor.Kind, kind); + return; + } + + _activeByKind[kind] = id; + PersistChoice(kind, id); + ProvidersChanged?.Invoke(); + } + + // --- Persistence --- + + private string PrefsKey(ProviderKind kind) => $"ActiveProvider:{kind}"; + + private string? GetPersistedChoice(ProviderKind kind) + { + // We store these as regular string preferences. + // Using reflection-free access via the plugin configs bag is simpler. + var cfg = _prefs.Current.PluginConfigs.FirstOrDefault(c => c.Name == "ProviderRegistry"); + return cfg?.Settings.TryGetValue(PrefsKey(kind), out var v) == true ? v : null; + } + + private void PersistChoice(ProviderKind kind, string id) + { + _prefs.Update(p => + { + var cfg = p.PluginConfigs.FirstOrDefault(c => c.Name == "ProviderRegistry"); + if (cfg == null) + { + cfg = new Models.PluginConfig { Name = "ProviderRegistry" }; + p.PluginConfigs.Add(cfg); + } + cfg.Settings[PrefsKey(kind)] = id; + }); + } + + private void LoadPersistedChoices() + { + var cfg = _prefs.Current.PluginConfigs.FirstOrDefault(c => c.Name == "ProviderRegistry"); + if (cfg == null) return; + foreach (var kind in Enum.GetValues()) + { + if (cfg.Settings.TryGetValue(PrefsKey(kind), out var id)) + _activeByKind[kind] = id; + } + } +} \ No newline at end of file diff --git a/Services/Plugins/YtDlpDownloadProvider.cs b/Services/Plugins/YtDlpDownloadProvider.cs index 5231dad..ee27edc 100644 --- a/Services/Plugins/YtDlpDownloadProvider.cs +++ b/Services/Plugins/YtDlpDownloadProvider.cs @@ -102,4 +102,10 @@ public Task DownloadAsync(string url, DownloadOptions options, C // events on the Inner service. return Task.FromResult(DownloadResult.Succeeded(string.Empty)); } + + public ProviderDescriptor Descriptor { get; } = new( + Id: "yt-dlp", + Kind: ProviderKind.Download, + DisplayName: "yt-dlp (YouTube, SoundCloud, Bandcamp)" + ); } \ No newline at end of file diff --git a/Services/Security/ISecretProtector.cs b/Services/Security/ISecretProtector.cs new file mode 100644 index 0000000..cfaaf13 --- /dev/null +++ b/Services/Security/ISecretProtector.cs @@ -0,0 +1,43 @@ +using System; +using System.Security.Cryptography; + +namespace NullWave.Services.Security; + +public interface ISecretProtector +{ + byte Id { get; } + string Name { get; } + bool IsAvailable { get; } + byte[] Protect(byte[] plain); + byte[] Unprotect(byte[] payload); +} + +internal sealed class AesGcmProtector : ISecretProtector +{ + private readonly byte[] _key; + public AesGcmProtector(byte id, string name, byte[] key) { Id = id; Name = name; _key = key; } + public byte Id { get; } + public string Name { get; } + public bool IsAvailable => true; + + public byte[] Protect(byte[] plain) + { + var nonce = RandomNumberGenerator.GetBytes(12); + var tag = new byte[16]; + var ct = new byte[plain.Length]; + using var aes = new AesGcm(_key, 16); + aes.Encrypt(nonce, plain, ct, tag); + var result = new byte[28 + ct.Length]; + nonce.CopyTo(result, 0); tag.CopyTo(result, 12); ct.CopyTo(result, 28); + return result; + } + + public byte[] Unprotect(byte[] payload) + { + if (payload.Length < 28) throw new CryptographicException("Invalid ciphertext size"); + var pt = new byte[payload.Length - 28]; + using var aes = new AesGcm(_key, 16); + aes.Decrypt(payload.AsSpan(0, 12), payload.AsSpan(28), payload.AsSpan(12, 16), pt); + return pt; + } +} \ No newline at end of file diff --git a/Services/Security/KeyStoreService.cs b/Services/Security/KeyStoreService.cs index 80e6147..88cc7ce 100644 --- a/Services/Security/KeyStoreService.cs +++ b/Services/Security/KeyStoreService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -13,24 +14,43 @@ namespace NullWave.Services.Security; public class KeyStoreService { private readonly string _storePath; - private readonly byte[] _encryptionKey; + private readonly byte[]? _encryptionKey; + private readonly ISecretProtector? _activeProtector; + private readonly ISecretProtector? _legacyProtector; private readonly object _lock = new(); private Dictionary? _cache; - /// True when an unreadable keystore was moved aside (keys must be re-entered). public bool WasRecovered { get; private set; } private bool _quarantineFailed; - public KeyStoreService() : this(NullWavePaths.KeyStorePath, DeriveKey()) + public KeyStoreService(string storePath) { - MigrateLegacyIfNeeded(); - lock (_lock) { LoadLocked(); } // surfaces WasRecovered at startup + _storePath = storePath; + + if (OperatingSystem.IsWindows()) + { + var dpapi = new DpapiProtector(); + _activeProtector = dpapi.IsAvailable ? dpapi : new LegacyDerivedProtector(storePath); + } + else if (OperatingSystem.IsLinux()) + { + var secretService = new SecretServiceProtector(); + _activeProtector = secretService.IsAvailable ? secretService : new LegacyDerivedProtector(storePath); + } + else + { + _activeProtector = new LegacyDerivedProtector(storePath); + } + + _legacyProtector = new LegacyDerivedProtector(storePath); } internal KeyStoreService(string storePath, byte[] encryptionKey) { _storePath = storePath; _encryptionKey = encryptionKey; + _activeProtector = new AesGcmProtector(1, "test", encryptionKey); + _legacyProtector = _activeProtector; Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!); } @@ -51,29 +71,6 @@ private void MigrateLegacyIfNeeded() } } - /* - * DEV NOTE: We derive the encryption key from the OS Machine ID and Username. - * This means if the user renames their Windows account or moves the drive to - * another PC, their API keys will become unreadable. We accept this trade-off - * for a zero-friction "no master password" UX. - */ - private static byte[] DeriveKey() - { - var machineId = GetMachineId(); - var username = Environment.UserName; - var raw = $"{machineId}:{username}:nullwave-keystore-v1"; - return SHA256.HashData(Encoding.UTF8.GetBytes(raw)); - } - - private static string GetMachineId() - { - if (NullWavePaths.IsLinux) - { - try { return File.ReadAllText("/etc/machine-id").Trim(); } catch { } - } - return Environment.MachineName; - } - public Dictionary LoadKeys() { lock (_lock) return new Dictionary(LoadLocked()); @@ -86,7 +83,28 @@ private Dictionary LoadLocked() try { - var json = Decrypt(File.ReadAllBytes(_storePath)); + var blob = File.ReadAllBytes(_storePath); + string json; + + // Try v2 format first (with protector) + if (blob.Length >= 6 && blob[0] == 'N' && blob[1] == 'W' && blob[2] == 'K' && blob[3] == 'S') + { + var protectorId = blob[5]; + var protector = protectorId == _activeProtector?.Id ? _activeProtector : _legacyProtector; + if (protector == null) + throw new CryptographicException($"No protector available for id {protectorId}"); + + var payload = new byte[blob.Length - 6]; + Array.Copy(blob, 6, payload, 0, payload.Length); + var plaintext = protector.Unprotect(payload); + json = Encoding.UTF8.GetString(plaintext); + } + else + { + // v1 format (legacy encryption) + json = Encoding.UTF8.GetString(DecryptLegacy(blob)); + } + return _cache = JsonSerializer.Deserialize>(json) ?? new(); } catch (Exception ex) @@ -142,12 +160,6 @@ public void DeleteAllKeys() { if (File.Exists(_storePath)) { - var size = new FileInfo(_storePath).Length; - using (var fs = new FileStream(_storePath, FileMode.Open)) - { - var noise = RandomNumberGenerator.GetBytes((int)size); - fs.Write(noise, 0, noise.Length); - } File.Delete(_storePath); Log.Warning("All API keys have been deleted"); } @@ -160,7 +172,26 @@ private void Persist(Dictionary keys) if (_quarantineFailed) throw new IOException("Keystore is unreadable and could not be moved aside; refusing to overwrite it."); - var blob = Encrypt(JsonSerializer.Serialize(keys)); + var json = JsonSerializer.Serialize(keys); + byte[] blob; + + if (_activeProtector != null) + { + // v2 format: NWKS | version(1) | protectorId(1) | payload + var plaintext = Encoding.UTF8.GetBytes(json); + var payload = _activeProtector.Protect(plaintext); + blob = new byte[6 + payload.Length]; + blob[0] = (byte)'N'; blob[1] = (byte)'W'; blob[2] = (byte)'K'; blob[3] = (byte)'S'; + blob[4] = 2; // version + blob[5] = _activeProtector.Id; + Array.Copy(payload, 0, blob, 6, payload.Length); + } + else + { + // Legacy format + blob = EncryptLegacy(json); + } + var tmp = _storePath + ".tmp"; File.WriteAllBytes(tmp, blob); if (!OperatingSystem.IsWindows()) @@ -198,14 +229,15 @@ private void ReplaceStoreFile(string temporaryPath) } private static bool IsTransientReplaceFailure(int hresult) => hresult is - unchecked((int)0x80070005) or // Access denied, commonly a read-only or briefly scanned target on Windows. - unchecked((int)0x80070020) or // Sharing violation. - unchecked((int)0x80070021); // Lock violation. + unchecked((int)0x80070005) or + unchecked((int)0x80070020) or + unchecked((int)0x80070021); - // Note: plaintext still exists briefly in managed memory (string and byte[] copies). - // This protects the file at rest only, not process memory. - private byte[] Encrypt(string plaintext) + private byte[] EncryptLegacy(string plaintext) { + if (_encryptionKey == null) + throw new InvalidOperationException("Encryption key not set"); + var nonce = new byte[12]; var tag = new byte[16]; RandomNumberGenerator.Fill(nonce); @@ -221,13 +253,15 @@ private byte[] Encrypt(string plaintext) Buffer.BlockCopy(tag, 0, result, 12, 16); Buffer.BlockCopy(ciphertext, 0, result, 28, ciphertext.Length); - // Wipe plaintext from memory immediately Array.Clear(input, 0, input.Length); return result; } - private byte[] Decrypt(byte[] blob) + private byte[] DecryptLegacy(byte[] blob) { + if (_encryptionKey == null) + throw new InvalidOperationException("Encryption key not set"); + if (blob.Length < 28) throw new CryptographicException("Invalid ciphertext size"); var nonce = blob.AsSpan(0, 12); @@ -240,4 +274,48 @@ private byte[] Decrypt(byte[] blob) return plaintext; } + + [SupportedOSPlatform("windows")] + internal sealed class DpapiProtector : ISecretProtector + { + private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("NullWave.KeyStore.v2"); + public byte Id => 2; + public string Name => "Windows DPAPI"; + public bool IsAvailable => OperatingSystem.IsWindows(); + + public byte[] Protect(byte[] plain) => + ProtectedData.Protect(plain, Entropy, DataProtectionScope.CurrentUser); + + public byte[] Unprotect(byte[] payload) => + ProtectedData.Unprotect(payload, Entropy, DataProtectionScope.CurrentUser); + } + + internal sealed class LegacyDerivedProtector : ISecretProtector + { + private readonly byte[] _key; + + public LegacyDerivedProtector(string storePath) + { + var machineId = GetMachineId(); + var username = Environment.UserName; + var raw = $"{machineId}:{username}:nullwave-keystore-v1"; + _key = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); + } + + public byte Id => 1; + public string Name => "Legacy (Machine ID)"; + public bool IsAvailable => true; + + public byte[] Protect(byte[] plain) => new AesGcmProtector(Id, Name, _key).Protect(plain); + public byte[] Unprotect(byte[] payload) => new AesGcmProtector(Id, Name, _key).Unprotect(payload); + + private static string GetMachineId() + { + if (NullWavePaths.IsLinux) + { + try { return File.ReadAllText("/etc/machine-id").Trim(); } catch { } + } + return Environment.MachineName; + } + } } \ No newline at end of file diff --git a/Services/Security/SecretServiceProtector.cs b/Services/Security/SecretServiceProtector.cs new file mode 100644 index 0000000..cc84302 --- /dev/null +++ b/Services/Security/SecretServiceProtector.cs @@ -0,0 +1,125 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +namespace NullWave.Services.Security; + +[SupportedOSPlatform("linux")] +internal sealed class SecretServiceProtector : ISecretProtector +{ + private const string SchemaName = "dev.nullwave.keystore"; + private const string AttrApp = "application"; + private const string AttrPurpose = "purpose"; + private const string Label = "NullWave keystore master key"; + + public byte Id => 3; + public string Name => "Linux Secret Service (GNOME Keyring / KWallet)"; + public bool IsAvailable => CheckAvailable(); + + private static bool CheckAvailable() + { + if (!OperatingSystem.IsLinux()) return false; + try + { + var psi = new ProcessStartInfo("secret-tool") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + psi.ArgumentList.Add("--version"); + using var p = Process.Start(psi); + p?.WaitForExit(2000); + return p?.ExitCode == 0; + } + catch { return false; } + } + + public byte[] Protect(byte[] plain) + { + var masterKey = GetOrCreateMasterKey(); + var aesProtector = new AesGcmProtector(Id, Name, masterKey); + return aesProtector.Protect(plain); + } + + public byte[] Unprotect(byte[] payload) + { + var masterKey = GetOrCreateMasterKey(); + var aesProtector = new AesGcmProtector(Id, Name, masterKey); + return aesProtector.Unprotect(payload); + } + + private byte[] GetOrCreateMasterKey() + { + var existing = LookupMasterKey(); + if (existing != null) return existing; + + var newKey = RandomNumberGenerator.GetBytes(32); + StoreMasterKey(newKey); + return newKey; + } + + private byte[]? LookupMasterKey() + { + var psi = new ProcessStartInfo("secret-tool") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + psi.ArgumentList.Add("lookup"); + psi.ArgumentList.Add(AttrApp); + psi.ArgumentList.Add(SchemaName); + psi.ArgumentList.Add(AttrPurpose); + psi.ArgumentList.Add("keystore-master"); + + using var p = Process.Start(psi); + if (p == null) return null; + var output = p.StandardOutput.ReadToEnd(); + p.WaitForExit(5000); + if (p.ExitCode != 0 || string.IsNullOrWhiteSpace(output)) return null; + + var base64 = output.Trim(); + try { return Convert.FromBase64String(base64); } + catch { return null; } + } + + private void StoreMasterKey(byte[] key) + { + var base64 = Convert.ToBase64String(key); + var psi = new ProcessStartInfo("secret-tool") + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + psi.ArgumentList.Add("store"); + psi.ArgumentList.Add("--label"); + psi.ArgumentList.Add(Label); + psi.ArgumentList.Add(AttrApp); + psi.ArgumentList.Add(SchemaName); + psi.ArgumentList.Add(AttrPurpose); + psi.ArgumentList.Add("keystore-master"); + + using var p = Process.Start(psi); + if (p == null) throw new InvalidOperationException("Failed to start secret-tool"); + + p.StandardInput.Write(base64); + p.StandardInput.Close(); + p.WaitForExit(10000); + + if (p.ExitCode != 0) + { + var err = p.StandardError.ReadToEnd(); + throw new InvalidOperationException($"secret-tool store failed: {err}"); + } + } +} \ No newline at end of file diff --git a/ViewModels/Dialogs/DownloaderConsentViewModel.cs b/ViewModels/Dialogs/DownloaderConsentViewModel.cs new file mode 100644 index 0000000..fa80e28 --- /dev/null +++ b/ViewModels/Dialogs/DownloaderConsentViewModel.cs @@ -0,0 +1,43 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using NullWave.Models; +using NullWave.Services; +using System; + +namespace NullWave.ViewModels.Dialogs; + +public partial class DownloaderConsentViewModel : ObservableObject +{ + private readonly PreferencesService _prefs; + private readonly Action _onAccepted; + private readonly Action _onDeclined; + public bool CanAccept => UserUnderstands && UserAcceptsResponsibility; + + [ObservableProperty] private bool _userUnderstands; + [ObservableProperty] private bool _userAcceptsResponsibility; + + public DownloaderConsentViewModel(PreferencesService prefs, Action onAccepted, Action onDeclined) + { + _prefs = prefs; + _onAccepted = onAccepted; + _onDeclined = onDeclined; + } + + [RelayCommand] + private void Accept() + { + var consent = new DownloaderConsent + { + AcceptedVersion = DownloaderConsent.CurrentVersion, + AcceptedAtUtc = DateTime.UtcNow + }; + _prefs.Update(p => p.DownloaderConsent = consent); + _onAccepted(); + } + + [RelayCommand] + private void Decline() + { + _onDeclined(); + } +} \ No newline at end of file diff --git a/ViewModels/Dialogs/M3uImportDialogViewModel.cs b/ViewModels/Dialogs/M3uImportDialogViewModel.cs new file mode 100644 index 0000000..836562c --- /dev/null +++ b/ViewModels/Dialogs/M3uImportDialogViewModel.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using NullWave.Models; +using NullWave.Services; + +namespace NullWave.ViewModels.Dialogs; + +public partial class M3uImportDialogViewModel : ObservableObject +{ + private readonly M3uImporter _importer; + private readonly string _m3uPath; + private readonly M3uImportPreview _preview; + + [ObservableProperty] private string _playlistName = string.Empty; + [ObservableProperty] private string _summaryText = string.Empty; + [ObservableProperty] private string _statusText = string.Empty; + [ObservableProperty] private IReadOnlyList _matches = new List(); + + public bool CanImport => Matches.Count > 0 && !string.IsNullOrWhiteSpace(PlaylistName); + + public Playlist? ImportedPlaylist { get; private set; } + + public M3uImportDialogViewModel(M3uImporter importer, string m3uPath) + { + _importer = importer; + _m3uPath = m3uPath; + _preview = importer.Preview(m3uPath); + + Matches = _preview.Matches; + PlaylistName = _preview.PlaylistName ?? System.IO.Path.GetFileNameWithoutExtension(m3uPath); + SummaryText = $"Found {_preview.TotalEntries} entries: {_preview.MatchedCount} matched, {_preview.MissingCount} missing"; + StatusText = _preview.MissingCount > 0 + ? "Missing tracks will be skipped during import." + : "All tracks found in library."; + } + + [RelayCommand] + private void Cancel() + { + ImportedPlaylist = null; + CloseRequested?.Invoke(); + } + + [RelayCommand] + private void Import() + { + // The actual import happens in the calling code (PlaylistViewModel) + // which has access to PlaylistService + CloseRequested?.Invoke(); + } + + public event Action? CloseRequested; +} \ No newline at end of file diff --git a/ViewModels/Library/SmartPlaylistViewModel.cs b/ViewModels/Library/SmartPlaylistViewModel.cs new file mode 100644 index 0000000..1b76ab3 --- /dev/null +++ b/ViewModels/Library/SmartPlaylistViewModel.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using NullWave.Models; +using NullWave.Services; + +namespace NullWave.ViewModels; + +public partial class SmartPlaylistViewModel : ObservableObject +{ + private readonly SmartPlaylistService _service; + private readonly SmartPlaylistDefinition _definition; + private readonly PlaylistService _playlistService; + + [ObservableProperty] private IReadOnlyList _tracks = new List(); + + public string NameKey => _definition.NameKey; + public string Id => _definition.Id; + + // Constructor now takes PlaylistService + public SmartPlaylistViewModel(SmartPlaylistService service, SmartPlaylistDefinition definition, PlaylistService playlistService) + { + _service = service; + _definition = definition; + _playlistService = playlistService; + Refresh(); + } + + public void Refresh() => Tracks = _service.Resolve(_definition); + + [RelayCommand] + private void PlayAll() { /* TODO: Wire to PlayerViewModel.PlayAll(Tracks) */ } + + [RelayCommand] + private void Shuffle() { /* TODO: Wire to PlayerViewModel.ShufflePlay(Tracks) */ } + + // IMPLEMENTED: Create snapshot playlist + [RelayCommand] + private async Task SaveAsPlaylist() + { + if (Tracks.Count == 0) + { + ToastService.Instance.Show("Cannot save empty smart playlist", ToastType.Warning, scope: "playlist"); + return; + } + + var snapshotName = $"Smart: {_definition.NameKey} (Snapshot)"; + + // Ensure unique name + var baseName = snapshotName; + int counter = 1; + while (_playlistService.NameExists(snapshotName)) + { + snapshotName = $"{baseName} ({counter++})"; + } + + var playlist = _playlistService.Create(snapshotName, $"Auto-generated snapshot from smart playlist '{_definition.NameKey}'"); + + foreach (var track in Tracks) + { + _playlistService.AddTrack(playlist.Id, track); + } + + ToastService.Instance.Show($"Saved as '{snapshotName}' with {Tracks.Count} tracks", ToastType.Success, scope: "playlist"); + } +} \ No newline at end of file diff --git a/ViewModels/Playlists/PlaylistViewModel.cs b/ViewModels/Playlists/PlaylistViewModel.cs index 509ef41..60543d3 100644 --- a/ViewModels/Playlists/PlaylistViewModel.cs +++ b/ViewModels/Playlists/PlaylistViewModel.cs @@ -17,6 +17,7 @@ namespace NullWave.ViewModels; public class PlaylistViewModel : ViewModelBase { private readonly PlaylistService _playlists; + private readonly SmartPlaylistService _smartPlaylists; private Playlist? _selectedPlaylist; private string _renameText = string.Empty; private bool _isRenaming; @@ -25,6 +26,52 @@ public class PlaylistViewModel : ViewModelBase public Func>? RequestCreatePlaylistName; public Func>? RequestCreateFolderName; + private bool _isSmartPlaylistActive; + public bool IsSmartPlaylistActive + { + get => _isSmartPlaylistActive; + set { _isSmartPlaylistActive = value; OnPropertyChanged(); OnPropertyChanged(nameof(HasActivePlaylist)); } + } + + private string? _currentSmartPlaylistId; + public string? CurrentSmartPlaylistId + { + get => _currentSmartPlaylistId; + set { _currentSmartPlaylistId = value; OnPropertyChanged(); OnPropertyChanged(nameof(SmartPlaylistTitle)); } + } + + public string SmartPlaylistTitle => CurrentSmartPlaylistId switch + { + "smart.recently-added" => "Recently Added", + "smart.most-played" => "Most Played", + "smart.forgotten-favorites" => "Forgotten Favorites", + _ => "Smart Playlist" + }; + + public async Task ImportM3uPlaylistAsync(M3uImportPreview preview, string playlistName) + { + var playlist = _playlists.Create(playlistName); + + foreach (var match in preview.Matches) + { + if (match.MatchedTrack != null) + { + _playlists.AddTrack(playlist.Id, match.MatchedTrack); + } + } + + Playlists.Add(playlist); + SelectedPlaylist = playlist; + PlaylistsChanged?.Invoke(); + + ToastService.Instance.Show( + $"Imported {preview.MatchedCount} tracks to '{playlistName}'", + ToastType.Success, + scope: "import"); + } + + public bool HasActivePlaylist => SelectedPlaylist != null || IsSmartPlaylistActive; + public string SearchQuery { get => _searchQuery; @@ -46,7 +93,6 @@ public bool SortAscending set { _sortAscending = value; OnPropertyChanged(); RefreshFilteredTracks(); } } - /// Playlist sorts. "Custom" first because it is the default and enables drag reorder. public Array SortOptions => SortFieldCatalog.Playlist; public bool IsSortedByTitle => CurrentSort == SortField.Title; @@ -55,9 +101,9 @@ public bool SortAscending public bool IsSortedByDate => CurrentSort == SortField.DateAdded; public bool IsSortedByPlayCount => CurrentSort == SortField.PlayCount; public bool IsSortedByLastPlayed => CurrentSort == SortField.LastPlayed; - + public bool IsSortedByLength => CurrentSort == SortField.Length; public bool IsSortedByCustom => CurrentSort == SortField.Custom; - public bool IsDragReorderEnabled => CurrentSort == SortField.Custom && string.IsNullOrEmpty(SearchQuery); + public bool IsDragReorderEnabled => CurrentSort == SortField.Custom && string.IsNullOrEmpty(SearchQuery) && !IsSmartPlaylistActive; public BulkObservableCollection FilteredTracks { get; } = new(); public string ResultCountLabel @@ -76,15 +122,10 @@ public ObservableCollection SelectedTracks get => _selectedTracks; set { - if (_selectedTracks != null) - _selectedTracks.CollectionChanged -= SelectedTracks_CollectionChanged; - + if (_selectedTracks != null) _selectedTracks.CollectionChanged -= SelectedTracks_CollectionChanged; _selectedTracks = value; OnPropertyChanged(); - - if (_selectedTracks != null) - _selectedTracks.CollectionChanged += SelectedTracks_CollectionChanged; - + if (_selectedTracks != null) _selectedTracks.CollectionChanged += SelectedTracks_CollectionChanged; OnPropertyChanged(nameof(HasMultiSelection)); OnPropertyChanged(nameof(SelectionCountLabel)); } @@ -96,7 +137,6 @@ private void SelectedTracks_CollectionChanged(object? sender, System.Collections OnPropertyChanged(nameof(SelectionCountLabel)); } - // FIX: parity with library - bulk bar only from 2 selections up (CTRL/SHIFT) public bool HasMultiSelection => SelectedTracks?.Count > 1; public string SelectionCountLabel => $"{SelectedTracks?.Count ?? 0} selected"; @@ -116,6 +156,8 @@ private void SelectedTracks_CollectionChanged(object? sender, System.Collections public ICommand SortByArtistCommand { get; } public ICommand SortBySourceCommand { get; } public ICommand SortByDateCommand { get; } + public ICommand SortByLengthCommand { get; } + public ICommand SortByPlayCountCommand { get; } public ICommand ToggleSortDirectionCommand { get; } public ICommand PlayAllCommand { get; } public ICommand BulkQueueCommand { get; } @@ -161,14 +203,15 @@ private void RefreshPinnedState() p.IsPinned = _nav.IsPlaylistPinned(p.Id); } - public PlaylistViewModel(PlaylistService playlists) + public PlaylistViewModel(PlaylistService playlists, SmartPlaylistService smartPlaylists) { _playlists = playlists; + _smartPlaylists = smartPlaylists; _selectedTracks.CollectionChanged += SelectedTracks_CollectionChanged; CreatePlaylistCommand = new SafeRelayCommand(async () => await CreatePlaylistAsync(), errorScope: "playlist"); - CreateFolderCommand = new SafeRelayCommand(async () => await CreateFolderAsync(),errorScope: "folder"); - RemovePlaylistCommand = new SafeRelayCommand(async () => await RemovePlaylistAsync_Safe(),errorScope: "playlist"); + CreateFolderCommand = new SafeRelayCommand(async () => await CreateFolderAsync(), errorScope: "folder"); + RemovePlaylistCommand = new SafeRelayCommand(async () => await RemovePlaylistAsync_Safe(), errorScope: "playlist"); AddToPlaylistCommand = new RelayCommand(AddToPlaylist); RemoveFromPlaylistCommand = new RelayCommand(RemoveFromPlaylist); @@ -185,18 +228,18 @@ public PlaylistViewModel(PlaylistService playlists) SortByArtistCommand = new RelayCommand(() => SetSort(SortField.Artist)); SortBySourceCommand = new RelayCommand(() => SetSort(SortField.Source)); SortByDateCommand = new RelayCommand(() => SetSort(SortField.DateAdded)); + SortByLengthCommand = new RelayCommand(() => SetSort(SortField.Length)); + SortByPlayCountCommand = new RelayCommand(() => SetSort(SortField.PlayCount)); ToggleSortDirectionCommand = new RelayCommand(() => SortAscending = !SortAscending); PlayAllCommand = new RelayCommand(() => { if (SelectedPlaylist != null) PlayAllRequested?.Invoke(SelectedPlaylist); }); BulkQueueCommand = new RelayCommand(() => BulkQueueRequested?.Invoke(SelectedTracks.ToList())); - BulkRemoveCommand = new SafeRelayCommand(async () => + BulkRemoveCommand = new SafeRelayCommand(async () => { if (SelectedPlaylist == null || SelectedTracks.Count == 0) return; var tracksToRemove = SelectedTracks.ToList(); foreach (var track in tracksToRemove) - { _playlists.RemoveTrack(SelectedPlaylist.Id, track.Id); - } RefreshFilteredTracks(); SelectedTracks.Clear(); await Task.CompletedTask; @@ -208,10 +251,7 @@ public PlaylistViewModel(PlaylistService playlists) private async Task RemovePlaylistAsync_Safe() { if (SelectedPlaylist == null) return; - try - { - await RemovePlaylistAsync(SelectedPlaylist); - } + try { await RemovePlaylistAsync(SelectedPlaylist); } catch (Exception ex) { Log.Error(ex, "[Playlist] Failed to remove playlist"); @@ -225,9 +265,7 @@ public void Refresh() Playlists.Clear(); foreach (var playlist in _playlists.GetAll()) Playlists.Add(playlist); - SelectedPlaylist = selectedId.HasValue - ? Playlists.FirstOrDefault(p => p.Id == selectedId.Value) - : null; + SelectedPlaylist = selectedId.HasValue ? Playlists.FirstOrDefault(p => p.Id == selectedId.Value) : null; RefreshPinnedState(); } @@ -236,10 +274,7 @@ private async Task CreatePlaylistAsync() if (RequestCreatePlaylistName == null) return; var name = await RequestCreatePlaylistName(); if (string.IsNullOrWhiteSpace(name)) return; - - if (_playlists.NameExists(name)) - name = $"{name} ({Guid.NewGuid().ToString()[..4]})"; - + if (_playlists.NameExists(name)) name = $"{name} ({Guid.NewGuid().ToString()[..4]})"; var playlist = _playlists.Create(name); Playlists.Add(playlist); SelectedPlaylist = playlist; @@ -261,12 +296,7 @@ private async Task RemovePlaylistAsync(Playlist target) type: ToastType.Warning, durationMs: 8000, actionText: "Undo", - actionCallback: () => - { - _playlists.Restore(snapshot); - Refresh(); - PlaylistsChanged?.Invoke(); - }, + actionCallback: () => { _playlists.Restore(snapshot); Refresh(); PlaylistsChanged?.Invoke(); }, scope: "playlist-delete"); await Task.CompletedTask; } @@ -277,18 +307,12 @@ private void AddToPlaylist(Track? track) var playlist = SelectedPlaylist; var added = _playlists.AddTrack(playlist.Id, track); if (added) - { - ToastService.Instance.Show( - $"Added '{track.Title}' to '{playlist.Name}'.", ToastType.Success, - durationMs: 5000, + ToastService.Instance.Show($"Added '{track.Title}' to '{playlist.Name}'.", ToastType.Success, durationMs: 5000, actionText: "Undo", actionCallback: () => { _playlists.RemoveTrack(playlist.Id, track.Id); RefreshFilteredTracks(); }, scope: "playlist-add"); - } else - { ToastService.Instance.Show($"'{track.Title}' is already in '{playlist.Name}'.", ToastType.Info, scope: "playlist-add"); - } RefreshFilteredTracks(); } @@ -297,7 +321,6 @@ private async Task CreateFolderAsync() if (RequestCreateFolderName == null) return; var name = await RequestCreateFolderName(); if (string.IsNullOrWhiteSpace(name)) return; - var folder = _playlists.CreateFolder(name); Log.Information("[PlaylistFolder] Created: {Name}", name); PlaylistsChanged?.Invoke(); @@ -314,20 +337,9 @@ private void ConfirmRename() { if (SelectedPlaylist == null) return; var trimmed = RenameText.Trim(); - if (string.IsNullOrWhiteSpace(trimmed)) - { - ToastService.Instance.Show("Playlist name can't be empty.", ToastType.Warning); - return; - } - - // FIX: Block rename if another playlist already uses this name + if (string.IsNullOrWhiteSpace(trimmed)) { ToastService.Instance.Show("Playlist name can't be empty.", ToastType.Warning); return; } var existing = Playlists.FirstOrDefault(p => p.Id != SelectedPlaylist.Id && p.Name.Equals(trimmed, StringComparison.OrdinalIgnoreCase)); - if (existing != null) - { - ToastService.Instance.Show($"A playlist named '{trimmed}' already exists.", ToastType.Warning); - return; // Keep the rename box open so the user can fix it - } - + if (existing != null) { ToastService.Instance.Show($"A playlist named '{trimmed}' already exists.", ToastType.Warning); return; } var oldName = SelectedPlaylist.Name; _playlists.Rename(SelectedPlaylist.Id, trimmed); Refresh(); @@ -341,12 +353,30 @@ private void RemoveFromPlaylist(Track? track) { if (track == null || SelectedPlaylist == null) return; var removed = _playlists.RemoveTrack(SelectedPlaylist.Id, track.Id); - if (removed) - ToastService.Instance.Show($"Removed '{track.Title}' from '{SelectedPlaylist.Name}'.", ToastType.Info); + if (removed) ToastService.Instance.Show($"Removed '{track.Title}' from '{SelectedPlaylist.Name}'.", ToastType.Info); + RefreshFilteredTracks(); + } + + public void SelectById(Guid playlistId) + { + // Reserved GUIDs = virtual smart playlists clicked from the sidebar folder. + var defId = SmartPlaylistIds.ToDefId(playlistId); + if (defId != null) { SelectSmartPlaylist(defId); return; } + + IsSmartPlaylistActive = false; + CurrentSmartPlaylistId = null; + SelectedPlaylist = Playlists.FirstOrDefault(p => p.Id == playlistId); + RefreshFilteredTracks(); + } + + public void SelectSmartPlaylist(string smartId) + { + SelectedPlaylist = null; + IsSmartPlaylistActive = true; + CurrentSmartPlaylistId = smartId; RefreshFilteredTracks(); } - public void SelectById(Guid playlistId) => SelectedPlaylist = Playlists.FirstOrDefault(p => p.Id == playlistId); public void SelectFirst() { if (SelectedPlaylist == null && Playlists.Count > 0) SelectById(Playlists[0].Id); } public void OpenTrackDetail(Track track) => TrackDetailRequested?.Invoke(track); @@ -354,9 +384,7 @@ public void MoveTrackInSelectedPlaylist(int fromIndex, int toIndex) { if (SelectedPlaylist == null) return; if (_playlists.MoveTrack(SelectedPlaylist.Id, fromIndex, toIndex)) - { RefreshFilteredTracks(); - } } private void SetSort(SortField field) @@ -367,53 +395,49 @@ private void SetSort(SortField field) private void RefreshSortIndicators() { - OnPropertyChanged(nameof(IsSortedByTitle)); - OnPropertyChanged(nameof(IsSortedByArtist)); - OnPropertyChanged(nameof(IsSortedBySource)); - OnPropertyChanged(nameof(IsSortedByDate)); - OnPropertyChanged(nameof(IsSortedByPlayCount)); - OnPropertyChanged(nameof(IsSortedByLastPlayed)); - OnPropertyChanged(nameof(IsSortedByCustom)); + OnPropertyChanged(nameof(IsSortedByTitle)); OnPropertyChanged(nameof(IsSortedByArtist)); + OnPropertyChanged(nameof(IsSortedBySource)); OnPropertyChanged(nameof(IsSortedByDate)); + OnPropertyChanged(nameof(IsSortedByPlayCount)); OnPropertyChanged(nameof(IsSortedByLastPlayed)); + OnPropertyChanged(nameof(IsSortedByLength)); OnPropertyChanged(nameof(IsSortedByCustom)); OnPropertyChanged(nameof(IsDragReorderEnabled)); } public void RefreshFilteredTracks() { - if (SelectedPlaylist == null) + if (SelectedPlaylist == null && !IsSmartPlaylistActive) { FilteredTracks.Clear(); OnPropertyChanged(nameof(ResultCountLabel)); return; } - IEnumerable tracks = SelectedPlaylist.Tracks; + IEnumerable tracks; + if (IsSmartPlaylistActive && CurrentSmartPlaylistId != null) + { + var def = _smartPlaylists.All.FirstOrDefault(d => d.Id == CurrentSmartPlaylistId); + tracks = def != null ? _smartPlaylists.Resolve(def) : Enumerable.Empty(); + } + else + { + tracks = SelectedPlaylist!.Tracks; + } + var query = (SearchQuery ?? string.Empty).Trim(); - if (query.StartsWith("ai:", StringComparison.OrdinalIgnoreCase)) - query = string.Empty; + if (query.StartsWith("ai:", StringComparison.OrdinalIgnoreCase)) query = string.Empty; if (!string.IsNullOrWhiteSpace(query)) { var q = query.ToLowerInvariant(); - tracks = tracks.Where(t => - t.Title.ToLowerInvariant().Contains(q) || - t.Artist.ToLowerInvariant().Contains(q)); + tracks = tracks.Where(t => t.Title.ToLowerInvariant().Contains(q) || t.Artist.ToLowerInvariant().Contains(q)); } var sorted = SortTracks(tracks, SelectedPlaylist, CurrentSort, SortAscending); - FilteredTracks.ReplaceAll(sorted); OnPropertyChanged(nameof(ResultCountLabel)); OnPropertyChanged(nameof(IsDragReorderEnabled)); } - /// - /// Pure sort function: every option offered by is - /// handled explicitly so none can silently fall through to "date added". - /// Kept static and internal so each case is unit-testable without a database. - /// Phase 7.2 merges this with the Library sort into one shared factory. - /// - internal static IEnumerable SortTracks( - IEnumerable tracks, Playlist? playlist, SortField sort, bool ascending) + internal static IEnumerable SortTracks(IEnumerable tracks, Playlist? playlist, SortField sort, bool ascending) => (sort, ascending) switch { (SortField.Custom, _) => tracks, @@ -429,7 +453,6 @@ internal static IEnumerable SortTracks( (SortField.PlayCount, false) => tracks.OrderByDescending(t => t.PlayCount).ThenByDescending(t => t.Title), (SortField.LastPlayed, true) => tracks.OrderBy(t => t.LastPlayed).ThenBy(t => t.Title), (SortField.LastPlayed, false) => tracks.OrderByDescending(t => t.LastPlayed).ThenByDescending(t => t.Title), - // Safety net for future enum values - never a silent no-op. (SortField.Length, true) => tracks.OrderBy(t => t.Duration == TimeSpan.Zero ? 1 : 0).ThenBy(t => t.Duration).ThenBy(t => t.Title), (SortField.Length, false) => tracks.OrderBy(t => t.Duration == TimeSpan.Zero ? 1 : 0).ThenByDescending(t => t.Duration).ThenByDescending(t => t.Title), (_, true) => tracks.OrderBy(t => t.DateAdded).ThenBy(t => t.Title), diff --git a/ViewModels/Shell/MainViewModel.Integration.cs b/ViewModels/Shell/MainViewModel.Integration.cs index 85eccf1..66fb4d4 100644 --- a/ViewModels/Shell/MainViewModel.Integration.cs +++ b/ViewModels/Shell/MainViewModel.Integration.cs @@ -62,6 +62,9 @@ private void InitializeServices() _plugins.Register(new OpenWeatherProvider(_weatherService, _prefsService)); _plugins.Register(new OllamaAIProvider(_localAI, _prefsService)); + // Provider registry (M3 foundation) - ASSIGN TO FIELD, not local variable + _providerRegistry = new ProviderRegistry(_prefsService); + Settings = new SettingsViewModel(_keyStore, _secureDelete, _prefsService, _localAI, _plugins); Settings.ClearYtDlpCacheRequested += OnClearYtDlpCacheRequested; Settings.DevEffectsTierOverrideChanged += tier => _effectsTier.SetDevOverride(tier); @@ -133,7 +136,10 @@ private void InitializeChildViewModels() _aiPlaylistsFolder = _playlists.GetAllFolders().FirstOrDefault(f => f.Name == "AI Playlists") ?? _playlists.CreateFolder("AI Playlists"); Library.AiPlaylistRequested += OnAiPlaylistRequested; - Playlist = new PlaylistViewModel(_playlists); + // FIX: Moved SmartPlaylistService creation up here so it can be passed to PlaylistViewModel + var smartPlaylistService = new SmartPlaylistService(_library, TimeProvider.System); + + Playlist = new PlaylistViewModel(_playlists, smartPlaylistService); Playlist.RequestCreatePlaylistName = async () => { var dialog = new Views.CreatePlaylistDialog(); @@ -156,7 +162,7 @@ private void InitializeChildViewModels() Queue = new QueueViewModel(_library); Nav = new NavigationViewModel( - _prefsService, _playlists, + _prefsService, _playlists, smartPlaylistService, () => NavigateLibraryCommand, () => NavigatePlaylistsCommand, () => NavigateRadioCommand, @@ -167,6 +173,12 @@ private void InitializeChildViewModels() Playlist.SelectById(playlistId); Nav.SetPlaylistActive(playlistId); LogNav($"PinnedPlaylist:{playlistId}"); + }, + navigateToSmartPlaylist: smartId => + { + CurrentPage = "Playlists"; + Playlist.SelectSmartPlaylist(smartId); + LogNav($"SmartPlaylist:{smartId}"); }); var oldAiPlaylists = _playlists.GetAll().Where(p => p.Name.StartsWith("AI: ", StringComparison.OrdinalIgnoreCase)).ToList(); @@ -189,6 +201,10 @@ private void InitializeChildViewModels() Playlist.Refresh(); Nav.Rebuild(); } + + // Register providers with the new registry - USE THE FIELD + foreach (var plugin in _plugins.Plugins.OfType()) + _providerRegistry.Register(plugin); } private void WireCoreEvents() diff --git a/ViewModels/Shell/MainViewModel.cs b/ViewModels/Shell/MainViewModel.cs index 42b52f4..cc050a4 100644 --- a/ViewModels/Shell/MainViewModel.cs +++ b/ViewModels/Shell/MainViewModel.cs @@ -25,7 +25,7 @@ namespace NullWave.ViewModels; public partial class MainViewModel : ViewModelBase, IDisposable { // --- Fields & Services --- - private readonly KeyStoreService _keyStore = new(); + private readonly KeyStoreService _keyStore = new(Path.Combine(NullWavePaths.DataDir, "keys.enc")); private SecureDeleteService _secureDelete = null!; private ConfigService _config = null!; private LibraryService _library = null!; @@ -56,6 +56,8 @@ public partial class MainViewModel : ViewModelBase, IDisposable private bool _initialMoodPlaylistRun; private DispatcherTimer? _libRefreshTimer; private bool _isMaintenanceRunning; + private ProviderRegistry _providerRegistry = null!; + public ProviderRegistry ProviderRegistry => _providerRegistry; // --- UI State Properties --- private bool _isMenuBarVisible; diff --git a/ViewModels/Shell/NavigationViewModel.cs b/ViewModels/Shell/NavigationViewModel.cs index e306396..bb94d61 100644 --- a/ViewModels/Shell/NavigationViewModel.cs +++ b/ViewModels/Shell/NavigationViewModel.cs @@ -20,7 +20,9 @@ public class NavigationViewModel : ViewModelBase { private readonly PreferencesService _prefs; private readonly PlaylistService _playlists; + private readonly SmartPlaylistService _smartPlaylists; private readonly Action _navigateToPlaylist; + private readonly Action? _navigateToSmartPlaylist; private readonly List _coreItems; private List _liveOrder = new(); @@ -54,15 +56,19 @@ public bool IsReorderDragging public NavigationViewModel( PreferencesService prefs, PlaylistService playlists, + SmartPlaylistService smartPlaylists, Func navigateLibrary, Func navigatePlaylists, Func navigateRadio, Func navigateAudiobooks, - Action navigateToPlaylist) + Action navigateToPlaylist, + Action navigateToSmartPlaylist) { _prefs = prefs; _playlists = playlists; + _smartPlaylists = smartPlaylists; _navigateToPlaylist = navigateToPlaylist; + _navigateToSmartPlaylist = navigateToSmartPlaylist; _coreItems = new List { @@ -92,13 +98,9 @@ public NavigationViewModel( _playlists.UpdateFolder(node.Folder); }); PinPlaylistCommand = new RelayCommand(p => { if (p != null) PinPlaylist(p.Id, p.Name); }); - RenameFolderCommand = new SafeRelayCommand( - async n => await RenameFolderAsync(n), - errorScope: "folder" -); DeleteFolderCommand = new SafeRelayCommand( - async n => await DeleteFolderAsync(n), - errorScope: "folder" -); + RenameFolderCommand = new SafeRelayCommand(async n => await RenameFolderAsync(n), errorScope: "folder"); + DeleteFolderCommand = new SafeRelayCommand(async n => await DeleteFolderAsync(n), errorScope: "folder"); + Items.CollectionChanged += (_, e) => { if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Move) @@ -137,6 +139,7 @@ public void Rebuild() if (suggestion != null) pinnedItems.Add(suggestion); } + // Core + pinned only. Smart playlists live inside the AI Playlists folder (see RefreshPlaylistLists). var all = _coreItems.Concat(pinnedItems).ToList(); var savedOrder = _liveOrder.Count > 0 ? _liveOrder : _prefs.Current.NavOrder; @@ -195,6 +198,7 @@ private void Decorate(NavItem item, Playlist? playlist) public void PinPlaylist(Guid playlistId, string label) { + if (_playlists.GetById(playlistId) == null) return; // guard: virtual/smart playlists can't be pinned var key = $"pin:{playlistId}"; if (_prefs.Current.PinnedItems.Any(p => p.Key == key)) return; @@ -263,13 +267,14 @@ public void RefreshPlaylistLists() FolderNodes.Add(node); } + InjectSmartPlaylistsIntoFolder(); + foreach (var item in Items) { if (item.Type == NavItemType.PinnedPlaylist && item.TargetPlaylistId.HasValue) Decorate(item, _playlists.GetById(item.TargetPlaylistId!.Value)); } - // 3.1 FIX: keep persisted pin labels in sync with renames/migration bool pinsDirty = false; foreach (var pin in _prefs.Current.PinnedItems) { @@ -281,14 +286,62 @@ public void RefreshPlaylistLists() pinsDirty = true; } } - - if (pinsDirty) + + if (pinsDirty) { - // Trigger the debounced save by reassigning the list reference var updatedPins = _prefs.Current.PinnedItems.ToList(); _prefs.Update(p => p.PinnedItems = updatedPins); - } - // 3.1 FIX: Removed manual _prefs.Save() here. Update() handles it. + } + } + + /// + /// Smart playlists are virtual (no DB rows). We surface them as read-only placeholder + /// Playlist objects inside the "AI Playlists" folder so they collapse/expand like + /// normal playlists and are never pinned to the top level. + /// + private void InjectSmartPlaylistsIntoFolder() + { + var node = FolderNodes.FirstOrDefault(n => string.Equals(n.Folder.Name, "AI Playlists", StringComparison.OrdinalIgnoreCase)); + if (node == null) + { + if (FolderNodes.Count == 0) + { + node = new SidebarFolderNode(new PlaylistFolder { Name = "AI Playlists" }); + FolderNodes.Add(node); + } + else return; // no AI folder and other folders exist: leave smart playlists hidden rather than misfiled + } + + foreach (var def in _smartPlaylists.All) + { + var virtualId = SmartPlaylistIds.ToGuid(def.Id); + if (virtualId == Guid.Empty) continue; + + var placeholder = new Playlist + { + Id = virtualId, + Name = GetSmartDisplayName(def), + Description = "Smart playlist · auto-updates" + }; + + foreach (var track in _smartPlaylists.Resolve(def)) + placeholder.Tracks.Add(track); + + node.Playlists.Add(placeholder); + } + } + + private static string GetSmartDisplayName(SmartPlaylistDefinition def) + { + var loc = LocalizationService.Instance[def.NameKey]; + if (!string.IsNullOrEmpty(loc) && !loc.StartsWith("[")) return loc; + return def.Id switch + { + "smart.recently-added" => "Recently Added", + "smart.most-played" => "Most Played", + "smart.forgotten-favorites" => "Forgotten Favorites", + _ => def.NameKey + }; } private void MoveUp(NavItem? item) @@ -329,7 +382,7 @@ private async Task RenameFolderAsync(SidebarFolderNode? node) var dialog = new Views.CreateFolderDialog(node.Folder.Name); var name = await DialogService.Instance.ShowDialogAsync(dialog); - + if (string.IsNullOrWhiteSpace(name)) return; _playlists.RenameFolder(node.Folder.Id, name); @@ -345,7 +398,7 @@ private async Task DeleteFolderAsync(SidebarFolderNode? node) $"Delete '{node.Folder.Name}'? Playlists inside move to the top level." ); var ok = await DialogService.Instance.ShowDialogAsync(dialog); - + if (!ok) return; var folderSnapshot = node.Folder; diff --git a/ViewModels/Tools/TrackInputViewModel.cs b/ViewModels/Tools/TrackInputViewModel.cs index 943585f..c429ab5 100644 --- a/ViewModels/Tools/TrackInputViewModel.cs +++ b/ViewModels/Tools/TrackInputViewModel.cs @@ -347,10 +347,27 @@ await _download.DownloadAsync( private static MediaType DetectMediaType(string url) { var value = url.ToLowerInvariant(); + // Radio detection: be more specific to avoid false positives if (value.EndsWith(".m3u8") || value.EndsWith(".m3u") || value.EndsWith(".pls") || - value.Contains("icecast") || value.Contains("/stream") || value.Contains("radio") || + value.Contains("icecast") || value.Contains("/stream") || RadioStationCatalog.TryGetChannels(url, out _, out _)) + { return MediaType.Radio; + } + // More precise radio URL pattern matching + // Match "radio" only when it's part of the domain or a clear radio indicator + if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + var host = uri.Host.ToLowerInvariant(); + // Check if "radio" is in the domain name (but not just as part of a band name) + if (host.Contains("radio") && + !host.Contains("radiohead") && // Exclude known band names + (host.StartsWith("radio") || host.EndsWith("radio") || host.Contains(".radio."))) + { + return MediaType.Radio; + } + } + // Audiobook detection if (value.Contains("audiobook") || value.Contains("librivox") || value.Contains("spoken word") || value.Contains("full book") || value.Contains("archive.org/details/audiobook")) return MediaType.Audiobook; diff --git a/Views/Controls/PlaylistsView.axaml b/Views/Controls/PlaylistsView.axaml index 10d5d3e..8b14225 100644 --- a/Views/Controls/PlaylistsView.axaml +++ b/Views/Controls/PlaylistsView.axaml @@ -3,21 +3,26 @@ xmlns:vm="using:NullWave.ViewModels" xmlns:models="using:NullWave.Models" xmlns:converters="using:NullWave.Helpers.Converters" + xmlns:services="using:NullWave.Services" xmlns:material="using:Material.Icons.Avalonia" xmlns:attached="using:NullWave.Helpers.Attached" + xmlns:helpers="using:NullWave.Helpers" xmlns:loc="using:NullWave.Helpers.Localization" x:Class="NullWave.Views.Controls.PlaylistsView" x:DataType="vm:MainViewModel"> - - + + + + - + + @@ -36,11 +41,15 @@ attached:AsyncImage.DecodeWidth="256" Stretch="UniformToFill" IsVisible="{Binding Playlist.SelectedPlaylist.ArtPath, Converter={x:Static ObjectConverters.IsNotNull}}"/> + + HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="6" + IsVisible="{Binding !Playlist.IsSmartPlaylistActive}"> - - + @@ -101,35 +126,62 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -139,16 +191,51 @@ SelectedItems="{Binding Playlist.SelectedTracks}" SelectionChanged="OnTrackSelected" DoubleTapped="OnTrackDoubleTapped" + ContextRequested="OnListContextRequested" ScrollViewer.HorizontalScrollBarVisibility="Disabled"> + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + - + - + - + attached:AsyncImage.DecodeWidth="96" Stretch="UniformToFill" + IsVisible="{Binding AlbumArtPath, Converter={x:Static ObjectConverters.IsNotNull}}" + RenderOptions.BitmapInterpolationMode="MediumQuality"/> + + + FontSize="{DynamicResource RowTitleSize}" FontWeight="Medium" TextTrimming="CharacterEllipsis"/> + FontSize="{DynamicResource RowSubSize}" TextTrimming="CharacterEllipsis"/> + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + - - + + + @@ -206,47 +343,54 @@ + - - - - + - - + + - - - + + + IsVisible="{Binding !Playlist.FilteredTracks.Count}"> - + \ No newline at end of file diff --git a/Views/Controls/PlaylistsView.axaml.cs b/Views/Controls/PlaylistsView.axaml.cs index 092e798..f1cddfd 100644 --- a/Views/Controls/PlaylistsView.axaml.cs +++ b/Views/Controls/PlaylistsView.axaml.cs @@ -1,8 +1,17 @@ +using System; +using System.Collections.Generic; using System.Linq; +using System.Windows.Input; +using Avalonia; using Avalonia.Controls; using Avalonia.Input; -using NullWave.ViewModels; +using Avalonia.Interactivity; +using Avalonia.VisualTree; +using NullWave.Helpers; using NullWave.Models; +using NullWave.Services; +using NullWave.ViewModels; +using NullWave.ViewModels.Base; namespace NullWave.Views.Controls; @@ -15,6 +24,116 @@ public PlaylistsView() InitializeComponent(); } + // --- Lazy row menus (parity with TrackListView) --- + + private void OnListContextRequested(object? sender, ContextRequestedEventArgs e) + { + if (sender is not ListBox list) return; + var track = FindRowTrack(e.Source as Visual); + if (track == null) return; + e.Handled = true; + BuildRowMenu(track).ShowAt(list, true); + } + + private void OnRowMenuClick(object? sender, RoutedEventArgs e) + { + if (sender is not Button btn) return; + var track = FindRowTrack(btn); + if (track == null) return; + BuildRowMenu(track).ShowAt(btn); + } + + private static Track? FindRowTrack(Visual? source) + { + var v = source; + while (v != null) + { + if (v.DataContext is Track t) return t; + v = v.GetVisualParent(); + } + return null; + } + + private MenuFlyout BuildRowMenu(Track track) + { + var menu = new MenuFlyout(); + if (DataContext is not MainViewModel root) return menu; + var lib = root.Library; + var loc = LocalizationService.Instance; + + AddItem(menu, loc["ContextMenu_Play"], new RelayCommand(() => root.Player.PlayTrack(track)), null); + AddItem(menu, loc["ContextMenu_ViewDetails"], new RelayCommand(() => root.Detail.OpenFor(track)), null); + AddItem(menu, loc["ContextMenu_ToggleFavorite"], lib.ToggleFavoriteCommand, track); + AddItem(menu, loc["ContextMenu_AddToQueue"], lib.AddToQueueCommand, track); + AddItem(menu, loc["ContextMenu_AddToPlaylist"], root.AddTrackToPlaylistCommand, track); + AddItem(menu, loc["ContextMenu_CopyUrl"], lib.CopyUrlCommand, null); + + if (root.Playlist.SelectedPlaylist != null) + { + menu.Items.Add(new Separator()); + AddItem(menu, loc["ContextMenu_Remove"], root.Playlist.RemoveFromPlaylistCommand, track); + } + + return menu; + } + + private static void AddItem(MenuFlyout menu, string header, ICommand command, object? parameter) + => menu.Items.Add(new MenuItem { Header = header, Command = command, CommandParameter = parameter }); + + private async void OnImportM3uClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + if (DataContext is not MainViewModel vm) return; + + var window = Avalonia.Application.Current?.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop + ? desktop.MainWindow : null; + if (window == null) return; + + var dialog = new Avalonia.Platform.Storage.FilePickerOpenOptions + { + Title = "Select M3U Playlist", + AllowMultiple = false, + FileTypeFilter = new[] + { + new Avalonia.Platform.Storage.FilePickerFileType("M3U Playlist") + { + Patterns = new[] { "*.m3u", "*.m3u8" } + } + } + }; + + var files = await window.StorageProvider.OpenFilePickerAsync(dialog); + if (files.Count == 0) return; + + var m3uPath = files[0].Path.LocalPath; + + // Get the LibraryService from MainViewModel (need to expose it) + // For now, use reflection or add a public property to MainViewModel + // Temporary workaround: create importer with the service + var libraryService = GetLibraryService(vm); + if (libraryService == null) return; + + var importer = new M3uImporter(libraryService); + var importVm = new ViewModels.Dialogs.M3uImportDialogViewModel(importer, m3uPath); + + var importDialog = new Views.Dialogs.M3uImportDialog { DataContext = importVm }; + importVm.CloseRequested += () => importDialog.Close(); + + await importDialog.ShowDialog(window); + + if (importVm.ImportedPlaylist != null) + { + vm.Playlist.SelectById(importVm.ImportedPlaylist.Id); + } + } + + private LibraryService? GetLibraryService(MainViewModel vm) + { + // Access the private _library field via reflection (temporary solution) + // Better: expose LibraryService as public property in MainViewModel + var field = typeof(MainViewModel).GetField("_library", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return field?.GetValue(vm) as LibraryService; + } + private void OnTrackSelected(object? sender, SelectionChangedEventArgs e) { if (sender is not ListBox list) return; @@ -28,7 +147,7 @@ private void OnTrackDoubleTapped(object? sender, TappedEventArgs e) { var track = (e.Source as Control)?.DataContext as Track ?? (sender as ListBox)?.SelectedItem as Track; - if (track != null && DataContext is MainViewModel vm) + if (track != null && DataContext is not null && DataContext is MainViewModel vm) vm.Player.PlayTrack(track); } diff --git a/Views/Dialogs/DownloaderConsentView.axaml b/Views/Dialogs/DownloaderConsentView.axaml new file mode 100644 index 0000000..c359fc9 --- /dev/null +++ b/Views/Dialogs/DownloaderConsentView.axaml @@ -0,0 +1,54 @@ + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Views/Settings/SourcesTab.axaml.cs b/Views/Settings/DownloadsTab.axaml.cs similarity index 51% rename from Views/Settings/SourcesTab.axaml.cs rename to Views/Settings/DownloadsTab.axaml.cs index 6a5b903..e703ff8 100644 --- a/Views/Settings/SourcesTab.axaml.cs +++ b/Views/Settings/DownloadsTab.axaml.cs @@ -1,10 +1,11 @@ using Avalonia.Controls; +using NullWave.ViewModels; namespace NullWave.Views.Settings; -public partial class SourcesTab : UserControl +public partial class DownloadsTab : UserControl { - public SourcesTab() + public DownloadsTab() { InitializeComponent(); } diff --git a/Views/Settings/GeneralTab.axaml b/Views/Settings/GeneralTab.axaml index 311e867..b20479b 100644 --- a/Views/Settings/GeneralTab.axaml +++ b/Views/Settings/GeneralTab.axaml @@ -31,7 +31,6 @@ MinWidth="200" VerticalAlignment="Center"/> - + + + + + + + - + @@ -264,6 +281,74 @@ + + + + + + + + + + + - - - - - + + + + + + diff --git a/tests/NullWave.Tests/TagWriteModeTests.cs b/tests/NullWave.Tests/TagWriteModeTests.cs new file mode 100644 index 0000000..cbca8d3 --- /dev/null +++ b/tests/NullWave.Tests/TagWriteModeTests.cs @@ -0,0 +1,23 @@ +using NullWave.Models; +using Xunit; + +namespace NullWave.Tests; + +public class TagWriteModeTests +{ + [Fact] + public void Default_is_DownloadsOnly() + { + var prefs = new Preferences(); + Assert.Equal(TagWriteMode.DownloadsOnly, prefs.TagWriteMode); + } + + [Theory] + [InlineData(TagWriteMode.Never)] + [InlineData(TagWriteMode.DownloadsOnly)] + [InlineData(TagWriteMode.Always)] + public void All_modes_are_valid_enum_values(TagWriteMode mode) + { + Assert.True(Enum.IsDefined(typeof(TagWriteMode), mode)); + } +} \ No newline at end of file From 8638c23d425c5b5f975319f108495566ea788e9d Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Fri, 6 Nov 2026 16:12:28 +0400 Subject: [PATCH 25/52] feat(library): implement N3 import modes (Keep/Copy) with FileOperationJournal --- Services/FileOperationJournal.cs | 81 +++++++++++++++++++++++++++++ ViewModels/Tools/ImportViewModel.cs | 69 ++++++++++++++++-------- 2 files changed, 127 insertions(+), 23 deletions(-) create mode 100644 Services/FileOperationJournal.cs diff --git a/Services/FileOperationJournal.cs b/Services/FileOperationJournal.cs new file mode 100644 index 0000000..e095da8 --- /dev/null +++ b/Services/FileOperationJournal.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using NullWave.Helpers; +using Serilog; + +namespace NullWave.Services; + +public enum FileOpType { Copy, Move, Delete } + +public record FileOpRecord(DateTime Utc, FileOpType Type, string Source, string Dest, Guid TrackId); + +/// +/// Records file system mutations (Copy, Move, Delete) to a bounded JSON journal. +/// Powers the "Undo last organize/import" feature and ensures D16/D17 data safety. +/// +public class FileOperationJournal +{ + private static readonly Lazy _instance = new(() => new FileOperationJournal()); + public static FileOperationJournal Instance => _instance.Value; + + private readonly string _path; + private readonly List _ops = new(); + private readonly object _lock = new(); + private const int MaxRecords = 1000; + + private FileOperationJournal() + { + _path = Path.Combine(NullWavePaths.DataDir, "file-ops.json"); + Load(); + } + + private void Load() + { + try + { + if (File.Exists(_path)) + { + var json = File.ReadAllText(_path); + var ops = JsonSerializer.Deserialize>(json); + if (ops != null) _ops.AddRange(ops); + } + } + catch (Exception ex) { Log.Warning(ex, "[Journal] Failed to load file-ops.json"); } + } + + private void Save() + { + try + { + // Keep only the most recent operations to prevent unbounded growth + var recent = _ops.TakeLast(MaxRecords).ToList(); + var json = JsonSerializer.Serialize(recent, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(_path, json); + } + catch (Exception ex) { Log.Warning(ex, "[Journal] Failed to save file-ops.json"); } + } + + public void Record(FileOpType type, string source, string dest, Guid trackId) + { + lock (_lock) + { + _ops.Add(new FileOpRecord(DateTime.UtcNow, type, source, dest, trackId)); + Save(); + } + } + + public FileOpRecord? PopLast() + { + lock (_lock) + { + if (_ops.Count == 0) return null; + var last = _ops[^1]; + _ops.RemoveAt(_ops.Count - 1); + Save(); + return last; + } + } +} \ No newline at end of file diff --git a/ViewModels/Tools/ImportViewModel.cs b/ViewModels/Tools/ImportViewModel.cs index 9f0a836..2e63110 100644 --- a/ViewModels/Tools/ImportViewModel.cs +++ b/ViewModels/Tools/ImportViewModel.cs @@ -60,42 +60,57 @@ private async Task ImportFolderAsync() return; } + // N3: Ask Import Mode (Copy vs Keep in Place) + var copyToLibrary = await AskCopyToLibraryAsync(); + var destDir = copyToLibrary ? NullWavePaths.DownloadsDir : null; + if (copyToLibrary) Directory.CreateDirectory(destDir!); + activity = ToastService.Instance.StartLiveActivity( "Importing Folder", $"Importing 0/{files.Count}...", isIndeterminate: false); - int added = 0; - int skipped = 0; + int added = 0, skipped = 0; for (int i = 0; i < files.Count; i++) { - var filePath = files[i]; - var (rawTitle, rawArtist, duration) = _metadata.FetchFromLocalFile(filePath); - - // Use TrackNormalizer to extract artist from title if needed - string title = rawTitle; - string artist = rawArtist; + var originalPath = files[i]; + var activePath = originalPath; + + // N3: Execute Copy if requested + if (copyToLibrary) + { + var (rawT, rawA, _) = _metadata.FetchFromLocalFile(originalPath); + var ext = Path.GetExtension(originalPath); + var safeName = FileNamer.Generate(rawA, rawT, Guid.NewGuid().ToString(), ext); + var targetPath = Path.Combine(destDir!, safeName); + + if (File.Exists(targetPath)) + targetPath = Path.Combine(destDir!, $"{Path.GetFileNameWithoutExtension(safeName)}_{i}{ext}"); + + File.Copy(originalPath, targetPath, overwrite: false); + // Track ID isn't generated yet, so we log Guid.Empty for imports + FileOperationJournal.Instance.Record(FileOpType.Copy, originalPath, targetPath, Guid.Empty); + activePath = targetPath; + } + + var (title, artist, duration) = _metadata.FetchFromLocalFile(activePath); if (string.IsNullOrWhiteSpace(artist) || artist == "Unknown") { - var split = NullWave.Helpers.TrackNormalizer.TrySplit(rawTitle); - if (split != null) - { - artist = split.Value.Artist; - title = split.Value.Title; - } + var split = NullWave.Helpers.TrackNormalizer.TrySplit(title); + if (split != null) { artist = split.Value.Artist; title = split.Value.Title; } } var track = new Track { Title = title, Artist = artist, - FilePath = filePath, + FilePath = activePath, Source = TrackSource.Local, Duration = duration }; - var (album, trackNum) = _metadata.FetchAlbumAndTrackNumber(filePath); + var (album, trackNum) = _metadata.FetchAlbumAndTrackNumber(activePath); track.Album = album; track.TrackNumber = trackNum; @@ -116,12 +131,13 @@ private async Task ImportFolderAsync() progressValue: (i + 1) * 100.0 / files.Count, isIndeterminate: false); - await Task.Delay(1); // yield to UI thread + await Task.Delay(1); } stopwatch.Stop(); + var modeText = copyToLibrary ? "copied to library" : "kept in place"; ToastService.Instance.CompleteLiveActivity( - activity, $"Import complete - {added} added, {skipped} skipped (duplicates)."); + activity, $"Import complete ({modeText}) - {added} added, {skipped} skipped."); NullActionLogger.ImportCompleted(folderPath, $"{added} tracks added", stopwatch.ElapsedMilliseconds, "ImportViewModel"); Log.Information("Folder import complete: {Added} added, {Skipped} skipped from {Path}", added, skipped, folderPath); @@ -139,11 +155,18 @@ private async Task ImportFolderAsync() } private static async Task AskIncludeSubfoldersAsync() + { + var dialog = new Views.ConfirmDialog("Import Subfolders?", "Include all subfolders in the import?"); + return await DialogService.Instance.ShowDialogAsync(dialog); + } + + private static async Task AskCopyToLibraryAsync() { var dialog = new Views.ConfirmDialog( - "Import Subfolders?", - "Include all subfolders in the import?"); - var result = await DialogService.Instance.ShowDialogAsync(dialog); - return result; + "Import Mode", + "Copy these files into your NullWave library folder?\n\n" + + "Yes = Copy files (Safe, managed by NullWave)\n" + + "No = Keep in place (Files stay where they are)"); + return await DialogService.Instance.ShowDialogAsync(dialog); } -} +} \ No newline at end of file From f6e9351c6bfc04af54208c9106c3c5514d3ba9e4 Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Fri, 6 Nov 2026 16:12:29 +0400 Subject: [PATCH 26/52] feat(library): implement N6 safe export pipeline (copy, rename, retag, M3U8) --- Services/Library/ExportService.cs | 96 ++++++++++++++++++++- ViewModels/Tools/ExportViewModel.cs | 125 ++++++++++++++-------------- 2 files changed, 156 insertions(+), 65 deletions(-) diff --git a/Services/Library/ExportService.cs b/Services/Library/ExportService.cs index bc7804c..288b951 100644 --- a/Services/Library/ExportService.cs +++ b/Services/Library/ExportService.cs @@ -1,11 +1,18 @@ +using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using NullWave.Helpers; using NullWave.Models; +using Serilog; namespace NullWave.Services; +public record ExportFolderResult(int Copied, int Skipped, int Failed, string? PlaylistPath); + public class ExportService { public void ExportToJson(IReadOnlyList tracks, string filePath) @@ -21,13 +28,11 @@ public void ExportToCsv(IReadOnlyList tracks, string filePath) foreach (var t in tracks) { - // FIX: Sanitize formula injection before RFC-4180 escaping var safeTitle = CsvSafe(t.Title); var safeArtist = CsvSafe(t.Artist); var safeUrl = CsvSafe(t.Url); var safePath = CsvSafe(t.FilePath); - // RFC-4180 CSV escaping: double up internal quotes var escapedTitle = safeTitle?.Replace("\"", "\"\"") ?? ""; var escapedArtist = safeArtist?.Replace("\"", "\"\"") ?? ""; var escapedUrl = safeUrl?.Replace("\"", "\"\"") ?? ""; @@ -46,9 +51,92 @@ public List ImportFromJson(string filePath) } /// - /// Prevents CSV formula injection by prefixing dangerous characters with a single quote. - /// Characters that trigger formula interpretation in Excel: = + - @ \t \r + /// N6: Safe Export Pipeline. Copies files to a destination folder, renames them using + /// FileNamer (255-byte limit), retags the COPIES (forcing ID3v2.3 for compatibility), + /// and generates a relative M3U8 playlist. Original files are never modified. /// + public async Task ExportToFolderAsync(IReadOnlyList tracks, string destDir, CancellationToken ct = default) + { + Directory.CreateDirectory(destDir); + int copied = 0, skipped = 0, failed = 0; + var exportedTracks = new List(); + + // Force ID3v2.3 for maximum compatibility (Android players, older car stereos) + TagLib.Id3v2.Tag.ForceDefaultVersion = true; + TagLib.Id3v2.Tag.DefaultVersion = 3; + + for (int i = 0; i < tracks.Count; i++) + { + ct.ThrowIfCancellationRequested(); + var t = tracks[i]; + + if (string.IsNullOrEmpty(t.FilePath) || !File.Exists(t.FilePath)) + { + skipped++; + continue; + } + + try + { + var ext = Path.GetExtension(t.FilePath); + + // N1: Use unified FileNamer to enforce 255-byte limit and clean names + var safeName = FileNamer.Generate(t.Artist, t.Title, t.Id.ToString(), ext); + var destPath = Path.Combine(destDir, safeName); + + if (File.Exists(destPath)) + destPath = Path.Combine(destDir, $"{Path.GetFileNameWithoutExtension(safeName)}_{i}{ext}"); + + // 1. Copy the file (never move or modify the original) + File.Copy(t.FilePath, destPath, overwrite: false); + FileOperationJournal.Instance.Record(FileOpType.Copy, t.FilePath, destPath, t.Id); + + // 2. Retag the COPY + try + { + using var file = TagLib.File.Create(destPath); + file.Tag.Title = t.Title; + file.Tag.Performers = new[] { t.Artist }; + file.Save(); + } + catch (Exception ex) + { + Log.Debug(ex, "[Export] Retag failed for copy {Path}", destPath); + } + + copied++; + + // Track the new path for the M3U8 generator + exportedTracks.Add(new Track + { + Title = t.Title, + Artist = t.Artist, + FilePath = destPath, + Duration = t.Duration, + MediaType = t.MediaType + }); + } + catch (Exception ex) + { + Log.Warning(ex, "[ExportService] Failed to export {Title}", t.Title); + failed++; + } + } + + // 3. Generate relative M3U8 playlist + string? m3uPath = null; + if (exportedTracks.Count > 0) + { + m3uPath = Path.Combine(destDir, "playlist.m3u8"); + var result = M3uWriter.Build(exportedTracks, m3uPath, new M3uWriteOptions(RelativePaths: true, PlaylistName: "Exported Playlist")); + M3uWriter.WriteFile(m3uPath, result.Text); + } + + return await Task.FromResult(new ExportFolderResult(copied, skipped, failed, m3uPath)); + } + + public record ExportFolderResult(int Copied, int Skipped, int Failed, string? PlaylistPath); + private static string? CsvSafe(string? value) { if (string.IsNullOrEmpty(value)) return value; diff --git a/ViewModels/Tools/ExportViewModel.cs b/ViewModels/Tools/ExportViewModel.cs index f754057..7949322 100644 --- a/ViewModels/Tools/ExportViewModel.cs +++ b/ViewModels/Tools/ExportViewModel.cs @@ -1,4 +1,9 @@ using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using System.Windows.Input; using Avalonia; using Avalonia.Controls; @@ -20,98 +25,96 @@ public class ExportViewModel : ViewModelBase public ICommand ExportJsonCommand { get; } public ICommand ExportCsvCommand { get; } public ICommand ExportM3uCommand { get; } + public ICommand ExportFolderCommand { get; } // Safe Export to USB/Folder public ExportViewModel(LibraryService library, ExportService export) { - ExportM3uCommand = new SafeRelayCommand(async () => await ExportAsync("m3u8"), errorScope: "export"); - _library = library; _export = export; - // D7 FIX: Use SafeRelayCommand for async operations (catches exceptions) - ExportJsonCommand = new SafeRelayCommand( - async () => await ExportAsync("json"), - errorScope: "export" - ); - ExportCsvCommand = new SafeRelayCommand( - async () => await ExportAsync("csv"), - errorScope: "export" - ); + ExportJsonCommand = new SafeRelayCommand(async () => await ExportAsync("json"), errorScope: "export"); + ExportCsvCommand = new SafeRelayCommand(async () => await ExportAsync("csv"), errorScope: "export"); + ExportM3uCommand = new SafeRelayCommand(async () => await ExportAsync("m3u8"), errorScope: "export"); + ExportFolderCommand = new SafeRelayCommand(async () => await ExportToFolderAsync(), errorScope: "export-folder"); } - private async System.Threading.Tasks.Task ExportAsync(string format) + private async Task ExportAsync(string format) { - var window = Application.Current?.ApplicationLifetime is - IClassicDesktopStyleApplicationLifetime desktop - ? desktop.MainWindow : null; - - if (window == null) - { - ErrorReporter.Report("Could not access application window.", "export"); - return; - } + var window = Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop ? desktop.MainWindow : null; + if (window == null) { ErrorReporter.Report("Could not access application window.", "export"); return; } var file = await window.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = "Export Library", DefaultExtension = format, - FileTypeChoices = new[] - { - new FilePickerFileType(format.ToUpper()) - { - Patterns = new[] { $"*.{format}" } - } - } + FileTypeChoices = new[] { new FilePickerFileType(format.ToUpper()) { Patterns = new[] { $"*.{format}" } } } }); - if (file == null) return; // User cancelled + if (file == null) return; var path = file.Path.LocalPath; - if (string.IsNullOrWhiteSpace(path)) - { - ErrorReporter.Report("Invalid file path selected.", "export"); - return; - } + if (string.IsNullOrWhiteSpace(path)) { ErrorReporter.Report("Invalid file path selected.", "export"); return; } try { var tracks = _library.GetAll(); - if (format == "json") - { - _export.ExportToJson(tracks, path); - } - else if (format == "csv") - { - _export.ExportToCsv(tracks, path); - } + if (format == "json") _export.ExportToJson(tracks, path); + else if (format == "csv") _export.ExportToCsv(tracks, path); else if (format == "m3u8" || format == "m3u") { - // M3U Export Logic var result = M3uWriter.Build(tracks, path, new M3uWriteOptions(RelativePaths: true)); M3uWriter.WriteFile(path, result.Text); - - Log.Information("[Export] Exported {Count} tracks to M3U (skipped {Skipped} missing)", result.Written, result.SkippedMissing); - ToastService.Instance.Show( - $"Exported {result.Written} tracks to M3U ({result.SkippedMissing} skipped missing files)", - ToastType.Success, - durationMs: 4000, - scope: "export" - ); - return; // Return early to avoid the generic toast below + ToastService.Instance.Show($"Exported {result.Written} tracks to M3U ({result.SkippedMissing} skipped missing files)", ToastType.Success, durationMs: 4000, scope: "export"); + return; } - Log.Information("[Export] Successfully exported {Count} tracks to {Path}", tracks.Count, path); - ToastService.Instance.Show( - $"Exported {tracks.Count} tracks to {System.IO.Path.GetFileName(path)}", - ToastType.Success, - durationMs: 4000, - scope: "export" - ); + ToastService.Instance.Show($"Exported {tracks.Count} tracks to {Path.GetFileName(path)}", ToastType.Success, durationMs: 4000, scope: "export"); + } + catch (Exception ex) { ErrorReporter.Report(ex, "Export failed. Please try again.", "export"); } + } + + private async Task ExportToFolderAsync() + { + var window = Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop ? desktop.MainWindow : null; + if (window == null) return; + + var folders = await window.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = "Select Destination Folder (e.g., USB Drive)", + AllowMultiple = false + }); + + if (folders.Count == 0) return; + var destDir = folders[0].Path.LocalPath; + + // N6: Export only tracks that have a valid local file + var tracks = _library.GetAll().Where(t => !string.IsNullOrEmpty(t.FilePath) && File.Exists(t.FilePath)).ToList(); + + if (tracks.Count == 0) + { + ToastService.Instance.Show("No tracks with local files found to export.", ToastType.Warning, scope: "export-folder"); + return; + } + + var activity = ToastService.Instance.StartLiveActivity("Exporting to Folder", $"Copying 0/{tracks.Count}...", isIndeterminate: false); + + try + { + var cts = new CancellationTokenSource(); + // Note: For a true progress callback, you'd pass the IProgress to ExportToFolderAsync. + // For now, we just run it and complete the activity. + var result = await _export.ExportToFolderAsync(tracks, destDir, cts.Token); + + ToastService.Instance.CompleteLiveActivity( + activity, + $"Export complete: {result.Copied} copied, {result.Skipped} skipped, {result.Failed} failed.", + lingerMs: 5000, + finalType: result.Failed > 0 ? ToastType.Warning : ToastType.Success); } catch (Exception ex) { - // D18 FIX: Use ErrorReporter instead of raw ex.Message - ErrorReporter.Report(ex, $"Export failed. Please try again.", "export"); + ToastService.Instance.Dismiss(activity); + ErrorReporter.Report(ex, "Folder export failed.", "export-folder"); } } } \ No newline at end of file From bc51187d898e07b00a6ccc3a6a4dea7245d8db03 Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Sat, 7 Nov 2026 00:02:29 +0400 Subject: [PATCH 27/52] fix(filenamer): cross-platform invalid chars, surrogate-safe truncation, tests --- Helpers/FileNamer.cs | 154 ++++++------------------- tests/NullWave.Tests/FileNamerTests.cs | 80 +++++++++++++ 2 files changed, 116 insertions(+), 118 deletions(-) create mode 100644 tests/NullWave.Tests/FileNamerTests.cs diff --git a/Helpers/FileNamer.cs b/Helpers/FileNamer.cs index f63d6e7..e348e77 100644 --- a/Helpers/FileNamer.cs +++ b/Helpers/FileNamer.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Text; using System.Text.RegularExpressions; +using System.Collections.Generic; using Serilog; namespace NullWave.Helpers; @@ -10,54 +11,43 @@ namespace NullWave.Helpers; /// /// Unified filename generator for NullWave. Enforces consistent naming across downloads, /// sync operations, and exports. Handles cross-platform compatibility including: -/// - Invalid character removal +/// - Invalid character removal (uses a FIXED set so Linux exports stay Windows/FAT-safe) /// - 255-byte UTF-8 filename limit (critical for CJK/Cyrillic) -/// - NullWave ID embedding for reliable file recovery -/// - Reserved name handling (Windows: CON, PRN, etc.) +/// - NullWave ID embedding for reliable file recovery via TryRelink /// public static class FileNamer { - // Maximum filename length in BYTES (not characters) for cross-platform compatibility private const int MaxFilenameBytes = 255; - - // Windows reserved names - private static readonly string[] ReservedNames = { - "CON", "PRN", "AUX", "NUL", - "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" - }; + + // FIXED invalid-char set: union of Windows invalid chars + Linux "/" + FAT/USB reserved. + // Path.GetInvalidFileNameChars() on Linux only returns '/' and '\0', which would let + // us write "Song: Title?.mp3" on Fedora and then fail on a USB stick or Windows share. + private static readonly HashSet InvalidChars = new( + Path.GetInvalidFileNameChars() + .Concat(new[] { '<', '>', ':', '"', '/', '\\', '|', '?', '*' }) + .Distinct()); /// - /// Generates a standardized filename: {Artist} - {Title} [nw-{id8}].{ext} - /// All invalid characters are removed, and the result is guaranteed to fit within - /// the 255-byte UTF-8 limit while preserving the NullWave ID suffix. + /// Generates: {Artist} - {Title} [nw-{id8}].{ext} /// public static string Generate(string artist, string title, string trackId, string extension) { if (string.IsNullOrWhiteSpace(extension)) - { throw new ArgumentException("Extension cannot be null or empty", nameof(extension)); - } - // Normalize extension if (!extension.StartsWith(".")) extension = "." + extension; - // Extract 8-char ID prefix for embedding var id8 = ExtractId8(trackId); - - // Sanitize artist and title var cleanArtist = Sanitize(artist ?? "Unknown Artist"); var cleanTitle = Sanitize(title ?? "Unknown Title"); - // Build the suffix that must always be preserved: [nw-{id8}]{ext} + // The suffix we MUST preserve so TryRelink can find the file later. var suffix = $" [nw-{id8}]{extension}"; var suffixBytes = Encoding.UTF8.GetByteCount(suffix); - // Calculate available bytes for "{Artist} - {Title}" var availableBytes = MaxFilenameBytes - suffixBytes; - - if (availableBytes < 20) // Sanity check + if (availableBytes < 20) { Log.Warning("[FileNamer] Suffix too long, truncating ID"); suffix = $" [nw]{extension}"; @@ -65,154 +55,82 @@ public static string Generate(string artist, string title, string trackId, strin availableBytes = MaxFilenameBytes - suffixBytes; } - // Build the base name with truncation if needed - var baseName = $"{cleanArtist} - {cleanTitle}"; - baseName = TruncateToByteLimit(baseName, availableBytes); - - // Handle Windows reserved names - if (IsReservedName(baseName)) - { - baseName = "_" + baseName; - } - + var baseName = TruncateToByteLimit($"{cleanArtist} - {cleanTitle}", availableBytes); var result = baseName + suffix; - - // Final validation + if (Encoding.UTF8.GetByteCount(result) > MaxFilenameBytes) { Log.Warning("[FileNamer] Generated filename exceeds limit, forcing truncation"); - result = TruncateToByteLimit(result, MaxFilenameBytes); + result = TruncateToByteLimit(result, MaxFilenameBytes - suffixBytes) + suffix; } return result; } - /// - /// Extracts the first 8 characters of a track ID (GUID or string). - /// Used for embedding in filenames for reliable recovery. - /// private static string ExtractId8(string trackId) { if (string.IsNullOrWhiteSpace(trackId)) return "00000000"; - // If it's a GUID, format without hyphens if (Guid.TryParse(trackId, out var guid)) - { return guid.ToString("N")[..8]; - } - // Otherwise take first 8 chars var cleaned = Regex.Replace(trackId, @"[^a-zA-Z0-9]", ""); return cleaned.Length >= 8 ? cleaned[..8] : cleaned.PadRight(8, '0'); } - /// - /// Removes all invalid filename characters and collapses whitespace. - /// private static string Sanitize(string input) { if (string.IsNullOrWhiteSpace(input)) return "Unknown"; - // Remove invalid characters - var invalidChars = Path.GetInvalidFileNameChars(); - var sanitized = new string(input.Where(c => !invalidChars.Contains(c)).ToArray()); - - // Replace multiple spaces with single space + // Use the FIXED cross-platform set, not Path.GetInvalidFileNameChars(). + var sanitized = new string(input.Where(c => !InvalidChars.Contains(c) && c >= 32).ToArray()); sanitized = Regex.Replace(sanitized, @"\s+", " ").Trim(); - - // Remove leading/trailing dots and spaces (Windows restriction) sanitized = sanitized.Trim('.', ' '); return string.IsNullOrWhiteSpace(sanitized) ? "Unknown" : sanitized; } - /// - /// Truncates a string to fit within a byte limit while preserving UTF-8 character boundaries. - /// Uses a binary search approach for efficiency. - /// private static string TruncateToByteLimit(string input, int maxBytes) { - if (string.IsNullOrEmpty(input)) - return input; - - var bytes = Encoding.UTF8.GetBytes(input); - if (bytes.Length <= maxBytes) - return input; + if (string.IsNullOrEmpty(input)) return input; - // Binary search for the right character count - int low = 0; - int high = input.Length; - int bestLength = 0; + if (Encoding.UTF8.GetByteCount(input) <= maxBytes) return input; + int low = 0, high = input.Length, bestLength = 0; while (low <= high) { int mid = (low + high) / 2; - var testBytes = Encoding.UTF8.GetByteCount(input[..mid]); - - if (testBytes <= maxBytes) + if (Encoding.UTF8.GetByteCount(input.AsSpan(0, mid)) <= maxBytes) { bestLength = mid; low = mid + 1; } - else - { - high = mid - 1; - } + else high = mid - 1; } + // Never split a surrogate pair (emoji, rare CJK). + if (bestLength > 0 && bestLength < input.Length && char.IsHighSurrogate(input[bestLength - 1])) + bestLength--; + var result = input[..bestLength].TrimEnd(); - - // Add ellipsis if we truncated - if (bestLength < input.Length && bestLength > 3) + + if (bestLength < input.Length && bestLength > 3 + && Encoding.UTF8.GetByteCount(result) + Encoding.UTF8.GetByteCount("...") <= maxBytes) { - var ellipsisBytes = Encoding.UTF8.GetByteCount("..."); - if (maxBytes - Encoding.UTF8.GetByteCount(result) >= ellipsisBytes) - { - result += "..."; - } + result += "..."; } return result; } - /// - /// Checks if a filename is a Windows reserved name. - /// - private static bool IsReservedName(string name) - { - var nameWithoutExt = Path.GetFileNameWithoutExtension(name); - return ReservedNames.Any(r => - string.Equals(r, nameWithoutExt, StringComparison.OrdinalIgnoreCase)); - } - - /// - /// Validates that a filename is safe for cross-platform use. - /// Returns true if the filename is valid, false otherwise. - /// public static bool IsValidFilename(string filename) { - if (string.IsNullOrWhiteSpace(filename)) - return false; - - // Check byte length - if (Encoding.UTF8.GetByteCount(filename) > MaxFilenameBytes) - return false; - - // Check for invalid characters - var invalidChars = Path.GetInvalidFileNameChars(); - if (filename.Any(c => invalidChars.Contains(c))) - return false; - - // Check for reserved names - if (IsReservedName(filename)) - return false; - - // Check for path traversal - if (filename.Contains("..") || filename.Contains("/") || filename.Contains("\\")) - return false; - + if (string.IsNullOrWhiteSpace(filename)) return false; + if (Encoding.UTF8.GetByteCount(filename) > MaxFilenameBytes) return false; + if (filename.Any(c => InvalidChars.Contains(c) || c < 32)) return false; + if (filename.Contains("..") || filename.Contains('/') || filename.Contains('\\')) return false; return true; } } \ No newline at end of file diff --git a/tests/NullWave.Tests/FileNamerTests.cs b/tests/NullWave.Tests/FileNamerTests.cs new file mode 100644 index 0000000..486f138 --- /dev/null +++ b/tests/NullWave.Tests/FileNamerTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Linq; +using System.Text; +using NullWave.Helpers; +using Xunit; + +namespace NullWave.Tests; + +public class FileNamerTests +{ + [Fact] + public void Long_cyrillic_title_fits_255_bytes_and_keeps_suffix() + { + var name = FileNamer.Generate( + "Артист", + new string('ж', 400), + "1e7de1ff-0000-0000-0000-000000000000", + ".mp3"); + + Assert.True(Encoding.UTF8.GetByteCount(name) <= 255, + $"Generated name is {Encoding.UTF8.GetByteCount(name)} bytes: {name}"); + Assert.EndsWith(" [nw-1e7de1ff].mp3", name); + } + + [Fact] + public void Truncation_never_leaves_a_lone_surrogate() + { + for (int n = 0; n < 6; n++) + { + var title = new string('a', n) + string.Concat(Enumerable.Repeat("😀", 200)); + var name = FileNamer.Generate("A", title, Guid.NewGuid().ToString(), ".mp3"); + + for (int i = 0; i < name.Length; i++) + { + if (char.IsHighSurrogate(name[i])) + { + Assert.True(i + 1 < name.Length && char.IsLowSurrogate(name[i + 1]), + $"Lone high surrogate at index {i} in: {name}"); + i++; + } + else + { + Assert.False(char.IsLowSurrogate(name[i]), + $"Lone low surrogate at index {i} in: {name}"); + } + } + } + } + + [Fact] + public void Output_has_no_FAT_invalid_chars() + { + var name = FileNamer.Generate( + "AC/DC", + "Why: me? *\"x\"|", + Guid.NewGuid().ToString(), + ".mp3"); + + var invalid = new[] { '<', '>', ':', '"', '/', '\\', '|', '?', '*' }; + foreach (var c in invalid) + { + Assert.DoesNotContain(c.ToString(), name); + } + } + + [Fact] + public void Guid_id_is_used_as_suffix_prefix() + { + var id = Guid.Parse("1e7de1ff-0000-0000-0000-000000000000"); + var name = FileNamer.Generate("Artist", "Title", id.ToString(), ".mp3"); + Assert.Contains("[nw-1e7de1ff]", name, StringComparison.Ordinal); + } + + [Fact] + public void Empty_title_and_artist_fall_back_to_unknown() + { + var name = FileNamer.Generate("", "", Guid.NewGuid().ToString(), ".flac"); + Assert.StartsWith("Unknown - Unknown [nw-", name); + } +} \ No newline at end of file From d3574fdc4bdf922339bd473d1e82167dc58370d3 Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Sat, 7 Nov 2026 00:02:29 +0400 Subject: [PATCH 28/52] fix(recyclebin): allow dotted names, argv-based process args, working timeout --- Helpers/RecycleBin.cs | 142 ++++++++++++++++-------------------------- 1 file changed, 52 insertions(+), 90 deletions(-) diff --git a/Helpers/RecycleBin.cs b/Helpers/RecycleBin.cs index fe8849a..3a1bca3 100644 --- a/Helpers/RecycleBin.cs +++ b/Helpers/RecycleBin.cs @@ -6,19 +6,8 @@ namespace NullWave.Helpers; -/// -/// Cross-platform helper to send files to the OS recycle bin/trash instead of permanent deletion. -/// Windows: Uses VisualBasic FileSystem API (SendToRecycleBin) -/// Linux: Uses gio trash (GNOME) or kioclient (KDE) -/// macOS: Uses osascript to move to Trash -/// Fallback: Moves to a .trash folder in the same directory -/// public static class RecycleBin { - /// - /// Sends a file to the OS recycle bin. Returns true if successful, false otherwise. - /// Never throws - all errors are logged and return false. - /// public static bool SendToRecycleBin(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) @@ -33,44 +22,20 @@ public static bool SendToRecycleBin(string filePath) return false; } - // Security: Resolve to absolute path and validate string fullPath; - try - { - fullPath = Path.GetFullPath(filePath); - } - catch (Exception ex) - { - Log.Warning(ex, "[RecycleBin] Invalid path: {Path}", filePath); - return false; - } + try { fullPath = Path.GetFullPath(filePath); } + catch (Exception ex) { Log.Warning(ex, "[RecycleBin] Invalid path: {Path}", filePath); return false; } - // Prevent path traversal attacks - if (fullPath.Contains("..")) - { - Log.Warning("[RecycleBin] Blocked path traversal attempt: {Path}", fullPath); - return false; - } + // NOTE: we do NOT reject paths containing ".." here. GetFullPath already resolved + // real traversal, and the literal ".." can appear in legitimate filenames like + // "Wait....mp3" (truncated by FileNamer) or "Song..remix.mp3". try { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return SendToRecycleBinWindows(fullPath); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - return SendToRecycleBinLinux(fullPath); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - return SendToRecycleBinMacOS(fullPath); - } - else - { - Log.Warning("[RecycleBin] Unsupported platform, using fallback"); - return FallbackRecycle(fullPath); - } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return SendToRecycleBinWindows(fullPath); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return SendToRecycleBinLinux(fullPath); + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return SendToRecycleBinMacOS(fullPath); + return FallbackRecycle(fullPath); } catch (Exception ex) { @@ -104,11 +69,10 @@ private static bool SendToRecycleBinWindows(string filePath) { try { - // Use SHFileOperation with FOF_ALLOWUNDO to send to Recycle Bin var fileOp = new SHFILEOPSTRUCT { wFunc = FO_DELETE, - pFrom = filePath + '\0' + '\0', // Double null terminator required + pFrom = filePath + '\0' + '\0', pTo = null, fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT, fAnyOperationsAborted = false, @@ -117,17 +81,13 @@ private static bool SendToRecycleBinWindows(string filePath) }; int result = SHFileOperation(ref fileOp); - if (result == 0 && !fileOp.fAnyOperationsAborted) { Log.Information("[RecycleBin] Successfully recycled (Windows): {Path}", filePath); return true; } - else - { - Log.Warning("[RecycleBin] Windows SHFileOperation failed with code {Code}", result); - return FallbackRecycle(filePath); - } + Log.Warning("[RecycleBin] Windows SHFileOperation failed with code {Code}", result); + return FallbackRecycle(filePath); } catch (Exception ex) { @@ -140,30 +100,22 @@ private static bool SendToRecycleBinLinux(string filePath) { try { - // Try gio trash first (GNOME/GTK standard) - var gioResult = RunProcess("gio", $"trash \"{filePath}\""); - if (gioResult.ExitCode == 0) + // ArgumentList avoids shell injection (titles with " ' ` $ etc). + if (RunProcess("gio", new[] { "trash", filePath }) == 0) { Log.Information("[RecycleBin] Successfully recycled (gio): {Path}", filePath); return true; } - - // Fallback to kioclient (KDE) - var kdeResult = RunProcess("kioclient5", $"move \"{filePath}\" trash:/"); - if (kdeResult.ExitCode == 0) + if (RunProcess("kioclient5", new[] { "move", filePath, "trash:/" }) == 0) { Log.Information("[RecycleBin] Successfully recycled (KDE): {Path}", filePath); return true; } - - // Fallback to trash-cli if available - var trashCliResult = RunProcess("trash-put", $"\"{filePath}\""); - if (trashCliResult.ExitCode == 0) + if (RunProcess("trash-put", new[] { filePath }) == 0) { Log.Information("[RecycleBin] Successfully recycled (trash-cli): {Path}", filePath); return true; } - Log.Warning("[RecycleBin] No Linux trash command available, using fallback"); return FallbackRecycle(filePath); } @@ -178,16 +130,19 @@ private static bool SendToRecycleBinMacOS(string filePath) { try { - // Use osascript to move to Trash via Finder - var script = $"tell application \"Finder\" to delete POSIX file \"{filePath}\""; - var result = RunProcess("osascript", $"-e \"{script}\""); - - if (result.ExitCode == 0) + // Pass the path via argv, not string interpolation. This closes the + // AppleScript injection hole for titles containing quote characters. + if (RunProcess("osascript", new[] + { + "-e", "on run argv", + "-e", "tell application \"Finder\" to delete (POSIX file (item 1 of argv))", + "-e", "end run", + filePath + }) == 0) { Log.Information("[RecycleBin] Successfully recycled (macOS): {Path}", filePath); return true; } - Log.Warning("[RecycleBin] macOS osascript failed, using fallback"); return FallbackRecycle(filePath); } @@ -202,7 +157,6 @@ private static bool FallbackRecycle(string filePath) { try { - // Create a .trash folder in the same directory var dir = Path.GetDirectoryName(filePath); if (string.IsNullOrEmpty(dir)) { @@ -213,7 +167,6 @@ private static bool FallbackRecycle(string filePath) var trashDir = Path.Combine(dir, ".trash"); Directory.CreateDirectory(trashDir); - // Generate unique filename with UTC timestamp to prevent collisions var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss_fff"); var fileName = Path.GetFileName(filePath); var trashPath = Path.Combine(trashDir, $"{timestamp}_{fileName}"); @@ -229,34 +182,43 @@ private static bool FallbackRecycle(string filePath) } } - private static (int ExitCode, string Output) RunProcess(string fileName, string arguments) + // Injection-safe: uses ArgumentList, never a single shell-parsed string. + private static int RunProcess(string fileName, string[] arguments) { try { - var process = new Process + var psi = new ProcessStartInfo { - StartInfo = new ProcessStartInfo - { - FileName = fileName, - Arguments = arguments, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - } + FileName = fileName, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true }; + foreach (var a in arguments) psi.ArgumentList.Add(a); + + using var process = Process.Start(psi); + if (process == null) return -1; - process.Start(); - var output = process.StandardOutput.ReadToEnd(); - var error = process.StandardError.ReadToEnd(); - process.WaitForExit(5000); // 5 second timeout + // Read streams asynchronously so a hung child process doesn't block us past the timeout. + var outTask = process.StandardOutput.ReadToEndAsync(); + var errTask = process.StandardError.ReadToEndAsync(); + + if (!process.WaitForExit(5000)) + { + try { process.Kill(entireProcessTree: true); } catch { } + return -1; + } - return (process.ExitCode, output + error); + // After the process exits, drain remaining output. + outTask.Wait(1000); + errTask.Wait(1000); + return process.ExitCode; } catch (Exception ex) { - Log.Debug(ex, "[RecycleBin] Process execution failed: {FileName} {Args}", fileName, arguments); - return (-1, ex.Message); + Log.Debug(ex, "[RecycleBin] Process execution failed: {FileName}", fileName); + return -1; } } } \ No newline at end of file From c346c401d915a8376daeab81a35a3a5eaf879c36 Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Sat, 7 Nov 2026 00:02:29 +0400 Subject: [PATCH 29/52] fix(journal): debounced atomic flush, flush on exit, honest doc comment --- Services/FileOperationJournal.cs | 58 ++++++++++++++++++++++++++------ Views/MainWindow.axaml.cs | 4 +++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/Services/FileOperationJournal.cs b/Services/FileOperationJournal.cs index e095da8..9bafbff 100644 --- a/Services/FileOperationJournal.cs +++ b/Services/FileOperationJournal.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Text.Json; +using System.Threading; using NullWave.Helpers; using Serilog; @@ -13,8 +14,9 @@ public enum FileOpType { Copy, Move, Delete } public record FileOpRecord(DateTime Utc, FileOpType Type, string Source, string Dest, Guid TrackId); /// -/// Records file system mutations (Copy, Move, Delete) to a bounded JSON journal. -/// Powers the "Undo last organize/import" feature and ensures D16/D17 data safety. +/// Append-only log of file-system mutations. Records (UTC, type, source, dest, trackId) +/// to a bounded JSON file. Does NOT implement undo (yet) - this is the audit trail that +/// a future "Undo last import/organize" feature will consume. D16/D17 data-safety evidence. /// public class FileOperationJournal { @@ -26,6 +28,11 @@ public class FileOperationJournal private readonly object _lock = new(); private const int MaxRecords = 1000; + // Debounced flush: every Record() re-arms the timer; fires 2 s after the last mutation. + // Avoids rewriting the file on every record during a 5k-track import. + private Timer? _flushTimer; + private bool _dirty; + private FileOperationJournal() { _path = Path.Combine(NullWavePaths.DataDir, "file-ops.json"); @@ -36,12 +43,16 @@ private void Load() { try { - if (File.Exists(_path)) - { - var json = File.ReadAllText(_path); - var ops = JsonSerializer.Deserialize>(json); - if (ops != null) _ops.AddRange(ops); - } + if (!File.Exists(_path)) return; + var json = File.ReadAllText(_path); + if (string.IsNullOrWhiteSpace(json)) return; + var ops = JsonSerializer.Deserialize>(json); + if (ops != null) _ops.AddRange(ops); + } + catch (JsonException ex) + { + Log.Warning(ex, "[Journal] file-ops.json is corrupt; quarantining and starting fresh"); + try { File.Move(_path, _path + ".bad-" + UtcTime.FileStamp()); } catch { } } catch (Exception ex) { Log.Warning(ex, "[Journal] Failed to load file-ops.json"); } } @@ -50,10 +61,11 @@ private void Save() { try { - // Keep only the most recent operations to prevent unbounded growth var recent = _ops.TakeLast(MaxRecords).ToList(); var json = JsonSerializer.Serialize(recent, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(_path, json); + var tmp = _path + ".tmp"; + File.WriteAllText(tmp, json); + File.Move(tmp, _path, overwrite: true); } catch (Exception ex) { Log.Warning(ex, "[Journal] Failed to save file-ops.json"); } } @@ -63,7 +75,32 @@ public void Record(FileOpType type, string source, string dest, Guid trackId) lock (_lock) { _ops.Add(new FileOpRecord(DateTime.UtcNow, type, source, dest, trackId)); + _dirty = true; + // Debounce: re-arm the timer on every mutation. Flush happens 2 s after the last one. + _flushTimer ??= new Timer(_ => FlushIfDirty(), null, Timeout.Infinite, Timeout.Infinite); + _flushTimer.Change(2000, Timeout.Infinite); + } + } + + /// Called by the caller at shutdown to guarantee the journal is on disk before exit. + public void Flush() + { + lock (_lock) + { + _flushTimer?.Change(Timeout.Infinite, Timeout.Infinite); + if (!_dirty) return; + Save(); + _dirty = false; + } + } + + private void FlushIfDirty() + { + lock (_lock) + { + if (!_dirty) return; Save(); + _dirty = false; } } @@ -75,6 +112,7 @@ public void Record(FileOpType type, string source, string dest, Guid trackId) var last = _ops[^1]; _ops.RemoveAt(_ops.Count - 1); Save(); + _dirty = false; return last; } } diff --git a/Views/MainWindow.axaml.cs b/Views/MainWindow.axaml.cs index 57b9b88..00669af 100644 --- a/Views/MainWindow.axaml.cs +++ b/Views/MainWindow.axaml.cs @@ -119,6 +119,10 @@ private void OnMainWindowClosing(object? sender, WindowClosingEventArgs e) _hangDetector?.Dispose(); try { + // Flush the file-operation journal so the last import/export isn't lost. + try { Services.FileOperationJournal.Instance.Flush(); } + catch (Exception ex) { Log.Warning(ex, "[MainWindow] Failed to flush file-op journal on exit"); } + if (DataContext is MainViewModel vm) { vm.Settings.StopHealthCheck(); From 8204ab6ac3750f9c93473153011bde75f867e6ec Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Sat, 7 Nov 2026 00:02:29 +0400 Subject: [PATCH 30/52] fix(library): central CanWriteTags, filename-marker managed rule, relink matches FileNamer ids --- .../Library/LibraryService.Maintenance.cs | 67 +++----- Services/Library/LibraryService.Sync.cs | 60 +++---- Services/Library/LibraryService.cs | 161 +++++++++--------- 3 files changed, 131 insertions(+), 157 deletions(-) diff --git a/Services/Library/LibraryService.Maintenance.cs b/Services/Library/LibraryService.Maintenance.cs index 10a064a..b0a02d9 100644 --- a/Services/Library/LibraryService.Maintenance.cs +++ b/Services/Library/LibraryService.Maintenance.cs @@ -56,29 +56,16 @@ public int RelinkMissingFiles() foreach (var track in missing) { var urlId = ExtractYouTubeId(track.Url); - var id8 = track.Id.ToString("N")[..8]; - - // --- PATCH 3: Pre-compute normalized name for fallback --- + var id8 = track.Id.ToString("N")[..8]; var wanted = TrackNormalizer.NormalizeForCompare($"{track.Artist} - {track.Title}"); - var match = orphans.FirstOrDefault(f => - { - var fileName = Path.GetFileNameWithoutExtension(f); - if (fileName.Contains($"[nw-{id8}-", StringComparison.OrdinalIgnoreCase)) return true; - if (urlId != null && fileName.Contains($"-{urlId}]", StringComparison.OrdinalIgnoreCase)) return true; - - // --- PATCH 3: Name fallback for files already renamed by Sync --- - if (!string.IsNullOrWhiteSpace(track.Artist) && !string.IsNullOrWhiteSpace(track.Title) - && TrackNormalizer.NormalizeForCompare(fileName) == wanted) return true; - - return false; - }); + var match = orphans.FirstOrDefault(f => MatchesRelinkCandidate(f, id8, urlId, wanted)); if (match != null) { track.FilePath = match; _db.Update(track); - orphans.Remove(match); + orphans.Remove(match); relinked++; Log.Information("[LibraryService] Relinked track {Title} to {Path}", track.Title, match); } @@ -89,11 +76,6 @@ public int RelinkMissingFiles() return relinked; } - /// - /// Attempts to find and relink a single track whose file is missing. - /// Checks ID tags, URL IDs, and falls back to normalized "Artist - Title" matching - /// for files that were already renamed by the sync engine. - /// public bool TryRelink(Track track) { if (track == null || string.IsNullOrEmpty(track.FilePath) || File.Exists(track.FilePath)) @@ -116,24 +98,7 @@ public bool TryRelink(Track track) { match = Directory.EnumerateFiles(downloadsDir, "*.*", SearchOption.AllDirectories) .Where(f => audioExts.Contains(Path.GetExtension(f).ToLowerInvariant())) - .FirstOrDefault(f => - { - var fileName = Path.GetFileNameWithoutExtension(f); - - // Match patterns like: "Song Name [nw-1e7de1ff-kXYiU_JCYtU]" - if (fileName.Contains($"[nw-{id8}-", StringComparison.OrdinalIgnoreCase)) - return true; - - if (urlId != null && fileName.Contains($"-{urlId}]", StringComparison.OrdinalIgnoreCase)) - return true; - - // Fallback: match by normalized "Artist - Title" for files already renamed by Sync - if (!string.IsNullOrWhiteSpace(track.Artist) && !string.IsNullOrWhiteSpace(track.Title) && - TrackNormalizer.NormalizeForCompare(fileName) == wanted) - return true; - - return false; - }); + .FirstOrDefault(f => MatchesRelinkCandidate(f, id8, urlId, wanted)); } catch (Exception ex) { @@ -151,6 +116,30 @@ public bool TryRelink(Track track) return true; } + /// + /// Matches either the legacy [nw-id8-videoId] format, the new FileNamer [nw-id8] format, + /// or falls back to a normalized "Artist - Title" comparison. + /// + private static bool MatchesRelinkCandidate(string filePath, string id8, string? urlId, string wanted) + { + var fileName = Path.GetFileNameWithoutExtension(filePath); + + // New FileNamer format: "... [nw-{id8}]" + if (fileName.Contains($"[nw-{id8}]", StringComparison.OrdinalIgnoreCase)) return true; + + // Legacy format: "... [nw-{id8}-{videoId}]" + if (fileName.Contains($"[nw-{id8}-", StringComparison.OrdinalIgnoreCase)) return true; + + // Legacy video-id-only suffix + if (urlId != null && fileName.Contains($"-{urlId}]", StringComparison.OrdinalIgnoreCase)) return true; + + // Normalized-name fallback for files renamed by older Sync versions. + // Guard: a track with empty artist/title has an empty wanted key, which would + // match any other empty-named file. Only match when wanted is non-empty. + return !string.IsNullOrWhiteSpace(wanted) + && TrackNormalizer.NormalizeForCompare(fileName) == wanted; + } + private static string? ExtractYouTubeId(string? url) { if (string.IsNullOrEmpty(url)) return null; diff --git a/Services/Library/LibraryService.Sync.cs b/Services/Library/LibraryService.Sync.cs index f5430ca..79c29fc 100644 --- a/Services/Library/LibraryService.Sync.cs +++ b/Services/Library/LibraryService.Sync.cs @@ -2,9 +2,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using NullWave.Helpers; using NullWave.Models; using Serilog; -using NullWave.Helpers; namespace NullWave.Services; @@ -42,53 +42,52 @@ public void NormalizeLocalFile(Track t) return (false, false); // S6 SAFETY GUARD: Never touch imported user files. Only sync files NullWave downloaded. - if (!IsManagedDownload(track.FilePath)) + if (!IsManagedDownload(track.FilePath)) return (false, false); bool retagged = false, renamed = false; if (_metadata != null) { var (fT, fA, _) = _metadata.FetchFromLocalFile(track.FilePath!); - - // Swapped tags detection - if (!TrackNormalizer.LooselyMatch(track.Title, fT, track.Artist, fA) && + + if (!TrackNormalizer.LooselyMatch(track.Title, fT, track.Artist, fA) && TrackNormalizer.LooselyMatch(track.Title, fA, track.Artist, fT)) { - // FIX: Only mutate in-memory track and DB if NOT a dry run if (!dryRun) { - track.Title = TrackNormalizer.CleanTitle(fT); + track.Title = TrackNormalizer.CleanTitle(fT); track.Artist = TrackNormalizer.CleanArtist(fA); _db.Update(track); } return (true, false); } - - // Standard tag mismatch + if (!string.Equals(fT, track.Title, StringComparison.Ordinal) || !string.Equals(fA, track.Artist, StringComparison.Ordinal)) { - // FIX: Only count as retagged if we actually wrote to the file (or if it's a dry run where we *would* write) - bool wouldWrite = !dryRun ? _metadata.WriteTagsToFile(track.FilePath!, track.Title, track.Artist) : true; - if (wouldWrite) retagged = true; + // N2 FIX: route through the central CanWriteTags rule. + bool shouldWrite = CanWriteTags(track.FilePath); + if (shouldWrite) + { + if (!dryRun) _metadata.WriteTagsToFile(track.FilePath!, track.Title, track.Artist); + retagged = true; + } } } var dir = Path.GetDirectoryName(track.FilePath!)!; var ext = Path.GetExtension(track.FilePath!); - - // N1 FIX: Use unified FileNamer instead of inline sanitization - // This preserves the [nw-{id8}] suffix for reliable file recovery via TryRelink + var name = FileNamer.Generate(track.Artist, track.Title, track.Id.ToString(), ext); var target = Path.Combine(dir, name); if (!string.Equals(Path.GetFileName(track.FilePath!), name, StringComparison.OrdinalIgnoreCase)) { if (File.Exists(target)) return (retagged, false); - if (!dryRun) - { - File.Move(track.FilePath!, target); - track.FilePath = target; - _db.Update(track); + if (!dryRun) + { + File.Move(track.FilePath!, target); + track.FilePath = target; + _db.Update(track); } renamed = true; } @@ -98,24 +97,11 @@ public void NormalizeLocalFile(Track t) public void UpdateFileTags(Track track) { if (_metadata == null || string.IsNullOrEmpty(track.FilePath) || !File.Exists(track.FilePath)) return; - var mode = _prefs?.Current.TagWriteMode ?? TagWriteMode.DownloadsOnly; - switch (mode) + // N2: central enforcement. + if (!CanWriteTags(track.FilePath)) { - case TagWriteMode.Never: - Log.Debug("[LibraryService] TagWriteMode=Never, skipping tag write for {Path}", track.FilePath); - return; - - case TagWriteMode.DownloadsOnly: - if (!IsManagedDownload(track.FilePath)) - { - Log.Debug("[LibraryService] TagWriteMode=DownloadsOnly, skipping user file {Path}", track.FilePath); - return; - } - break; - - case TagWriteMode.Always: - // Write to all files, including user imports - break; + Log.Debug("[LibraryService] TagWriteMode denied write for {Path}", track.FilePath); + return; } _metadata.WriteTagsToFile(track.FilePath, track.Title, track.Artist); } diff --git a/Services/Library/LibraryService.cs b/Services/Library/LibraryService.cs index 15a923d..bbb3b6c 100644 --- a/Services/Library/LibraryService.cs +++ b/Services/Library/LibraryService.cs @@ -47,7 +47,7 @@ public LibraryService(DatabaseService db, MetadataService? metadata = null, Pref _prefs = prefs; lock (_tracksLock) { _tracks = _db.LoadAll(); } Log.Information("[LibraryService] Loaded {Count} tracks from DB", _tracks.Count); - Log.Information("[LibraryService] By type: {Counts}", + Log.Information("[LibraryService] By type: {Counts}", string.Join(", ", _tracks.GroupBy(t => t.MediaType).Select(g => $"{g.Key}={g.Count()}"))); if (!startBackgroundWork) return; @@ -56,10 +56,10 @@ public LibraryService(DatabaseService db, MetadataService? metadata = null, Pref _ = Task.Run(BackfillAlbumArt); _ = Task.Run(BackfillYouTubeThumbnails); _ = Task.Run(BackfillSoundCloudThumbnails); - + _ = Task.Run(async () => { - await Task.Delay(1500); + await Task.Delay(1500); List targets; lock (_tracksLock) { @@ -85,9 +85,11 @@ public LibraryService(DatabaseService db, MetadataService? metadata = null, Pref } /// - /// S6: Returns true if the file is inside the NullWave downloads directory. - /// We only automatically write ID3 tags to files we downloaded ourselves, - /// never to a user's personal imported music collection. + /// S6: Returns true if the file is inside a NullWave-managed downloads directory. + /// The default DownloadsDir is always managed. A user-configured DownloadDirectory + /// is only treated as managed when the file's name contains the "[nw-" marker + /// NullWave writes - so pointing this pref at a personal music folder doesn't + /// silently retag everything in it. /// private bool IsManagedDownload(string? filePath) { @@ -95,31 +97,51 @@ private bool IsManagedDownload(string? filePath) try { var fullPath = Path.GetFullPath(filePath); - - // Check both the default path AND the user-configured path from Preferences - var dirsToCheck = new List { Path.GetFullPath(NullWavePaths.DownloadsDir) }; - if (_prefs != null && !string.IsNullOrWhiteSpace(_prefs.Current.DownloadDirectory)) - { - try { dirsToCheck.Add(Path.GetFullPath(_prefs.Current.DownloadDirectory)); } catch { } - } + var defaultDir = Path.GetFullPath(NullWavePaths.DownloadsDir); + var defaultCheck = defaultDir.EndsWith(Path.DirectorySeparatorChar.ToString()) + ? defaultDir : defaultDir + Path.DirectorySeparatorChar; - foreach (var dir in dirsToCheck) + if (fullPath.StartsWith(defaultCheck, StringComparison.OrdinalIgnoreCase) || + string.Equals(fullPath, defaultDir.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase)) + return true; + + if (_prefs != null && !string.IsNullOrWhiteSpace(_prefs.Current.DownloadDirectory)) { - var checkDir = dir.EndsWith(Path.DirectorySeparatorChar.ToString()) - ? dir - : dir + Path.DirectorySeparatorChar; - - if (fullPath.StartsWith(checkDir, StringComparison.OrdinalIgnoreCase) || - string.Equals(fullPath, dir.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase)) + try { - return true; + var userDir = Path.GetFullPath(_prefs.Current.DownloadDirectory); + if (string.Equals(userDir, defaultDir, StringComparison.OrdinalIgnoreCase)) return true; + + var userCheck = userDir.EndsWith(Path.DirectorySeparatorChar.ToString()) + ? userDir : userDir + Path.DirectorySeparatorChar; + + if (fullPath.StartsWith(userCheck, StringComparison.OrdinalIgnoreCase) || + string.Equals(fullPath, userDir.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase)) + { + // Only the FILE NAME matters: "[nw-" is what NullWave writes. + return Path.GetFileName(fullPath).Contains("[nw-", StringComparison.OrdinalIgnoreCase); + } } + catch { } } return false; } catch { return false; } } + /// Central rule for tag writes. All callers (Add, Sync, ForceClean) go through this. + internal bool CanWriteTags(string? filePath) + { + var mode = _prefs?.Current.TagWriteMode ?? TagWriteMode.DownloadsOnly; + return mode switch + { + TagWriteMode.Never => false, + TagWriteMode.DownloadsOnly => IsManagedDownload(filePath), + TagWriteMode.Always => !string.IsNullOrEmpty(filePath) && File.Exists(filePath), + _ => false + }; + } + private void EnsureIdentityIndex() { if (_identityIndexVersion == StateVersion) return; @@ -133,10 +155,10 @@ private static IEnumerable GetIdentityKeys(Track t) { if (!string.IsNullOrWhiteSpace(t.Url)) yield return $"url:{t.Url.ToLowerInvariant()}"; if (!string.IsNullOrWhiteSpace(t.FilePath)) yield return $"path:{t.FilePath.ToLowerInvariant()}"; - + var normTitle = TrackNormalizer.NormalizeForCompare(t.Title); var normArtist = TrackNormalizer.NormalizeForCompare(t.Artist); - if (normTitle.Length > 0 && normArtist.Length > 0) + if (normTitle.Length > 0 && normArtist.Length > 0) yield return $"ta:{normArtist}|{normTitle}"; } @@ -173,22 +195,22 @@ public void Add(Track track) track.FilePath = fullPath; } - lock (_tracksLock) + lock (_tracksLock) { EnsureIdentityIndex(); - if (GetIdentityKeys(track).Any(k => _identityIndex.TryGetValue(k, out var existingId) && existingId != track.Id)) - return; + if (GetIdentityKeys(track).Any(k => _identityIndex.TryGetValue(k, out var existingId) && existingId != track.Id)) + return; _tracks.Add(track); foreach (var k in GetIdentityKeys(track)) _identityIndex.TryAdd(k, track.Id); StateVersion++; _identityIndexVersion = StateVersion; } - + _db.Insert(track); OnLibraryChanged(); - // N2 FIX: UpdateFileTags now respects the user's TagWriteMode setting (Never/DownloadsOnly/Always) + // N2: tag writes go through the central CanWriteTags rule. UpdateFileTags(track); if (!string.IsNullOrEmpty(track.FilePath) && string.IsNullOrEmpty(track.AlbumArtPath) && _metadata != null) @@ -201,7 +223,7 @@ public void Add(Track track) if (!string.IsNullOrEmpty(artPath)) { track.AlbumArtPath = artPath; - Update(track); + Update(track); } } catch (Exception ex) @@ -224,80 +246,57 @@ public void Add(Track track) } /// - /// Removes a track from the library and optionally sends its file to the recycle bin. - /// This is the safe deletion method that should be used for user-initiated removals. + /// Removes a track and optionally recycles its file. Unmanaged (user-imported) files are + /// NEVER deleted, even if recycleFile is true - they are only removed from the DB. /// - /// Track ID to remove - /// If true, sends the file to OS recycle bin. If false, only removes from library. - /// True if the track was removed, false if it wasn't found - public bool RemoveWithFile(Guid id, bool deleteFile = false) - { - Track? track; - lock (_tracksLock) - { - track = _tracks.FirstOrDefault(t => t.Id == id); - if (track == null) return false; - } - - // Remove from library first (this also removes from queue/playlists) - Remove(id); - - // Optionally delete the file - if (deleteFile && !string.IsNullOrEmpty(track.FilePath) && File.Exists(track.FilePath)) - { - var success = RecycleBin.SendToRecycleBin(track.FilePath); - if (success) - { - Log.Information("[LibraryService] Recycled file for track {Title}: {Path}", - track.Title, track.FilePath); - } - else - { - Log.Warning("[LibraryService] Failed to recycle file for track {Title}: {Path}", - track.Title, track.FilePath); - } - } - - return true; - } - public void Remove(Guid id, bool recycleFile = false) { Track? track; - lock (_tracksLock) + lock (_tracksLock) { track = _tracks.FirstOrDefault(t => t.Id == id); if (track == null) return; - + _tracks.Remove(track); foreach (var k in GetIdentityKeys(track)) _identityIndex.Remove(k); StateVersion++; _identityIndexVersion = StateVersion; } - + var queueEntry = _queue.FirstOrDefault(e => e.Track.Id == id); if (queueEntry != null) { _queue.Remove(queueEntry); QueueChanged?.Invoke(this, EventArgs.Empty); } - + _db.Delete(id); OnLibraryChanged(); TrackRemovedFromLibrary?.Invoke(this, id); - // N4 FIX: Safely recycle the file if requested and it's a managed download if (recycleFile && track != null && !string.IsNullOrEmpty(track.FilePath) && File.Exists(track.FilePath)) { + // Unified rule: only managed downloads may be recycled. if (IsManagedDownload(track.FilePath)) { - var success = RecycleBin.SendToRecycleBin(track.FilePath); - if (success) Log.Information("[LibraryService] Recycled file for track {Title}", track.Title); + if (RecycleBin.SendToRecycleBin(track.FilePath)) + Log.Information("[LibraryService] Recycled file for track {Title}", track.Title); + else + Log.Warning("[LibraryService] Failed to recycle file for track {Title}: {Path}", track.Title, track.FilePath); } else { - // Safety guard: Never delete user's personal imported files Log.Warning("[LibraryService] Refused to recycle unmanaged user file: {Path}", track.FilePath); } } } + /// Convenience wrapper. Equivalent to Remove(id, recycleFile: deleteFile). + public bool RemoveWithFile(Guid id, bool deleteFile = false) + { + bool existed; + lock (_tracksLock) existed = _tracks.Any(t => t.Id == id); + if (!existed) return false; + Remove(id, recycleFile: deleteFile); + return true; + } + public void Update(Track track) { _db.UpdateAsync(track); @@ -306,7 +305,7 @@ public void Update(Track track) var idx = _tracks.FindIndex(t => t.Id == track.Id); if (idx >= 0) _tracks[idx] = track; StateVersion++; - _identityIndexVersion = -1; + _identityIndexVersion = -1; } OnLibraryChanged(); } @@ -343,9 +342,9 @@ private static IReadOnlyList SortTracks(IEnumerable source, SortFi SortField.Source => t => t.Source, SortField.PlayCount => t => t.PlayCount, SortField.LastPlayed => t => t.LastPlayed, - _ => t => t.DateAdded + _ => t => t.DateAdded }; - + var sorted = ascending ? source.OrderBy(key) : source.OrderByDescending(key); return sorted.ThenBy(t => t.Title, StringComparer.OrdinalIgnoreCase).ToList(); } @@ -388,7 +387,7 @@ public void RecordPlay(Guid id) lock (_tracksLock) track = _tracks.FirstOrDefault(t => t.Id == id); if (track == null) return; track.PlayCount++; - track.LastPlayed = DateTime.UtcNow; + track.LastPlayed = DateTime.UtcNow; _db.UpdateAsync(track); StateVersion++; _history.Add(track); @@ -449,9 +448,9 @@ private void OnLibraryChanged() _changeTimer.Change(250, Timeout.Infinite); } - public void Dispose() - { - _changeTimer?.Dispose(); - _db.Dispose(); + public void Dispose() + { + _changeTimer?.Dispose(); + _db.Dispose(); } } \ No newline at end of file From fc7550d4fb6fdec9a43b568880664aa9ba4cdcf3 Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Sat, 7 Nov 2026 00:02:30 +0400 Subject: [PATCH 31/52] fix(export): run off the UI thread, restore TagLib globals, skip placeholder artists --- Services/Library/ExportService.cs | 163 ++++++++++++++++------------ ViewModels/Tools/ExportViewModel.cs | 24 ++-- 2 files changed, 104 insertions(+), 83 deletions(-) diff --git a/Services/Library/ExportService.cs b/Services/Library/ExportService.cs index 288b951..88c4759 100644 --- a/Services/Library/ExportService.cs +++ b/Services/Library/ExportService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Text.Json; using System.Threading; @@ -51,92 +52,114 @@ public List ImportFromJson(string filePath) } /// - /// N6: Safe Export Pipeline. Copies files to a destination folder, renames them using - /// FileNamer (255-byte limit), retags the COPIES (forcing ID3v2.3 for compatibility), + /// N6: Safe Export Pipeline. Copies files to a destination folder, renames them using + /// FileNamer (255-byte limit), retags the COPIES (forcing ID3v2.3 for compatibility), /// and generates a relative M3U8 playlist. Original files are never modified. /// - public async Task ExportToFolderAsync(IReadOnlyList tracks, string destDir, CancellationToken ct = default) + public Task ExportToFolderAsync(IReadOnlyList tracks, string destDir, CancellationToken ct = default) { - Directory.CreateDirectory(destDir); - int copied = 0, skipped = 0, failed = 0; - var exportedTracks = new List(); - - // Force ID3v2.3 for maximum compatibility (Android players, older car stereos) - TagLib.Id3v2.Tag.ForceDefaultVersion = true; - TagLib.Id3v2.Tag.DefaultVersion = 3; - - for (int i = 0; i < tracks.Count; i++) + return Task.Run(() => { - ct.ThrowIfCancellationRequested(); - var t = tracks[i]; - - if (string.IsNullOrEmpty(t.FilePath) || !File.Exists(t.FilePath)) - { - skipped++; - continue; - } + Directory.CreateDirectory(destDir); + int copied = 0, skipped = 0, failed = 0; + var exportedTracks = new List(); + + // Force ID3v2.3 for maximum compatibility (Android players, older car stereos). + // These are STATIC in TagLib, so we MUST restore them in a finally block or every + // later tag write in the app silently becomes v2.3. + var prevForce = TagLib.Id3v2.Tag.ForceDefaultVersion; + var prevVer = TagLib.Id3v2.Tag.DefaultVersion; + TagLib.Id3v2.Tag.ForceDefaultVersion = true; + TagLib.Id3v2.Tag.DefaultVersion = 3; try { - var ext = Path.GetExtension(t.FilePath); - - // N1: Use unified FileNamer to enforce 255-byte limit and clean names - var safeName = FileNamer.Generate(t.Artist, t.Title, t.Id.ToString(), ext); - var destPath = Path.Combine(destDir, safeName); - - if (File.Exists(destPath)) - destPath = Path.Combine(destDir, $"{Path.GetFileNameWithoutExtension(safeName)}_{i}{ext}"); - - // 1. Copy the file (never move or modify the original) - File.Copy(t.FilePath, destPath, overwrite: false); - FileOperationJournal.Instance.Record(FileOpType.Copy, t.FilePath, destPath, t.Id); - - // 2. Retag the COPY - try + for (int i = 0; i < tracks.Count; i++) { - using var file = TagLib.File.Create(destPath); - file.Tag.Title = t.Title; - file.Tag.Performers = new[] { t.Artist }; - file.Save(); + ct.ThrowIfCancellationRequested(); + var t = tracks[i]; + + if (string.IsNullOrEmpty(t.FilePath) || !File.Exists(t.FilePath)) + { + skipped++; + continue; + } + + string? destPath = null; + try + { + var ext = Path.GetExtension(t.FilePath); + var safeName = FileNamer.Generate(t.Artist, t.Title, t.Id.ToString(), ext); + destPath = Path.Combine(destDir, safeName); + + if (File.Exists(destPath)) + destPath = Path.Combine(destDir, $"{Path.GetFileNameWithoutExtension(safeName)}_{i}{ext}"); + + // 1. Copy the file (never move or modify the original) + try + { + File.Copy(t.FilePath, destPath, overwrite: false); + } + catch + { + // Don't leave a half-copied file on the USB if the copy failed. + try { if (File.Exists(destPath)) File.Delete(destPath); } catch { } + throw; + } + FileOperationJournal.Instance.Record(FileOpType.Copy, t.FilePath, destPath, t.Id); + + // 2. Retag the COPY. Skip placeholder artists so we don't write "Unknown" into files. + try + { + using var file = TagLib.File.Create(destPath); + file.Tag.Title = t.Title; + if (!string.IsNullOrWhiteSpace(t.Artist) && !TrackNormalizer.IsPlaceholderArtist(t.Artist)) + file.Tag.Performers = new[] { t.Artist }; + file.Save(); + } + catch (Exception ex) + { + Log.Debug(ex, "[Export] Retag failed for copy {Path}", destPath); + } + + copied++; + exportedTracks.Add(new Track + { + Title = t.Title, + Artist = t.Artist, + FilePath = destPath, + Duration = t.Duration, + MediaType = t.MediaType + }); + } + catch (Exception ex) + { + Log.Warning(ex, "[ExportService] Failed to export {Title}", t.Title); + failed++; + } } - catch (Exception ex) - { - Log.Debug(ex, "[Export] Retag failed for copy {Path}", destPath); + + // 3. Generate relative M3U8 playlist + string? m3uPath = null; + if (exportedTracks.Count > 0) + { + m3uPath = Path.Combine(destDir, "playlist.m3u8"); + var result = M3uWriter.Build(exportedTracks, m3uPath, + new M3uWriteOptions(RelativePaths: true, PlaylistName: "Exported Playlist")); + M3uWriter.WriteFile(m3uPath, result.Text); } - copied++; - - // Track the new path for the M3U8 generator - exportedTracks.Add(new Track - { - Title = t.Title, - Artist = t.Artist, - FilePath = destPath, - Duration = t.Duration, - MediaType = t.MediaType - }); + return new ExportFolderResult(copied, skipped, failed, m3uPath); } - catch (Exception ex) + finally { - Log.Warning(ex, "[ExportService] Failed to export {Title}", t.Title); - failed++; + // ALWAYS restore TagLib's global state, even if cancellation or error. + TagLib.Id3v2.Tag.ForceDefaultVersion = prevForce; + TagLib.Id3v2.Tag.DefaultVersion = prevVer; } - } - - // 3. Generate relative M3U8 playlist - string? m3uPath = null; - if (exportedTracks.Count > 0) - { - m3uPath = Path.Combine(destDir, "playlist.m3u8"); - var result = M3uWriter.Build(exportedTracks, m3uPath, new M3uWriteOptions(RelativePaths: true, PlaylistName: "Exported Playlist")); - M3uWriter.WriteFile(m3uPath, result.Text); - } - - return await Task.FromResult(new ExportFolderResult(copied, skipped, failed, m3uPath)); + }, ct); } - public record ExportFolderResult(int Copied, int Skipped, int Failed, string? PlaylistPath); - private static string? CsvSafe(string? value) { if (string.IsNullOrEmpty(value)) return value; diff --git a/ViewModels/Tools/ExportViewModel.cs b/ViewModels/Tools/ExportViewModel.cs index 7949322..3b7b9c8 100644 --- a/ViewModels/Tools/ExportViewModel.cs +++ b/ViewModels/Tools/ExportViewModel.cs @@ -3,7 +3,7 @@ using System.IO; using System.Linq; using System.Threading; -using System.Threading.Tasks; +using System.Threading.Tasks; using System.Windows.Input; using Avalonia; using Avalonia.Controls; @@ -11,7 +11,7 @@ using Avalonia.Platform.Storage; using NullWave.Helpers; using NullWave.Services; -using NullWave.Models; +using NullWave.Models; using NullWave.ViewModels.Base; using Serilog; @@ -25,13 +25,13 @@ public class ExportViewModel : ViewModelBase public ICommand ExportJsonCommand { get; } public ICommand ExportCsvCommand { get; } public ICommand ExportM3uCommand { get; } - public ICommand ExportFolderCommand { get; } // Safe Export to USB/Folder + public ICommand ExportFolderCommand { get; } public ExportViewModel(LibraryService library, ExportService export) { _library = library; _export = export; - + ExportJsonCommand = new SafeRelayCommand(async () => await ExportAsync("json"), errorScope: "export"); ExportCsvCommand = new SafeRelayCommand(async () => await ExportAsync("csv"), errorScope: "export"); ExportM3uCommand = new SafeRelayCommand(async () => await ExportAsync("m3u8"), errorScope: "export"); @@ -50,8 +50,8 @@ private async Task ExportAsync(string format) FileTypeChoices = new[] { new FilePickerFileType(format.ToUpper()) { Patterns = new[] { $"*.{format}" } } } }); - if (file == null) return; - + if (file == null) return; + var path = file.Path.LocalPath; if (string.IsNullOrWhiteSpace(path)) { ErrorReporter.Report("Invalid file path selected.", "export"); return; } @@ -65,7 +65,7 @@ private async Task ExportAsync(string format) var result = M3uWriter.Build(tracks, path, new M3uWriteOptions(RelativePaths: true)); M3uWriter.WriteFile(path, result.Text); ToastService.Instance.Show($"Exported {result.Written} tracks to M3U ({result.SkippedMissing} skipped missing files)", ToastType.Success, durationMs: 4000, scope: "export"); - return; + return; } ToastService.Instance.Show($"Exported {tracks.Count} tracks to {Path.GetFileName(path)}", ToastType.Success, durationMs: 4000, scope: "export"); @@ -87,9 +87,8 @@ private async Task ExportToFolderAsync() if (folders.Count == 0) return; var destDir = folders[0].Path.LocalPath; - // N6: Export only tracks that have a valid local file var tracks = _library.GetAll().Where(t => !string.IsNullOrEmpty(t.FilePath) && File.Exists(t.FilePath)).ToList(); - + if (tracks.Count == 0) { ToastService.Instance.Show("No tracks with local files found to export.", ToastType.Warning, scope: "export-folder"); @@ -101,12 +100,11 @@ private async Task ExportToFolderAsync() try { var cts = new CancellationTokenSource(); - // Note: For a true progress callback, you'd pass the IProgress to ExportToFolderAsync. - // For now, we just run it and complete the activity. + // Runs on a pool thread inside ExportService. var result = await _export.ExportToFolderAsync(tracks, destDir, cts.Token); - + ToastService.Instance.CompleteLiveActivity( - activity, + activity, $"Export complete: {result.Copied} copied, {result.Skipped} skipped, {result.Failed} failed.", lingerMs: 5000, finalType: result.Failed > 0 ? ToastType.Warning : ToastType.Success); From af82021c604ccdd5f8233d290c813e7d577b5ece Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Sat, 7 Nov 2026 00:02:30 +0400 Subject: [PATCH 32/52] fix(import): duplicate-check before copy, honor ImportMode pref --- ViewModels/Shell/MainViewModel.Integration.cs | 2 +- ViewModels/Tools/ImportViewModel.cs | 88 +++++++++++-------- 2 files changed, 51 insertions(+), 39 deletions(-) diff --git a/ViewModels/Shell/MainViewModel.Integration.cs b/ViewModels/Shell/MainViewModel.Integration.cs index df2c7e7..fe62dc0 100644 --- a/ViewModels/Shell/MainViewModel.Integration.cs +++ b/ViewModels/Shell/MainViewModel.Integration.cs @@ -155,7 +155,7 @@ private void InitializeChildViewModels() }; Export = new ExportViewModel(_library, _export); Detail = new TrackDetailViewModel(_library, _plugins, _identity, _prefsService); - Import = new ImportViewModel(_library, _metadata); + Import = new ImportViewModel(_library, _metadata, _prefsService); Settings.ImportExistingLibraryRequested += () => Import.ImportFolderCommand.Execute(null); Player = new PlayerViewModel(_playbackService, _downloadService, _library, Settings, _metadata); diff --git a/ViewModels/Tools/ImportViewModel.cs b/ViewModels/Tools/ImportViewModel.cs index 2e63110..4e0986f 100644 --- a/ViewModels/Tools/ImportViewModel.cs +++ b/ViewModels/Tools/ImportViewModel.cs @@ -21,16 +21,18 @@ public class ImportViewModel : ViewModelBase { private readonly LibraryService _library; private readonly MetadataService _metadata; + private readonly PreferencesService _prefs; public ICommand ImportFolderCommand { get; } public event Action? ImportCompleted; private static readonly string[] SupportedExtensions = { ".mp3", ".flac", ".wav", ".ogg", ".m4a", ".aac" }; - public ImportViewModel(LibraryService library, MetadataService metadata) + public ImportViewModel(LibraryService library, MetadataService metadata, PreferencesService prefs) { _library = library; _metadata = metadata; + _prefs = prefs; ImportFolderCommand = new RelayCommand(async () => await ImportFolderAsync()); } @@ -60,8 +62,8 @@ private async Task ImportFolderAsync() return; } - // N3: Ask Import Mode (Copy vs Keep in Place) - var copyToLibrary = await AskCopyToLibraryAsync(); + // Honor the saved preference; only prompt if ImportMode == "Ask". + var copyToLibrary = await ResolveCopyDecisionAsync(); var destDir = copyToLibrary ? NullWavePaths.DownloadsDir : null; if (copyToLibrary) Directory.CreateDirectory(destDir!); @@ -75,30 +77,16 @@ private async Task ImportFolderAsync() for (int i = 0; i < files.Count; i++) { var originalPath = files[i]; - var activePath = originalPath; - // N3: Execute Copy if requested - if (copyToLibrary) - { - var (rawT, rawA, _) = _metadata.FetchFromLocalFile(originalPath); - var ext = Path.GetExtension(originalPath); - var safeName = FileNamer.Generate(rawA, rawT, Guid.NewGuid().ToString(), ext); - var targetPath = Path.Combine(destDir!, safeName); - - if (File.Exists(targetPath)) - targetPath = Path.Combine(destDir!, $"{Path.GetFileNameWithoutExtension(safeName)}_{i}{ext}"); - - File.Copy(originalPath, targetPath, overwrite: false); - // Track ID isn't generated yet, so we log Guid.Empty for imports - FileOperationJournal.Instance.Record(FileOpType.Copy, originalPath, targetPath, Guid.Empty); - activePath = targetPath; - } + // Read metadata from the ORIGINAL file first, so we can duplicate-check + // before doing any filesystem work. + var (rawTitle, rawArtist, duration) = _metadata.FetchFromLocalFile(originalPath); + string title = rawTitle; + string artist = rawArtist; - var (title, artist, duration) = _metadata.FetchFromLocalFile(activePath); - if (string.IsNullOrWhiteSpace(artist) || artist == "Unknown") { - var split = NullWave.Helpers.TrackNormalizer.TrySplit(title); + var split = TrackNormalizer.TrySplit(rawTitle); if (split != null) { artist = split.Value.Artist; title = split.Value.Title; } } @@ -106,23 +94,38 @@ private async Task ImportFolderAsync() { Title = title, Artist = artist, - FilePath = activePath, + FilePath = originalPath, Source = TrackSource.Local, Duration = duration }; - var (album, trackNum) = _metadata.FetchAlbumAndTrackNumber(activePath); + var (album, trackNum) = _metadata.FetchAlbumAndTrackNumber(originalPath); track.Album = album; track.TrackNumber = trackNum; - if (!_library.IsDuplicate(track)) + if (_library.IsDuplicate(track)) { - _library.Add(track); - added++; - NullActionLogger.TrackAdded(track.FilePath, "LocalFolder", "ImportViewModel"); + // Skip BEFORE copying - otherwise re-imports pile up orphan copies. + skipped++; } else { - skipped++; + if (copyToLibrary) + { + var ext = Path.GetExtension(originalPath); + var safeName = FileNamer.Generate(artist, title, track.Id.ToString(), ext); + var targetPath = Path.Combine(destDir!, safeName); + + if (File.Exists(targetPath)) + targetPath = Path.Combine(destDir!, $"{Path.GetFileNameWithoutExtension(safeName)}_{i}{ext}"); + + File.Copy(originalPath, targetPath, overwrite: false); + FileOperationJournal.Instance.Record(FileOpType.Copy, originalPath, targetPath, track.Id); + track.FilePath = targetPath; + } + + _library.Add(track); + added++; + NullActionLogger.TrackAdded(track.FilePath, "LocalFolder", "ImportViewModel"); } ToastService.Instance.UpdateLiveActivity( @@ -131,7 +134,7 @@ private async Task ImportFolderAsync() progressValue: (i + 1) * 100.0 / files.Count, isIndeterminate: false); - await Task.Delay(1); + await Task.Delay(1); } stopwatch.Stop(); @@ -154,19 +157,28 @@ private async Task ImportFolderAsync() } } - private static async Task AskIncludeSubfoldersAsync() + /// + /// Decides whether to copy files based on the saved ImportMode pref. Only prompts + /// when the mode is "Ask"; "Copy" and "Keep" short-circuit the dialog. + /// + private async Task ResolveCopyDecisionAsync() { - var dialog = new Views.ConfirmDialog("Import Subfolders?", "Include all subfolders in the import?"); - return await DialogService.Instance.ShowDialogAsync(dialog); - } + var mode = _prefs.Current.ImportMode; + if (string.Equals(mode, "Copy", StringComparison.OrdinalIgnoreCase)) return true; + if (string.Equals(mode, "Keep", StringComparison.OrdinalIgnoreCase)) return false; - private static async Task AskCopyToLibraryAsync() - { + // "Ask" or unrecognized value: prompt the user. var dialog = new Views.ConfirmDialog( - "Import Mode", + "Import Mode", "Copy these files into your NullWave library folder?\n\n" + "Yes = Copy files (Safe, managed by NullWave)\n" + "No = Keep in place (Files stay where they are)"); return await DialogService.Instance.ShowDialogAsync(dialog); } + + private static async Task AskIncludeSubfoldersAsync() + { + var dialog = new Views.ConfirmDialog("Import Subfolders?", "Include all subfolders in the import?"); + return await DialogService.Instance.ShowDialogAsync(dialog); + } } \ No newline at end of file From 40943a3676e1cd62342cc4cf27260c1a98b08f20 Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Sat, 7 Nov 2026 12:08:42 +0400 Subject: [PATCH 33/52] ci: run on feature branches, enforce banned APIs (RS0030) --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76728ca..79eaa09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [ main, "release/**" ] + branches: [ main, "release/**", "feature/**" ] pull_request: branches: [ main ] workflow_dispatch: @@ -17,6 +17,7 @@ jobs: matrix: os: [windows-latest, ubuntu-latest] runs-on: ${{ matrix.os }} + timeout-minutes: 20 steps: - uses: actions/checkout@v4 @@ -30,15 +31,14 @@ jobs: run: dotnet restore - name: Build - run: dotnet build --no-restore -warnaserror:BannedApiAnalyzers + run: dotnet build --no-restore -warnaserror:RS0030 - name: Test - run: dotnet test --no-build --verbosity normal --logger trx --results-directory TestResults + run: dotnet test --no-build --verbosity normal --logger trx --results-directory TestResults --filter "Category!=NeedsVlc" - name: Upload test results uses: actions/upload-artifact@v4 if: always() with: name: test-results-${{ matrix.os }} - path: TestResults/**/*.trx - + path: TestResults/**/*.trx \ No newline at end of file From d0d34e2f542209878c865120515befd0b1f9944d Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Sat, 7 Nov 2026 12:08:43 +0400 Subject: [PATCH 34/52] fix(keystore): prevent Linux data loss, fallback on lock, reject newer formats, secure temp files --- Services/Security/KeyStoreService.cs | 77 +++++++++------- Services/Security/SecretServiceProtector.cs | 22 +++-- .../NullWave.Tests/KeyStoreMigrationTests.cs | 92 ++++++++++++++----- 3 files changed, 128 insertions(+), 63 deletions(-) diff --git a/Services/Security/KeyStoreService.cs b/Services/Security/KeyStoreService.cs index fc5732b..7fb1109 100644 --- a/Services/Security/KeyStoreService.cs +++ b/Services/Security/KeyStoreService.cs @@ -20,6 +20,7 @@ public class KeyStoreService private readonly object _lock = new(); private Dictionary? _cache; public bool WasRecovered { get; private set; } + public bool UsedFallback { get; private set; } // K2: Track if we fell back to legacy private bool _quarantineFailed; private string? _readOnlyReason; @@ -29,7 +30,6 @@ public KeyStoreService(string storePath) Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!); MigrateLegacyIfNeeded(); - // CRITICAL FIX: Derive the legacy key so we can read v1 files _encryptionKey = DeriveLegacyKey(); if (OperatingSystem.IsWindows()) @@ -60,10 +60,6 @@ internal KeyStoreService(string storePath, byte[] encryptionKey) Initialize(); } - /// - /// Internal ctor for migration tests: exercises the real Initialize() path with a - /// separate legacy key and an arbitrary active protector, mirroring the public ctor. - /// internal KeyStoreService(string storePath, byte[] legacyKey, ISecretProtector active) { _storePath = storePath; @@ -79,10 +75,6 @@ private void Initialize() var migratedNow = MigrateV1ToV2IfNeeded(); lock (_lock) { LoadLocked(); } - // Delete the v1 backup only after a clean start where: - // - we didn't just migrate (it was created this run), and - // - we didn't quarantine a corrupt file, and - // - we aren't in read-only mode (the original is still the authoritative copy). if (!migratedNow && !WasRecovered && _readOnlyReason == null) { var bak = _storePath + ".v1.bak"; @@ -121,14 +113,12 @@ private bool MigrateV1ToV2IfNeeded() { var blob = File.ReadAllBytes(_storePath); - // Check if already v2 format if (blob.Length >= 6 && blob[0] == 'N' && blob[1] == 'W' && blob[2] == 'K' && blob[3] == 'S') { Log.Debug("[KeyStoreService] Keystore already v2 format"); return false; } - // v1 format: try to decrypt with legacy key Log.Information("[KeyStoreService] Detected v1 keystore, attempting migration to v2"); var json = Encoding.UTF8.GetString(DecryptLegacy(blob)); var keys = JsonSerializer.Deserialize>(json); @@ -138,12 +128,10 @@ private bool MigrateV1ToV2IfNeeded() return false; } - // Re-save as v2 with active protector if (_activeProtector != null) { var newBlob = EncryptWithActiveProtector(json); - // SAFETY: Verify round-trip BEFORE touching the original file try { var payload = new byte[newBlob.Length - 6]; @@ -159,9 +147,7 @@ private bool MigrateV1ToV2IfNeeded() } var tmp = _storePath + ".tmp"; - File.WriteAllBytes(tmp, newBlob); - if (!OperatingSystem.IsWindows()) - File.SetUnixFileMode(tmp, UnixFileMode.UserRead | UnixFileMode.UserWrite); + WriteSecure(tmp, newBlob); // K4 FIX var backup = _storePath + ".v1.bak"; File.Copy(_storePath, backup, overwrite: true); @@ -188,7 +174,7 @@ private byte[] EncryptWithActiveProtector(string plaintext) var payload = _activeProtector.Protect(plaintextBytes); var blob = new byte[6 + payload.Length]; blob[0] = (byte)'N'; blob[1] = (byte)'W'; blob[2] = (byte)'K'; blob[3] = (byte)'S'; - blob[4] = 2; // version + blob[4] = 2; blob[5] = _activeProtector.Id; Array.Copy(payload, 0, blob, 6, payload.Length); return blob; @@ -227,17 +213,22 @@ private Dictionary LoadLocked() var blob = File.ReadAllBytes(_storePath); string json; - // Try v2 format first (with protector) if (blob.Length >= 6 && blob[0] == 'N' && blob[1] == 'W' && blob[2] == 'K' && blob[3] == 'S') { + // K3 FIX: Reject newer format versions explicitly + if (blob[4] != 2) + { + _readOnlyReason = $"Keystore format v{blob[4]} was written by a newer NullWave. Left untouched."; + Log.Error("[KeyStoreService] {Reason}", _readOnlyReason); + return _cache = new Dictionary(); + } + var protectorId = blob[5]; var protector = protectorId == _activeProtector?.Id ? _activeProtector : protectorId == _legacyProtector?.Id ? _legacyProtector : null; if (protector == null) { - // SAFETY: Unknown protector (e.g. file moved from Windows DPAPI to Linux). - // Do NOT quarantine. Do NOT allow overwrites. Leave the file byte-for-byte alone. _readOnlyReason = $"Keystore was written by protector {protectorId}, which isn't available here. Left untouched."; Log.Error("[KeyStoreService] {Reason}", _readOnlyReason); return _cache = new Dictionary(); @@ -250,7 +241,6 @@ private Dictionary LoadLocked() } else { - // v1 format (legacy encryption) json = Encoding.UTF8.GetString(DecryptLegacy(blob)); } @@ -261,7 +251,7 @@ private Dictionary LoadLocked() Quarantine(ex); return _cache = new Dictionary(); } - catch (Exception ex) // I/O or other temporary trouble: leave the file where it is + catch (Exception ex) { _readOnlyReason = $"Keystore could not be read right now ({ex.GetType().Name}). Left untouched."; Log.Error(ex, "[KeyStoreService] {Reason}", _readOnlyReason); @@ -319,7 +309,6 @@ public void DeleteAllKeys() Log.Warning("All API keys have been deleted"); } - // Clean up backups, quarantined files, and temp files to ensure no recoverable copies remain var dir = Path.GetDirectoryName(_storePath); if (dir != null && Directory.Exists(dir)) { @@ -335,7 +324,8 @@ public void DeleteAllKeys() } } _cache = new Dictionary(); - _readOnlyReason = null; // Allow normal writes again after explicit wipe + _readOnlyReason = null; + UsedFallback = false; } } @@ -351,30 +341,51 @@ private void Persist(Dictionary keys) if (_activeProtector != null) { - // v2 format: NWKS | version(1) | protectorId(1) | payload - var plaintext = Encoding.UTF8.GetBytes(json); - var payload = _activeProtector.Protect(plaintext); + byte[] payload; + byte id; + + // K2 FIX: Fall back to legacy protector if the active one throws (e.g., keyring locked) + try + { + payload = _activeProtector.Protect(Encoding.UTF8.GetBytes(json)); + id = _activeProtector.Id; + } + catch (Exception ex) + { + Log.Warning(ex, "[KeyStoreService] {P} failed; using legacy protection", _activeProtector.Name); + payload = _legacyProtector!.Protect(Encoding.UTF8.GetBytes(json)); + id = _legacyProtector.Id; + UsedFallback = true; + } + blob = new byte[6 + payload.Length]; blob[0] = (byte)'N'; blob[1] = (byte)'W'; blob[2] = (byte)'K'; blob[3] = (byte)'S'; - blob[4] = 2; // version - blob[5] = _activeProtector.Id; + blob[4] = 2; + blob[5] = id; Array.Copy(payload, 0, blob, 6, payload.Length); } else { - // Legacy format blob = EncryptLegacy(json); } var tmp = _storePath + ".tmp"; - File.WriteAllBytes(tmp, blob); - if (!OperatingSystem.IsWindows()) - File.SetUnixFileMode(tmp, UnixFileMode.UserRead | UnixFileMode.UserWrite); + WriteSecure(tmp, blob); // K4 FIX ReplaceStoreFile(tmp); _cache = new Dictionary(keys); } + // K4 FIX: Secure temp file creation to prevent world-readable window + private static void WriteSecure(string path, byte[] data) + { + try { File.Delete(path); } catch { } + var o = new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write, Share = FileShare.None }; + if (!OperatingSystem.IsWindows()) o.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + using var fs = new FileStream(path, o); + fs.Write(data); + } + private void ReplaceStoreFile(string temporaryPath) { if (OperatingSystem.IsWindows() && File.Exists(_storePath)) diff --git a/Services/Security/SecretServiceProtector.cs b/Services/Security/SecretServiceProtector.cs index 7a4acc8..445d9db 100644 --- a/Services/Security/SecretServiceProtector.cs +++ b/Services/Security/SecretServiceProtector.cs @@ -21,7 +21,6 @@ internal sealed class SecretServiceProtector : ISecretProtector public byte Id => 3; public string Name => "Linux Secret Service (GNOME Keyring / KWallet)"; - // Cache availability to avoid spawning processes on every check private static bool? _isAvailable; public bool IsAvailable @@ -33,7 +32,6 @@ public bool IsAvailable try { - // Use ProcessRunner for safe, timeout-bound execution var result = ProcessRunner.RunAsync("secret-tool", new[] { "--version" }, TimeSpan.FromSeconds(2)).GetAwaiter().GetResult(); _isAvailable = result.ExitCode == 0 && !result.TimedOut; } @@ -52,7 +50,8 @@ public byte[] Protect(byte[] plain) public byte[] Unprotect(byte[] payload) { - var masterKey = GetOrCreateMasterKey(); + // K1 FIX: Never create a key while decrypting. If the key is missing, throw. + var masterKey = LookupMasterKey() ?? throw new InvalidOperationException("Master key missing from Secret Service"); var aesProtector = new AesGcmProtector(Id, Name, masterKey); return aesProtector.Unprotect(payload); } @@ -67,16 +66,23 @@ private byte[] GetOrCreateMasterKey() return newKey; } + // K1 FIX: Separate "not found" (null) from "error" (throws). private byte[]? LookupMasterKey() { var args = new[] { "lookup", AttrApp, SchemaName, AttrPurpose, "keystore-master" }; var result = ProcessRunner.RunAsync("secret-tool", args, TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); - if (result.ExitCode != 0 || result.TimedOut || string.IsNullOrWhiteSpace(result.StandardOutput)) + if (result.TimedOut) + throw new InvalidOperationException("Secret Service timed out (keyring locked?)"); + + if (result.ExitCode == 0 && !string.IsNullOrWhiteSpace(result.StandardOutput)) + return Convert.FromBase64String(result.StandardOutput.Trim()); + + // Exit code 1 with empty stderr means "not found". + if (result.ExitCode == 1 && string.IsNullOrWhiteSpace(result.StandardError)) return null; - - try { return Convert.FromBase64String(result.StandardOutput.Trim()); } - catch { return null; } + + throw new InvalidOperationException($"Secret Service lookup failed: {result.StandardError}"); } private void StoreMasterKey(byte[] key) @@ -84,8 +90,6 @@ private void StoreMasterKey(byte[] key) var base64 = Convert.ToBase64String(key); var args = new[] { "store", "--label", Label, AttrApp, SchemaName, AttrPurpose, "keystore-master" }; - // Note: ProcessRunner doesn't currently support stdin injection natively in the snippet provided. - // For secret-tool, we must echo the base64 string into it. var psi = new ProcessStartInfo("secret-tool") { RedirectStandardInput = true, diff --git a/tests/NullWave.Tests/KeyStoreMigrationTests.cs b/tests/NullWave.Tests/KeyStoreMigrationTests.cs index 0b8cf5f..acbed7b 100644 --- a/tests/NullWave.Tests/KeyStoreMigrationTests.cs +++ b/tests/NullWave.Tests/KeyStoreMigrationTests.cs @@ -41,17 +41,9 @@ private static byte[] MakeV2Blob(byte protectorId, string json, byte[] key) return blob; } - /// - /// Opens a KeyStoreService with a real legacy key but an active protector that deliberately - /// fails the round-trip check. Mirrors the real migration path where the active protector's - /// Unprotect produces different bytes than it encrypted. - /// private KeyStoreService OpenBrokenMigration() => new(StorePath, _legacy, new BrokenProtector()); - private KeyStoreService OpenWithActive(ISecretProtector active) - => new(StorePath, _legacy, active); - private KeyStoreService Open() => new(StorePath, _legacy, new AesGcmProtector(2, "active", _active)); [Fact] @@ -61,7 +53,7 @@ public void V1_file_migrates_to_v2_and_keys_survive() Assert.Equal("abc", Open().GetKey("YouTube")); var b = File.ReadAllBytes(StorePath); Assert.Equal("NWKS", Encoding.ASCII.GetString(b, 0, 4)); - Assert.Equal(2, b[4]); // Version + Assert.Equal(2, b[4]); Assert.True(File.Exists(StorePath + ".v1.bak")); } @@ -69,10 +61,10 @@ public void V1_file_migrates_to_v2_and_keys_survive() public void Backup_is_removed_on_the_next_clean_start() { File.WriteAllBytes(StorePath, V1Blob(Json, _legacy)); - Open(); // migrates, creates .v1.bak + Open(); Assert.True(File.Exists(StorePath + ".v1.bak")); - var second = Open(); // clean start: should delete .v1.bak + var second = Open(); Assert.False(File.Exists(StorePath + ".v1.bak")); Assert.Equal("abc", second.GetKey("YouTube")); } @@ -87,25 +79,22 @@ public void Failed_round_trip_leaves_the_v1_file_alone() Assert.Equal(v1, File.ReadAllBytes(StorePath)); Assert.False(File.Exists(StorePath + ".v1.bak")); - Assert.Equal("abc", ks.GetKey("YouTube")); // still readable as v1 + Assert.Equal("abc", ks.GetKey("YouTube")); } [Fact] public void Unknown_protector_is_left_untouched_and_blocks_writes() { - // Write a v2 file with protector ID 99 (which our test instance doesn't know about) File.WriteAllBytes(StorePath, MakeV2Blob(99, Json, _active)); var original = File.ReadAllBytes(StorePath); var ks = Open(); Assert.Null(ks.GetKey("YouTube")); - Assert.False(ks.WasRecovered); // MUST NOT quarantine! + Assert.False(ks.WasRecovered); - // File must be byte-for-byte identical Assert.Equal(original, File.ReadAllBytes(StorePath)); Assert.Empty(Directory.GetFiles(_dir, "keys.enc.bad-*")); - // The critical part: writes must throw, not silently overwrite the file Assert.Throws(() => ks.SaveKey("A", "1")); Assert.Equal(original, File.ReadAllBytes(StorePath)); } @@ -120,15 +109,67 @@ public void DeleteAllKeys_removes_backups_and_quarantine_files_and_unblocks_writ ks.DeleteAllKeys(); Assert.Empty(Directory.GetFiles(_dir, "keys.enc*")); - // After explicit wipe, normal writes should work again ks.SaveKey("N", "1"); Assert.Equal("1", ks.GetKey("N")); } - /// - /// Protector that encrypts OK but returns different bytes on Unprotect, - /// forcing the round-trip verification in MigrateV1ToV2IfNeeded to fail. - /// + // === NEW TESTS FOR K1-K4 FIXES === + + [Fact] + public void Newer_format_version_is_left_untouched() + { + var b = MakeV2Blob(2, Json, _active); + b[4] = 9; // Fake version 9 + File.WriteAllBytes(StorePath, b); + + var ks = Open(); + Assert.Null(ks.GetKey("YouTube")); + Assert.False(ks.WasRecovered); + Assert.Throws(() => ks.SaveKey("A", "1")); + Assert.Equal(b, File.ReadAllBytes(StorePath)); + } + + [Fact] + public void Throwing_protector_falls_back_and_keys_survive() + { + new KeyStoreService(StorePath, _legacy, new ThrowingProtector()).SaveKey("A", "1"); + Assert.Equal("1", new KeyStoreService(StorePath, _legacy, new ThrowingProtector()).GetKey("A")); + } + + [Fact] + public void Empty_v1_store_creates_no_backup() + { + File.WriteAllBytes(StorePath, V1Blob("{}", _legacy)); + Open(); + Assert.False(File.Exists(StorePath + ".v1.bak")); + } + + [Fact] + public void Stale_tmp_file_is_ignored_and_replaced() + { + File.WriteAllBytes(StorePath, V1Blob(Json, _legacy)); + File.WriteAllText(StorePath + ".tmp", "junk"); + Assert.Equal("abc", Open().GetKey("YouTube")); + Assert.False(File.Exists(StorePath + ".tmp")); + } + + [Fact] + public void Keystore_file_mode_is_0600_on_Linux() + { + if (!OperatingSystem.IsLinux()) return; + Open().SaveKey("A", "1"); + Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(StorePath)); + } + + [Fact] + public void Dpapi_round_trips_on_Windows() + { + if (!OperatingSystem.IsWindows()) return; + var p = new DpapiProtector(); + var d = new byte[] { 1, 2, 3 }; + Assert.Equal(d, p.Unprotect(p.Protect(d))); + } + private sealed class BrokenProtector : ISecretProtector { public byte Id => 2; @@ -137,4 +178,13 @@ private sealed class BrokenProtector : ISecretProtector public byte[] Protect(byte[] plain) => plain; public byte[] Unprotect(byte[] payload) => new byte[] { 1, 2, 3 }; } + + private sealed class ThrowingProtector : ISecretProtector + { + public byte Id => 2; + public string Name => "throws"; + public bool IsAvailable => true; + public byte[] Protect(byte[] p) => throw new IOException("keyring locked"); + public byte[] Unprotect(byte[] p) => throw new IOException("keyring locked"); + } } \ No newline at end of file From 4be9e2ab3c8f6d99792035958ab4e878b522be41 Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Sat, 7 Nov 2026 13:18:41 +0400 Subject: [PATCH 35/52] test(plugins): make parallel and blocking init tests deterministic --- .../PluginManagerInitializationTests.cs | 54 +++++++++++-------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/tests/NullWave.Tests/PluginManagerInitializationTests.cs b/tests/NullWave.Tests/PluginManagerInitializationTests.cs index be037a4..f0f4538 100644 --- a/tests/NullWave.Tests/PluginManagerInitializationTests.cs +++ b/tests/NullWave.Tests/PluginManagerInitializationTests.cs @@ -1,6 +1,10 @@ +using System; using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; using NullWave.Services; using NullWave.Services.Plugins; +using Xunit; namespace NullWave.Tests.Plugins; @@ -42,24 +46,32 @@ private static PluginManager NewManager(params FakePlugin[] plugins) [Fact] public async Task Plugins_initialize_at_the_same_time_not_one_after_another() { - async Task Slow(CancellationToken _) { await Task.Delay(400); return true; } - var a = new FakePlugin("a", Slow); - var b = new FakePlugin("b", Slow); - var c = new FakePlugin("c", Slow); + var started = 0; + var allStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Finishes only once all three plugins have begun initializing. + // If the manager ran them sequentially, this would time out and the plugin would end up in Error. + async Task WaitForTheOthers(CancellationToken _) + { + if (Interlocked.Increment(ref started) == 3) allStarted.SetResult(); + await allStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + return true; + } + + var a = new FakePlugin("a", WaitForTheOthers); + var b = new FakePlugin("b", WaitForTheOthers); + var c = new FakePlugin("c", WaitForTheOthers); - var clock = Stopwatch.StartNew(); await NewManager(a, b, c).InitializeAllAsync(perPluginTimeout: TimeSpan.FromSeconds(10)); - clock.Stop(); Assert.All(new[] { a, b, c }, p => Assert.Equal(PluginState.Available, p.State)); - Assert.True(clock.ElapsedMilliseconds < 1000, - $"Took {clock.ElapsedMilliseconds} ms; three 400 ms plugins one after another would take 1200 ms or more"); + Assert.Equal(3, started); } [Fact] public async Task A_plugin_that_ignores_cancellation_is_marked_Error_and_does_not_hold_up_the_rest() { - var stuck = new FakePlugin("stuck", _ => new TaskCompletionSource().Task); // never completes + var stuck = new FakePlugin("stuck", _ => new TaskCompletionSource().Task); var healthy = new FakePlugin("healthy", _ => Task.FromResult(true)); var clock = Stopwatch.StartNew(); @@ -81,7 +93,6 @@ public async Task A_plugin_that_honours_the_cancellation_token_is_marked_Error_w }); await NewManager(cooperative).InitializeAllAsync(perPluginTimeout: TimeSpan.FromMilliseconds(300)); - Assert.Equal(PluginState.Error, cooperative.State); } @@ -101,9 +112,7 @@ public async Task A_throwing_plugin_is_marked_Error_and_the_others_still_initial public async Task A_plugin_that_reports_false_is_not_Available() { var notConfigured = new FakePlugin("no-key", _ => Task.FromResult(false)); - await NewManager(notConfigured).InitializeAllAsync(perPluginTimeout: TimeSpan.FromSeconds(5)); - Assert.NotEqual(PluginState.Available, notConfigured.State); } @@ -111,7 +120,6 @@ public async Task A_plugin_that_reports_false_is_not_Available() public async Task Disabled_plugins_are_not_initialized() { var disabled = new FakePlugin("off", _ => Task.FromResult(true), enabled: false); - await NewManager(disabled).InitializeAllAsync(perPluginTimeout: TimeSpan.FromSeconds(5)); Assert.Equal(0, disabled.InitCalls); @@ -134,26 +142,28 @@ public async Task Cancelling_startup_stops_waiting_and_does_not_throw() [Fact] public async Task A_plugin_whose_start_code_blocks_does_not_block_the_caller() { + using var release = new ManualResetEventSlim(false); var blocking = new FakePlugin("blocking", _ => { - Thread.Sleep(500); // synchronous work before returning, like a Process.Start + WaitForExit + // Synchronous block, like a Process.Start + WaitForExit + release.Wait(TimeSpan.FromSeconds(5)); return Task.FromResult(true); }); - var clock = Stopwatch.StartNew(); - var start = NewManager(blocking).InitializeAllAsync(perPluginTimeout: TimeSpan.FromSeconds(5)); - var callReturnedAfterMs = clock.ElapsedMilliseconds; // time until the call itself came back - await start; + var start = NewManager(blocking).InitializeAllAsync(perPluginTimeout: TimeSpan.FromSeconds(10)); - Assert.True(callReturnedAfterMs < 250, - $"The call took {callReturnedAfterMs} ms to return; the plugin's blocking work must run on a pool thread"); + // The call must hand back an unfinished task while the plugin is still blocked. + // If the plugin ran inline on the caller's thread, the call would only return after the 5s wait. + Assert.False(start.IsCompleted, "The plugin's blocking work ran on the caller's thread"); + + release.Set(); + await start; Assert.Equal(PluginState.Available, blocking.State); } } public class YtDlpDownloadProviderInitializationTests { - // InitializeAsync never touches the inner DownloadService, so tests pass null for it. private static YtDlpDownloadProvider NewProvider(Func> probe, bool enabled = true) { var prefs = new PreferencesService(); @@ -165,7 +175,6 @@ private static YtDlpDownloadProvider NewProvider(Func> probe, bool public async Task Found_version_makes_the_provider_Available() { var provider = NewProvider(() => Task.FromResult("2026.08.19")); - Assert.True(await provider.InitializeAsync()); Assert.Equal(PluginState.Available, provider.State); } @@ -174,7 +183,6 @@ public async Task Found_version_makes_the_provider_Available() public async Task Missing_yt_dlp_makes_the_provider_Unavailable() { var provider = NewProvider(() => Task.FromResult(null)); - Assert.False(await provider.InitializeAsync()); Assert.Equal(PluginState.Unavailable, provider.State); } From 8389e68d2e9f7c231acb7b367910c448e1bec28a Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Sat, 7 Nov 2026 15:58:11 +0400 Subject: [PATCH 36/52] fix(safety): confirm and move orphaned files --- CHANGELOG.md | 5 + Helpers/Localization/Locales.cs | 18 ++- Helpers/Localization/Locales.ru.cs | 18 ++- NullWave.csproj | 2 +- README.md | 2 +- .../Library/LibraryService.Maintenance.cs | 43 ++++-- Services/Library/OrphanSweepCoordinator.cs | 100 ++++++++++++++ .../Settings/SettingsViewModel.Maintenance.cs | 34 +++-- ViewModels/Shell/MainViewModel.Maintenance.cs | 27 +++- tests/NullWave.Tests/OrphanSweepTests.cs | 123 ++++++++++++++++++ 10 files changed, 335 insertions(+), 37 deletions(-) create mode 100644 Services/Library/OrphanSweepCoordinator.cs create mode 100644 tests/NullWave.Tests/OrphanSweepTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d9e0519..fd1f7e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to NullWave will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.6.5-beta.2] - Unreleased + +### Fixed +- **Orphan sweep safety**: previews and confirms before moving orphaned files to `_orphans`; files are never deleted. + ## [0.6.5-beta.1] - 31-Oct-2026 ### Added diff --git a/Helpers/Localization/Locales.cs b/Helpers/Localization/Locales.cs index 90244c3..ba4f1b2 100644 --- a/Helpers/Localization/Locales.cs +++ b/Helpers/Localization/Locales.cs @@ -257,11 +257,21 @@ public static Dictionary GetDictionary(string code) ["Settings_Maintenance_Reimport_Desc"] = "Scan download folder and re-link audio files to existing tracks", ["Settings_Maintenance_Reimport_Button"] = "Reimport Assets", ["Settings_Maintenance_Section_Cleanup"] = "DATA CLEANUP", - ["Settings_Maintenance_Sweep_Title"] = "Sweep orphaned files", - ["Settings_Maintenance_Sweep_Tooltip"] = "Deletes audio files in your downloads folder that no library track points to.", - ["Settings_Maintenance_Sweep_Desc"] = "Delete .mp3/.m4a files with no matching library entry", + ["Settings_Maintenance_Sweep_Title"] = "Move orphaned files", + ["Settings_Maintenance_Sweep_Tooltip"] = "Preview and move audio files with no library link to the _orphans folder. Files are not deleted.", + ["Settings_Maintenance_Sweep_Desc"] = "Move .mp3/.m4a files with no matching library entry to _orphans", ["Settings_Maintenance_Preview"] = "Preview", - ["Settings_Maintenance_Sweep_Button"] = "Sweep", + ["Settings_Maintenance_Sweep_Button"] = "Move files", + ["Settings_Maintenance_Sweep_Scanning"] = "Scanning for orphaned files...", + ["Settings_Maintenance_Sweep_Moving"] = "Preparing to move orphaned files...", + ["Settings_Maintenance_Sweep_Confirm_Title"] = "Move orphaned files?", + ["Settings_Maintenance_Sweep_Confirm"] = "Found {0} orphaned file(s) out of {1} scanned. If you continue, only these files will move to _orphans; no files will be deleted.", + ["Settings_Maintenance_Sweep_Guard"] = "Nothing was moved because the library has no linked files or too many files appear unlinked. Check that the library database and download folder are correct, then preview again.", + ["Settings_Maintenance_Sweep_Cancelled"] = "No files were moved.", + ["Settings_Maintenance_Sweep_Preview"] = "Found {0} orphaned file(s) out of {1} scanned. Choose Sweep to review and move them to _orphans.", + ["Settings_Maintenance_Sweep_Complete"] = "Moved {0} orphaned file(s) to _orphans.", + ["Settings_Maintenance_Sweep_Failed"] = "Moved {0} file(s); {1} could not be moved. Check the logs.", + ["Settings_Maintenance_Sweep_Fallback"] = "The configured download folder is missing or not writable. Using the default downloads folder instead.", ["Settings_Maintenance_Dedupe_Title"] = "Remove duplicate tracks", ["Settings_Maintenance_Dedupe_Tooltip"] = "Finds tracks with matching title and artist, keeps the copy with the highest play count and best metadata.", ["Settings_Maintenance_Dedupe_Desc"] = "Find tracks with matching title and artist, keep the best copy", diff --git a/Helpers/Localization/Locales.ru.cs b/Helpers/Localization/Locales.ru.cs index 5c0d1ef..b0865a9 100644 --- a/Helpers/Localization/Locales.ru.cs +++ b/Helpers/Localization/Locales.ru.cs @@ -236,11 +236,21 @@ public static partial class Locales ["Settings_Maintenance_Reimport_Desc"] = "Сканировать папку загрузок и повторно привязать файлы к существующим трекам", ["Settings_Maintenance_Reimport_Button"] = "Переимпортировать", ["Settings_Maintenance_Section_Cleanup"] = "ОЧИСТКА ДАННЫХ", - ["Settings_Maintenance_Sweep_Title"] = "Удалить осиротевшие файлы", - ["Settings_Maintenance_Sweep_Tooltip"] = "Удаляет аудиофайлы в папке загрузок, на которые не ссылается ни один трек библиотеки.", - ["Settings_Maintenance_Sweep_Desc"] = "Удалить файлы .mp3/.m4a, не привязанные к библиотеке", + ["Settings_Maintenance_Sweep_Title"] = "Переместить осиротевшие файлы", + ["Settings_Maintenance_Sweep_Tooltip"] = "Просмотрите и переместите аудиофайлы без ссылки из библиотеки в папку _orphans. Файлы не удаляются.", + ["Settings_Maintenance_Sweep_Desc"] = "Переместить файлы .mp3/.m4a без записи в библиотеке в папку _orphans", ["Settings_Maintenance_Preview"] = "Предпросмотр", - ["Settings_Maintenance_Sweep_Button"] = "Очистить", + ["Settings_Maintenance_Sweep_Button"] = "Переместить файлы", + ["Settings_Maintenance_Sweep_Scanning"] = "Поиск осиротевших файлов...", + ["Settings_Maintenance_Sweep_Moving"] = "Подготовка к перемещению осиротевших файлов...", + ["Settings_Maintenance_Sweep_Confirm_Title"] = "Переместить осиротевшие файлы?", + ["Settings_Maintenance_Sweep_Confirm"] = "Найдено осиротевших файлов: {0} из {1}. При продолжении будут перемещены только эти файлы в папку _orphans; файлы не удаляются.", + ["Settings_Maintenance_Sweep_Guard"] = "Файлы не перемещены: в библиотеке нет связанных файлов или слишком много файлов не имеют ссылок. Проверьте базу данных библиотеки и папку загрузок, затем запустите предпросмотр снова.", + ["Settings_Maintenance_Sweep_Cancelled"] = "Файлы не перемещены.", + ["Settings_Maintenance_Sweep_Preview"] = "Найдено осиротевших файлов: {0} из {1}. Нажмите «Очистить», чтобы просмотреть список и переместить их в _orphans.", + ["Settings_Maintenance_Sweep_Complete"] = "Перемещено осиротевших файлов в _orphans: {0}.", + ["Settings_Maintenance_Sweep_Failed"] = "Перемещено файлов: {0}; не удалось переместить: {1}. Проверьте журналы.", + ["Settings_Maintenance_Sweep_Fallback"] = "Настроенная папка загрузок отсутствует или недоступна для записи. Используется стандартная папка загрузок.", ["Settings_Maintenance_Dedupe_Title"] = "Удалить дубликаты треков", ["Settings_Maintenance_Dedupe_Tooltip"] = "Находит треки с совпадающими названием и исполнителем, сохраняет копию с наибольшим количеством прослушиваний и лучшими метаданными.", ["Settings_Maintenance_Dedupe_Desc"] = "Найти треки с одинаковым названием и исполнителем, оставив лучшую версию", diff --git a/NullWave.csproj b/NullWave.csproj index 9488020..6113f3e 100644 --- a/NullWave.csproj +++ b/NullWave.csproj @@ -9,7 +9,7 @@ en;ru - 0.6.5-beta.1 + 0.6.5-beta.2 ZenQuant NullWave diff --git a/README.md b/README.md index b505b22..a332a93 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A personal music organizer with download, playback, and AI-powered smart sorting ## Version -v0.6.5-beta.1 "Oxeye Daisy" +v0.6.5-beta.2 "Oxeye Daisy" ## About diff --git a/Services/Library/LibraryService.Maintenance.cs b/Services/Library/LibraryService.Maintenance.cs index b0a02d9..5f6f108 100644 --- a/Services/Library/LibraryService.Maintenance.cs +++ b/Services/Library/LibraryService.Maintenance.cs @@ -438,7 +438,7 @@ public int ClearTagsForReSync() return cleared; } - public (int Scanned, int Orphaned, int Deleted, int Failed) SweepOrphanedFiles(string downloadsDir, bool dryRun = true) + public (int Scanned, int Orphaned, int Moved, int Failed) SweepOrphanedFiles(string downloadsDir, bool dryRun = true) { if (!Directory.Exists(downloadsDir)) return (0, 0, 0, 0); @@ -465,7 +465,7 @@ public int ClearTagsForReSync() return (files.Count, -1, 0, 0); } - int orphaned = 0, deleted = 0, failed = 0; + int orphaned = 0, moved = 0, failed = 0; var orphansDir = Path.Combine(downloadsDir, "_orphans"); @@ -480,17 +480,40 @@ public int ClearTagsForReSync() try { Directory.CreateDirectory(orphansDir); - var dest = Path.Combine(orphansDir, Path.GetFileName(f)); - - if (File.Exists(dest)) dest = Path.Combine(orphansDir, $"{Path.GetFileNameWithoutExtension(f)}_{Guid.NewGuid():N}{Path.GetExtension(f)}"); - - File.Move(f, dest); - deleted++; + var name = Path.GetFileNameWithoutExtension(f); + var extension = Path.GetExtension(f); + for (var suffix = 0; ; suffix++) + { + var destName = suffix == 0 ? Path.GetFileName(f) : $"{name}_{suffix}{extension}"; + var dest = Path.Combine(orphansDir, destName); + if (File.Exists(dest) || Directory.Exists(dest)) + continue; + + try + { + File.Move(f, dest); + break; + } + catch (IOException) when (File.Exists(dest) || Directory.Exists(dest)) + { + // Another file occupied this name after the existence check. + } + } + moved++; + } + catch (IOException ex) + { + Log.Warning(ex, "[LibraryService] Failed to move orphaned file {Path}", f); + failed++; + } + catch (UnauthorizedAccessException ex) + { + Log.Warning(ex, "[LibraryService] Failed to move orphaned file {Path}", f); + failed++; } - catch { failed++; } } - return (files.Count, orphaned, deleted, failed); + return (files.Count, orphaned, moved, failed); } public (int Scanned, int DuplicateGroups, int Removed) RemoveDuplicates(bool dryRun = true) diff --git a/Services/Library/OrphanSweepCoordinator.cs b/Services/Library/OrphanSweepCoordinator.cs new file mode 100644 index 0000000..284f0f9 --- /dev/null +++ b/Services/Library/OrphanSweepCoordinator.cs @@ -0,0 +1,100 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Serilog; + +namespace NullWave.Services; + +public sealed record OrphanSweepResult( + int Scanned, + int Orphaned, + int Moved, + int Failed, + bool GuardRefused, + bool Cancelled, + bool UsedFallback); + +public static class OrphanSweepCoordinator +{ + public static async Task PreviewAsync( + LibraryService library, + string? configuredDirectory, + string defaultDirectory) + { + var (directory, usedFallback) = ResolveDirectory(configuredDirectory, defaultDirectory); + var (scanned, orphaned, _, failed) = await Task.Run( + () => library.SweepOrphanedFiles(directory, dryRun: true)); + + return new OrphanSweepResult(scanned, orphaned, 0, failed, orphaned == -1, false, usedFallback); + } + + public static async Task SweepAsync( + LibraryService library, + string? configuredDirectory, + string defaultDirectory, + Func> confirm) + { + var preview = await PreviewAsync(library, configuredDirectory, defaultDirectory); + if (preview.GuardRefused || preview.Orphaned == 0) + return preview; + + if (!await confirm(preview.Scanned, preview.Orphaned)) + return preview with { Cancelled = true }; + + var (directory, usedFallback) = ResolveDirectory(configuredDirectory, defaultDirectory); + var (scanned, orphaned, moved, failed) = await Task.Run( + () => library.SweepOrphanedFiles(directory, dryRun: false)); + + return new OrphanSweepResult( + scanned, orphaned, moved, failed, orphaned == -1, false, + usedFallback || preview.UsedFallback); + } + + private static (string Directory, bool UsedFallback) ResolveDirectory( + string? configuredDirectory, + string defaultDirectory) + { + if (string.IsNullOrWhiteSpace(configuredDirectory)) + return (EnsureWritable(defaultDirectory), false); + + if (IsWritable(configuredDirectory)) + return (configuredDirectory, false); + + Log.Warning( + "[OrphanSweep] Configured downloads directory is missing or not writable; falling back to {Directory}", + defaultDirectory); + return (EnsureWritable(defaultDirectory), true); + } + + private static string EnsureWritable(string directory) + { + Directory.CreateDirectory(directory); + if (!IsWritable(directory)) + throw new IOException($"The default downloads directory is not writable: {directory}"); + return directory; + } + + private static bool IsWritable(string directory) + { + if (!Directory.Exists(directory)) + return false; + + var probe = Path.Combine(directory, $".nullwave-write-probe-{Guid.NewGuid():N}"); + try + { + using var stream = new FileStream( + probe, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1, FileOptions.DeleteOnClose); + return true; + } + catch (IOException ex) + { + Log.Warning(ex, "[OrphanSweep] Cannot write to downloads directory {Directory}", directory); + return false; + } + catch (UnauthorizedAccessException ex) + { + Log.Warning(ex, "[OrphanSweep] Cannot write to downloads directory {Directory}", directory); + return false; + } + } +} diff --git a/ViewModels/Settings/SettingsViewModel.Maintenance.cs b/ViewModels/Settings/SettingsViewModel.Maintenance.cs index e2c1d28..ab148ea 100644 --- a/ViewModels/Settings/SettingsViewModel.Maintenance.cs +++ b/ViewModels/Settings/SettingsViewModel.Maintenance.cs @@ -42,8 +42,8 @@ public partial class SettingsViewModel [RelayCommand] private void RepairPaths() { IsRepairing = true; RepairStatus = "Scanning file paths..."; RepairPathsRequested?.Invoke(); } [RelayCommand] private void ReimportAssets() { IsRepairing = true; RepairStatus = "Scanning download folder..."; ReimportAssetsRequested?.Invoke(); } [RelayCommand] private void ForceMetaResync() { IsRepairing = true; RepairStatus = "Clearing cached tags - re-sync starting..."; ForceMetaResyncRequested?.Invoke(); } - [RelayCommand] private void PreviewOrphanedFiles() { IsRepairing = true; SweepStatus = "Scanning for orphaned files..."; SweepOrphanedFilesRequested?.Invoke(true); } - [RelayCommand] private void SweepOrphanedFiles() { IsRepairing = true; SweepStatus = "Deleting orphaned files..."; SweepOrphanedFilesRequested?.Invoke(false); } + [RelayCommand] private void PreviewOrphanedFiles() { IsRepairing = true; SweepStatus = LocalizationService.Instance["Settings_Maintenance_Sweep_Scanning"]; SweepOrphanedFilesRequested?.Invoke(true); } + [RelayCommand] private void SweepOrphanedFiles() { IsRepairing = true; SweepStatus = LocalizationService.Instance["Settings_Maintenance_Sweep_Moving"]; SweepOrphanedFilesRequested?.Invoke(false); } [RelayCommand] private void VacuumDatabase() { IsRepairing = true; VacuumStatus = "Optimizing database..."; VacuumDatabaseRequested?.Invoke(); } [RelayCommand] private void RebuildSearchIndex() { IsRepairing = true; RebuildSearchIndexRequested?.Invoke(); } [RelayCommand] private void VerifyLinks() { IsRepairing = true; VerifyLinksStatus = "Checking file links against embedded metadata..."; VerifyLinksRequested?.Invoke(); } @@ -66,23 +66,31 @@ public void ReportDedupeComplete(int scanned, int groups, int removed, bool wasD DedupeStatus = wasDryRun ? $"Found {groups} duplicate group(s) ({removed} extra track(s) would be removed) out of {scanned} scanned. Click Remove to clean up." : $"✓ Removed {removed} duplicate track(s) across {groups} group(s)."; ToastService.Instance.Show(DedupeStatus, ToastType.Success, scope: "maintenance"); } - public void ReportSweepComplete(int scanned, int orphaned, int deleted, int failed, bool wasDryRun) + public void ReportSweepComplete(int scanned, int orphaned, int moved, int failed, bool wasDryRun, bool guardRefused, bool usedFallback, bool cancelled) { IsRepairing = false; - // C8 FIX: Handle the guard flag from LibraryService - if (orphaned == -1) + var strings = LocalizationService.Instance; + if (guardRefused) { - SweepStatus = "⚠ Sweep aborted: Your library appears empty. Please check your database before deleting files."; + SweepStatus = strings["Settings_Maintenance_Sweep_Guard"]; ToastService.Instance.Show(SweepStatus, ToastType.Warning, scope: "maintenance"); return; } - // Note: Changed "Deleted" to "Moved" because C8 moves them to _orphans/ safely! - SweepStatus = wasDryRun - ? $"Found {orphaned} orphaned file(s) out of {scanned} scanned. Click Sweep to move to _orphans/." - : failed == 0 - ? $"✓ Moved {deleted} orphaned file(s) to _orphans/." - : $"Moved {deleted} file(s), {failed} failed (check logs)."; - ToastService.Instance.Show(SweepStatus, failed > 0 ? ToastType.Warning : ToastType.Success, scope: "maintenance"); + if (cancelled) + SweepStatus = strings["Settings_Maintenance_Sweep_Cancelled"]; + else if (wasDryRun) + SweepStatus = string.Format(strings["Settings_Maintenance_Sweep_Preview"], orphaned, scanned); + else if (failed == 0) + SweepStatus = string.Format(strings["Settings_Maintenance_Sweep_Complete"], moved); + else + SweepStatus = string.Format(strings["Settings_Maintenance_Sweep_Failed"], moved, failed); + + if (usedFallback) + SweepStatus = strings["Settings_Maintenance_Sweep_Fallback"] + " " + SweepStatus; + ToastService.Instance.Show( + SweepStatus, + usedFallback || failed > 0 ? ToastType.Warning : ToastType.Success, + scope: "maintenance"); } public void ReportVacuumComplete(long beforeKB, long afterKB) { diff --git a/ViewModels/Shell/MainViewModel.Maintenance.cs b/ViewModels/Shell/MainViewModel.Maintenance.cs index caaf5cd..9b01abb 100644 --- a/ViewModels/Shell/MainViewModel.Maintenance.cs +++ b/ViewModels/Shell/MainViewModel.Maintenance.cs @@ -19,10 +19,17 @@ private void WireMaintenanceEvents() { Settings.SweepOrphanedFilesRequested += dryRun => RunMaintenanceTask("SweepOrphanedFiles", dryRun, async () => { - var dir = _prefsService.Current.DownloadDirectory; - if (string.IsNullOrWhiteSpace(dir)) dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nullwave", "downloads"); - var (scanned, orphaned, deleted, failed) = await Task.Run(() => _library.SweepOrphanedFiles(dir, dryRun)); - return () => Settings.ReportSweepComplete(scanned, orphaned, deleted, failed, dryRun); + var configuredDirectory = _prefsService.Current.DownloadDirectory; + var result = dryRun + ? await OrphanSweepCoordinator.PreviewAsync(_library, configuredDirectory, NullWavePaths.DownloadsDir) + : await OrphanSweepCoordinator.SweepAsync( + _library, + configuredDirectory, + NullWavePaths.DownloadsDir, + ConfirmOrphanSweepAsync); + return () => Settings.ReportSweepComplete( + result.Scanned, result.Orphaned, result.Moved, result.Failed, dryRun, + result.GuardRefused, result.UsedFallback, result.Cancelled); }); Settings.VacuumDatabaseRequested += () => RunMaintenanceTask("VacuumDatabase", false, async () => @@ -197,4 +204,16 @@ await Dispatcher.UIThread.InvokeAsync(() => } }); } + + private async Task ConfirmOrphanSweepAsync(int scanned, int orphaned) + { + var strings = LocalizationService.Instance; + var message = string.Format(strings["Settings_Maintenance_Sweep_Confirm"], orphaned, scanned); + var dialog = new Views.ConfirmDialog( + strings["Settings_Maintenance_Sweep_Confirm_Title"], + message); + var dialogTask = await Dispatcher.UIThread.InvokeAsync( + () => DialogService.Instance.ShowDialogAsync(dialog)); + return dialogTask == true; + } } \ No newline at end of file diff --git a/tests/NullWave.Tests/OrphanSweepTests.cs b/tests/NullWave.Tests/OrphanSweepTests.cs new file mode 100644 index 0000000..63d6c9c --- /dev/null +++ b/tests/NullWave.Tests/OrphanSweepTests.cs @@ -0,0 +1,123 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using NullWave.Helpers; +using NullWave.Models; +using NullWave.Services; +using Xunit; + +namespace NullWave.Tests; + +[Collection("Database")] +public sealed class OrphanSweepTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "nw-orphan-sweep-" + Guid.NewGuid().ToString("N")); + private readonly DatabaseService _db; + private readonly LibraryService _library; + + public OrphanSweepTests() + { + Directory.CreateDirectory(_root); + _db = new DatabaseService(); + foreach (var track in _db.LoadAll()) + _db.Delete(track.Id); + _library = new LibraryService(_db, null, null, startBackgroundWork: false); + } + + public void Dispose() + { + _library.Dispose(); + foreach (var track in _db.LoadAll()) + _db.Delete(track.Id); + _db.Dispose(); + try { Directory.Delete(_root, recursive: true); } catch { } + } + + [Fact] + public async Task Guard_refuses_empty_library_without_moving_files_or_asking_confirmation() + { + var orphan = Path.Combine(_root, "orphan.mp3"); + File.WriteAllText(orphan, "orphan"); + var askedForConfirmation = false; + + var result = await OrphanSweepCoordinator.SweepAsync( + _library, _root, _root, (_, _) => + { + askedForConfirmation = true; + return Task.FromResult(true); + }); + + Assert.True(result.GuardRefused); + Assert.Equal(0, result.Moved); + Assert.False(askedForConfirmation); + Assert.True(File.Exists(orphan)); + } + + [Fact] + public async Task Confirmed_sweep_moves_only_orphans_and_preserves_collision_destination() + { + var known = Path.Combine(_root, "known.mp3"); + var orphan = Path.Combine(_root, "orphan.mp3"); + var orphanDirectory = Path.Combine(_root, "_orphans"); + var collision = Path.Combine(orphanDirectory, "orphan.mp3"); + Directory.CreateDirectory(orphanDirectory); + File.WriteAllText(known, "known"); + File.WriteAllText(orphan, "orphan"); + File.WriteAllText(collision, "existing"); + _library.Add(new Track { Title = "Known", Artist = "Artist", FilePath = known, Source = TrackSource.Local }); + + var result = await OrphanSweepCoordinator.SweepAsync( + _library, _root, _root, (_, _) => Task.FromResult(true)); + + Assert.False(result.GuardRefused); + Assert.False(result.Cancelled); + Assert.Equal(1, result.Moved); + Assert.True(File.Exists(known)); + Assert.False(File.Exists(orphan)); + Assert.Equal("existing", File.ReadAllText(collision)); + Assert.Equal(2, Directory.GetFiles(orphanDirectory).Length); + var movedFile = Assert.Single(Array.FindAll(Directory.GetFiles(orphanDirectory), path => path != collision)); + Assert.Equal("orphan", File.ReadAllText(movedFile)); + Assert.Equal("orphan_1.mp3", Path.GetFileName(movedFile)); + } + + [Fact] + public void Dry_run_changes_nothing_and_known_files_are_not_swept() + { + var known = Path.Combine(_root, "known.mp3"); + var orphan = Path.Combine(_root, "orphan.mp3"); + File.WriteAllText(known, "known"); + File.WriteAllText(orphan, "orphan"); + _library.Add(new Track { Title = "Known", Artist = "Artist", FilePath = known, Source = TrackSource.Local }); + + var result = _library.SweepOrphanedFiles(_root, dryRun: true); + + Assert.Equal(2, result.Scanned); + Assert.Equal(1, result.Orphaned); + Assert.Equal(0, result.Moved); + Assert.True(File.Exists(known)); + Assert.True(File.Exists(orphan)); + Assert.False(Directory.Exists(Path.Combine(_root, "_orphans"))); + } + + [Fact] + public async Task Missing_configured_directory_uses_default_and_reports_fallback() + { + var configured = Path.Combine(_root, "missing"); + var fallback = Path.Combine(_root, "default"); + Directory.CreateDirectory(fallback); + var known = Path.Combine(fallback, "known.mp3"); + File.WriteAllText(known, "known"); + File.WriteAllText(Path.Combine(fallback, "orphan.mp3"), "orphan"); + _library.Add(new Track { Title = "Known", Artist = "Artist", FilePath = known, Source = TrackSource.Local }); + var result = await OrphanSweepCoordinator.SweepAsync( + _library, + configured, + fallback, + (_, _) => Task.FromResult(false)); + + Assert.True(result.UsedFallback); + Assert.True(Directory.Exists(fallback)); + Assert.True(result.Cancelled); + } +} From 52c752a2f1e1110ea977547fb50a479b1af423af Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Sat, 7 Nov 2026 16:01:01 +0400 Subject: [PATCH 37/52] feat(consent): gate package manager installs --- Helpers/Localization/Locales.cs | 5 ++ Helpers/Localization/Locales.ru.cs | 5 ++ Models/Settings/Preferences.cs | 1 + Services/System/DependencyUpdateService.cs | 66 +++++++++++++++++-- .../Settings/SettingsViewModel.Updates.cs | 20 +++++- .../Dialogs/PackageManagerInstallDialog.axaml | 29 ++++++++ .../PackageManagerInstallDialog.axaml.cs | 23 +++++++ .../PackageInstallConsentTests.cs | 60 +++++++++++++++++ 8 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 Views/Dialogs/PackageManagerInstallDialog.axaml create mode 100644 Views/Dialogs/PackageManagerInstallDialog.axaml.cs create mode 100644 tests/NullWave.Tests/PackageInstallConsentTests.cs diff --git a/Helpers/Localization/Locales.cs b/Helpers/Localization/Locales.cs index ba4f1b2..9651c30 100644 --- a/Helpers/Localization/Locales.cs +++ b/Helpers/Localization/Locales.cs @@ -115,6 +115,11 @@ public static Dictionary GetDictionary(string code) ["Settings_Updates_Update"] = "Update", ["Settings_Updates_Install"] = "Install", ["Settings_Updates_SystemPackage"] = "system package", + ["Settings_Updates_InstallConsent_Title"] = "Confirm package installation", + ["Settings_Updates_InstallConsent_Description"] = "NullWave is about to run this package-manager command. Review it before allowing the install:", + ["Settings_Updates_InstallConsent_Remember"] = "Remember my choice for future package installs", + ["Settings_Updates_InstallConsent_Run"] = "Run install", + ["Settings_Updates_InstallDeclined"] = "Installation declined.", // Audio ["Settings_Audio_Section_Download"] = "DOWNLOAD", diff --git a/Helpers/Localization/Locales.ru.cs b/Helpers/Localization/Locales.ru.cs index b0865a9..73244d4 100644 --- a/Helpers/Localization/Locales.ru.cs +++ b/Helpers/Localization/Locales.ru.cs @@ -94,6 +94,11 @@ public static partial class Locales ["Settings_Updates_Update"] = "Обновить", ["Settings_Updates_Install"] = "Установить", ["Settings_Updates_SystemPackage"] = "системный пакет", + ["Settings_Updates_InstallConsent_Title"] = "Подтвердите установку пакета", + ["Settings_Updates_InstallConsent_Description"] = "NullWave собирается выполнить эту команду менеджера пакетов. Проверьте её перед подтверждением установки:", + ["Settings_Updates_InstallConsent_Remember"] = "Запомнить выбор для будущих установок пакетов", + ["Settings_Updates_InstallConsent_Run"] = "Запустить установку", + ["Settings_Updates_InstallDeclined"] = "Установка отклонена.", // Audio ["Settings_Audio_Section_Download"] = "ЗАГРУЗКА", diff --git a/Models/Settings/Preferences.cs b/Models/Settings/Preferences.cs index 8e5e1b6..6da0e91 100644 --- a/Models/Settings/Preferences.cs +++ b/Models/Settings/Preferences.cs @@ -83,6 +83,7 @@ public class Preferences public List UnlockedExclusiveWallpapers { get; set; } = new(); public bool EnableExperimentalFeatures { get; set; } = false; public DownloaderConsent? DownloaderConsent { get; set; } + public bool? PackageManagerInstallConsent { get; set; } public TagWriteMode TagWriteMode { get; set; } = TagWriteMode.DownloadsOnly; public string ImportMode { get; set; } = "Ask"; } \ No newline at end of file diff --git a/Services/System/DependencyUpdateService.cs b/Services/System/DependencyUpdateService.cs index 053e944..2bb6e49 100644 --- a/Services/System/DependencyUpdateService.cs +++ b/Services/System/DependencyUpdateService.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Net.Http; using System.Runtime.InteropServices; using System.Text.Json; @@ -19,6 +21,8 @@ public class DependencyInfo public bool IsInstalled { get; init; } } +public sealed record PackageInstallExecution(bool Approved, ProcessResult? ProcessResult); + public class DependencyUpdateService { private static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(8); @@ -29,11 +33,17 @@ public class DependencyUpdateService private const int WingetNoUpdateExitCode = unchecked((int)0x8A15002B); private readonly HttpClient _http; + private readonly Func, TimeSpan, Task> _packageProcessRunner; + + public const string InstallCancelledStatus = "Install cancelled by user"; - public DependencyUpdateService() + public DependencyUpdateService( + Func, TimeSpan, Task>? packageProcessRunner = null) { _http = new HttpClient { Timeout = TimeSpan.FromSeconds(8) }; _http.DefaultRequestHeaders.Add("User-Agent", "NullWave-DepChecker"); + _packageProcessRunner = packageProcessRunner + ?? ((executable, arguments, timeout) => ProcessRunner.RunAsync(executable, arguments, timeout: timeout)); } // ===== YT-DLP ===== @@ -181,22 +191,55 @@ public async Task GetVlcInfoAsync() return null; } - public async Task InstallVlcAsync() + public async Task InstallVlcAsync(Func> requestConsent) { if (!OperatingSystem.IsWindows()) return "Install via your package manager (dnf/apt)"; - Log.Information("[DependencyUpdate] Attempting VLC install via winget..."); - var ok = await RunCommandAsync("winget", "install", "--id", "VideoLAN.VLC", "-e", "--accept-source-agreements", "--accept-package-agreements"); - if (ok != null) + var result = await RunPackageInstallAsync( + "winget", + new List + { + "install", "--id", "VideoLAN.VLC", "-e", + "--accept-source-agreements", "--accept-package-agreements" + }, + requestConsent); + if (!result.Approved) + return InstallCancelledStatus; + + var process = result.ProcessResult + ?? throw new InvalidOperationException("An approved package install must have a process result."); + if (process.ExitCode == 0 && !process.TimedOut && !process.Canceled) { Log.Information("[DependencyUpdate] VLC installed via winget"); VlcLocator.Invalidate(); return "VLC installed via winget"; } + + Log.Warning( + "[DependencyUpdate] winget install failed with exit code {ExitCode}; timed out: {TimedOut}; canceled: {Canceled}", + process.ExitCode, process.TimedOut, process.Canceled); return "winget install failed - install VLC manually"; } + public async Task RunPackageInstallAsync( + string executable, + IList arguments, + Func> requestConsent) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executable); + ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(requestConsent); + + var command = FormatCommand(executable, arguments); + if (!await requestConsent(command)) + return new PackageInstallExecution(false, null); + + Log.Information("[DependencyUpdate] Running approved package install: {Command}", command); + var result = await _packageProcessRunner(executable, arguments, ActionTimeout); + return new PackageInstallExecution(true, result); + } + // ===== FFMPEG & .NET ===== public async Task GetFfmpegInfoAsync() { @@ -235,6 +278,19 @@ public Task GetDotNetInfoAsync() private static Task RunCommandAsync(string cmd, params string[] args) => RunWithTimeoutAsync(ActionTimeout, cmd, args); + private static string FormatCommand(string executable, IList arguments) + { + static string FormatArgument(string argument) + { + if (argument.Length > 0 && argument.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) + return argument; + return "\"" + argument.Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\"", "\\\"", StringComparison.Ordinal) + "\""; + } + + return string.Join(" ", new[] { executable }.Concat(arguments).Select(FormatArgument)); + } + private static async Task RunWithTimeoutAsync(TimeSpan timeout, string cmd, string[] args) { try diff --git a/ViewModels/Settings/SettingsViewModel.Updates.cs b/ViewModels/Settings/SettingsViewModel.Updates.cs index 40f6a10..74bb7d7 100644 --- a/ViewModels/Settings/SettingsViewModel.Updates.cs +++ b/ViewModels/Settings/SettingsViewModel.Updates.cs @@ -12,6 +12,7 @@ using Avalonia.Input.Platform; using Avalonia.Media; using Avalonia.Platform.Storage; +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using NullWave.Helpers; @@ -144,7 +145,10 @@ [RelayCommand] private async Task InstallVlcAsync() VlcStatus = "Installing..."; try { - VlcStatus = await _deps.InstallVlcAsync(); + var status = await _deps.InstallVlcAsync(ConfirmPackageInstallAsync); + VlcStatus = status == DependencyUpdateService.InstallCancelledStatus + ? LocalizationService.Instance["Settings_Updates_InstallDeclined"] + : status; } catch (Exception ex) { @@ -153,6 +157,20 @@ [RelayCommand] private async Task InstallVlcAsync() } } + private async Task ConfirmPackageInstallAsync(string command) + { + var rememberedChoice = _prefsService.Current.PackageManagerInstallConsent; + if (rememberedChoice.HasValue) + return rememberedChoice.Value; + + var dialog = new Views.Dialogs.PackageManagerInstallDialog(command); + var choice = await Dispatcher.UIThread.InvokeAsync( + () => DialogService.Instance.ShowDialogAsync(dialog)); + if (choice?.RememberChoice == true) + _prefsService.Update(p => p.PackageManagerInstallConsent = choice.Approved); + return choice?.Approved == true; + } + [RelayCommand] private async Task CheckDependenciesAsync() { // FIX: Busy guard to prevent double-clicks diff --git a/Views/Dialogs/PackageManagerInstallDialog.axaml b/Views/Dialogs/PackageManagerInstallDialog.axaml new file mode 100644 index 0000000..88184d6 --- /dev/null +++ b/Views/Dialogs/PackageManagerInstallDialog.axaml @@ -0,0 +1,29 @@ + + + + + + @@ -44,7 +44,7 @@ - + @@ -76,7 +76,7 @@ - + @@ -91,7 +91,7 @@ - + @@ -140,7 +140,7 @@ - + @@ -172,7 +172,7 @@ - + @@ -270,20 +270,20 @@ - - - + - @@ -107,7 +107,7 @@ Background="{StaticResource BrushBase}" CornerRadius="6" BorderBrush="{StaticResource BrushBorder}" BorderThickness="1"> - diff --git a/Views/Controls/TrackDetailView.axaml b/Views/Controls/TrackDetailView.axaml index 98becb2..183a2ae 100644 --- a/Views/Controls/TrackDetailView.axaml +++ b/Views/Controls/TrackDetailView.axaml @@ -164,7 +164,7 @@ - + @@ -306,7 +306,7 @@ + FontSize="{DynamicResource FontSizeXs}" VerticalAlignment="Center"/> @@ -696,7 +696,7 @@ - + diff --git a/Views/Settings/ApiKeysTab.axaml b/Views/Settings/ApiKeysTab.axaml index ad668c2..6078a42 100644 --- a/Views/Settings/ApiKeysTab.axaml +++ b/Views/Settings/ApiKeysTab.axaml @@ -7,7 +7,7 @@ x:DataType="vm:SettingsViewModel"> - + @@ -29,7 +29,7 @@ - + @@ -57,7 +57,7 @@ - + @@ -78,7 +78,7 @@ - + @@ -99,7 +99,7 @@ - + @@ -155,7 +155,7 @@ - + diff --git a/Views/Settings/AppearanceTab.axaml b/Views/Settings/AppearanceTab.axaml index fda8512..4371cdd 100644 --- a/Views/Settings/AppearanceTab.axaml +++ b/Views/Settings/AppearanceTab.axaml @@ -20,7 +20,7 @@ - + - + @@ -280,7 +280,7 @@ - + diff --git a/Views/Settings/AudioTab.axaml b/Views/Settings/AudioTab.axaml index e0f71bf..7ffec2b 100644 --- a/Views/Settings/AudioTab.axaml +++ b/Views/Settings/AudioTab.axaml @@ -7,27 +7,27 @@ x:DataType="vm:SettingsViewModel"> - + - + - + - + - + - + @@ -68,15 +68,15 @@ @@ -118,9 +118,9 @@ @@ -134,13 +134,13 @@ - + - + - + @@ -155,7 +155,7 @@ - + @@ -164,15 +164,15 @@ @@ -198,27 +198,27 @@ - + - + - + - + - + - + @@ -235,7 +235,7 @@ - + @@ -244,9 +244,9 @@ @@ -255,9 +255,9 @@ @@ -266,9 +266,9 @@ @@ -148,7 +148,7 @@ - + @@ -159,7 +159,7 @@ - + @@ -180,7 +180,7 @@ - + @@ -208,7 +208,7 @@ - + @@ -232,7 +232,7 @@ - + @@ -240,7 +240,7 @@ - + diff --git a/Views/Settings/UpdatesTab.axaml b/Views/Settings/UpdatesTab.axaml index 8fef8ed..ed24724 100644 --- a/Views/Settings/UpdatesTab.axaml +++ b/Views/Settings/UpdatesTab.axaml @@ -7,11 +7,11 @@ x:DataType="vm:SettingsViewModel"> - + - + @@ -58,7 +58,7 @@ - + - + \ No newline at end of file diff --git a/Views/Settings/AudioTab.axaml b/Views/Settings/AudioTab.axaml index 7ffec2b..53ff1b9 100644 --- a/Views/Settings/AudioTab.axaml +++ b/Views/Settings/AudioTab.axaml @@ -3,6 +3,7 @@ xmlns:vm="using:NullWave.ViewModels" xmlns:material="using:Material.Icons.Avalonia" xmlns:loc="using:NullWave.Helpers.Localization" + xmlns:controls="using:NullWave.Views.Controls" x:Class="NullWave.Views.Settings.AudioTab" x:DataType="vm:SettingsViewModel"> @@ -52,7 +53,15 @@ - + @@ -64,7 +73,15 @@ - + diff --git a/Views/Settings/GeneralTab.axaml b/Views/Settings/GeneralTab.axaml index f2b3906..afd90b7 100644 --- a/Views/Settings/GeneralTab.axaml +++ b/Views/Settings/GeneralTab.axaml @@ -3,6 +3,7 @@ xmlns:vm="using:NullWave.ViewModels" xmlns:material="using:Material.Icons.Avalonia" xmlns:loc="using:NullWave.Helpers.Localization" + xmlns:controls="using:NullWave.Views.Controls" x:Class="NullWave.Views.Settings.GeneralTab" x:DataType="vm:SettingsViewModel"> @@ -104,8 +105,14 @@ - + From 350fd6ce79bf6d3421769063ede385ef81c5cf13 Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Sat, 7 Nov 2026 18:26:39 +0400 Subject: [PATCH 45/52] ui: phase 3 - motion system and window transitions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Themes/ControlStyles.axaml | 68 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/Themes/ControlStyles.axaml b/Themes/ControlStyles.axaml index b7a75fe..7846f37 100644 --- a/Themes/ControlStyles.axaml +++ b/Themes/ControlStyles.axaml @@ -456,6 +456,11 @@ + + + + + @@ -767,4 +772,67 @@ + + + + + + + + + + + + + + \ No newline at end of file From 8ce4e82b2eed67cbad107e03bc06b5ee2d6059a7 Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Sat, 7 Nov 2026 18:27:57 +0400 Subject: [PATCH 46/52] fix: use supported Avalonia tab transition property Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Themes/ControlStyles.axaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Themes/ControlStyles.axaml b/Themes/ControlStyles.axaml index 7846f37..15012b6 100644 --- a/Themes/ControlStyles.axaml +++ b/Themes/ControlStyles.axaml @@ -813,6 +813,14 @@ + + From 4986da5137dc26f54fb2efcfef75dcbb10884295 Mon Sep 17 00:00:00 2001 From: ZenQuantDev Date: Sun, 8 Nov 2026 14:47:24 +0400 Subject: [PATCH 48/52] feat(ui): dropdown fixes, effects tier gating, rail animation, typography cleanup --- App.axaml.cs | 8 +- Helpers/Attached/ComboBoxScrollGuard.cs | 69 +++++++++++++++++ Helpers/Attached/SmoothScrollBehavior.cs | 3 +- Services/EffectsTierResolver.cs | 56 ++++++++++---- Services/SmartSorting/HardwareDetector.cs | 38 +++++++++- Services/SmartSorting/HardwareInfo.cs | 2 + Themes/ControlStyles.axaml | 55 +++++++------- Themes/Typography.axaml | 45 ++++------- ViewModels/Shell/MainViewModel.cs | 3 +- Views/Controls/MiniPlayerView.axaml | 42 ++++++----- Views/Controls/TrackDetailView.axaml | 1 - Views/Controls/WaveSeekBar.cs | 14 ++++ Views/Settings/AppearanceTab.axaml | 6 +- Views/Settings/GeneralTab.axaml | 2 +- Views/Settings/HelpTab.axaml | 2 +- Views/Settings/MaintenanceTab.axaml | 20 ++--- Views/SettingsWindow.axaml | 75 +++---------------- .../EffectsTierResolverTests.cs | 21 ++++++ 18 files changed, 286 insertions(+), 176 deletions(-) create mode 100644 Helpers/Attached/ComboBoxScrollGuard.cs diff --git a/App.axaml.cs b/App.axaml.cs index 30dd964..aac573b 100644 --- a/App.axaml.cs +++ b/App.axaml.cs @@ -41,6 +41,13 @@ public override void Initialize() public override void OnFrameworkInitializationCompleted() { + // Register ComboBox scroll guard (closes dropdowns when user scrolls outside them) + NullWave.Helpers.Attached.ComboBoxScrollGuard.Register(); + + // Attach hardware detection to effects tier resolver so it re-evaluates when detection completes + var prefs = PreferencesService.Shared; + var resolver = new EffectsTierResolver(prefs); + _ = resolver.AttachDetection(NullWave.Services.SmartSorting.HardwareDetector.GetCachedInfoAsync()); if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { // FIX (What's New every launch): read + persist LastSeenVersion BEFORE @@ -49,7 +56,6 @@ public override void OnFrameworkInitializationCompleted() // raced with the MainViewModel instance, which then wrote its stale // in-memory LastSeenVersion back to disk, re-triggering the dialog // on every single launch. - var prefs = PreferencesService.Shared; var currentVersion = GetAppVersion(); bool showWhatsNew = prefs.Current.LastSeenVersion != currentVersion; diff --git a/Helpers/Attached/ComboBoxScrollGuard.cs b/Helpers/Attached/ComboBoxScrollGuard.cs new file mode 100644 index 0000000..52d7b14 --- /dev/null +++ b/Helpers/Attached/ComboBoxScrollGuard.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace NullWave.Helpers.Attached; + +/// +/// Closes open ComboBox dropdowns when the user scrolls outside them. +/// Avalonia's Popup doesn't auto-close on wheel events, causing orphaned dropdowns +/// that drift away from their owner controls when the page scrolls. +/// +public static class ComboBoxScrollGuard +{ + private static readonly HashSet Open = new(); + + // Custom IObserver because Avalonia 11+ exposes ComboBox.IsDropDownOpenProperty.Changed + // as a plain IObservable>, which has no + // AddClassHandler extension and no Subscribe(Action) overload without System.Reactive. + private sealed class DropDownObserver : IObserver> + { + public void OnCompleted() { } + public void OnError(Exception error) { } + + public void OnNext(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is ComboBox cb) + { + if (e.NewValue.GetValueOrDefault()) Open.Add(cb); + else Open.Remove(cb); + } + } + } + + public static void Register() + { + ComboBox.IsDropDownOpenProperty.Changed.Subscribe(new DropDownObserver()); + + InputElement.PointerWheelChangedEvent.AddClassHandler((_, e) => + { + if (Open.Count == 0) return; + + if (e.Source is Visual v && + v.GetSelfAndVisualAncestors().OfType().Any(b => b.Name == "PopupBorder")) + return; + + foreach (var cb in Open.ToArray()) + cb.IsDropDownOpen = false; + }, RoutingStrategies.Tunnel); + + ScrollViewer.ScrollChangedEvent.AddClassHandler((_, e) => + { + if (Open.Count == 0) return; + if (e.OffsetDelta != default) + { + if (e.Source is Visual v && + v.GetSelfAndVisualAncestors().OfType().Any(b => b.Name == "PopupBorder")) + return; + + foreach (var cb in Open.ToArray()) + cb.IsDropDownOpen = false; + } + }); + } +} \ No newline at end of file diff --git a/Helpers/Attached/SmoothScrollBehavior.cs b/Helpers/Attached/SmoothScrollBehavior.cs index f065979..975bc39 100644 --- a/Helpers/Attached/SmoothScrollBehavior.cs +++ b/Helpers/Attached/SmoothScrollBehavior.cs @@ -121,7 +121,8 @@ private static void OnWheel(object? sender, PointerWheelEventArgs e) var maxY = Math.Max(0, sv.Extent.Height - sv.Viewport.Height); if (maxY <= 0) return; - + // Don't swallow wheel events when already at edge (allows nested scrollers to pass through) + if ((s.Target <= 0 && e.Delta.Y > 0) || (s.Target >= maxY && e.Delta.Y < 0)) return; e.Handled = true; // Resync position if content extent changed under us (filter/refresh). diff --git a/Services/EffectsTierResolver.cs b/Services/EffectsTierResolver.cs index 3877308..7df9cb6 100644 --- a/Services/EffectsTierResolver.cs +++ b/Services/EffectsTierResolver.cs @@ -14,6 +14,7 @@ public class EffectsTierResolver : INotifyPropertyChanged private readonly Action? _post; private EffectsTier? _devOverride; private EffectsTier? _batteryOverride; + private bool _resolutionLogged; public EffectsTierResolver(PreferencesService prefs) : this(prefs, null, null) { } @@ -43,35 +44,62 @@ public void ClearBatteryOverride() OnPropertyChanged(nameof(Current)); } - /// - /// Call this when AutoEffectsTier or manual EffectsTier preferences change in the Settings UI. - /// public void NotifyPreferencesChanged() => OnPropertyChanged(nameof(Current)); public EffectsTier Current { get { + EffectsTier result; + // 1. Dev override always wins (for testing in Dev Tab) - if (_devOverride.HasValue) return _devOverride.Value; - + if (_devOverride.HasValue) + { + result = _devOverride.Value; + } // 2. Battery override forces Minimal to save power/reduce heat - if (_batteryOverride.HasValue && _prefs.Current.AutoEffectsTier) - return _batteryOverride.Value; - + else if (_batteryOverride.HasValue && _prefs.Current.AutoEffectsTier) + { + result = _batteryOverride.Value; + } // 3. Auto-detect based on hardware - if (_prefs.Current.AutoEffectsTier) + else if (_prefs.Current.AutoEffectsTier) { var info = _hardwareProbe != null ? _hardwareProbe() : HardwareDetector.CachedOrNull(); - return info != null && (info.HasNvidia || info.HasAmd) + result = info != null && (info.HasNvidia || info.HasAmd || info.HasModernGpu) ? EffectsTier.Standard : EffectsTier.Minimal; } - // 4. Manual override from Settings - return System.Enum.TryParse(_prefs.Current.EffectsTier, out var manual) - ? manual - : EffectsTier.Standard; + else + { + result = System.Enum.TryParse(_prefs.Current.EffectsTier, out var manual) + ? manual + : EffectsTier.Standard; + } + + // Log once when we have complete hardware info (not "pending") + if (!_resolutionLogged && Serilog.Log.IsEnabled(Serilog.Events.LogEventLevel.Information)) + { + var info = _hardwareProbe != null ? _hardwareProbe() : HardwareDetector.CachedOrNull(); + + // Only log when detection is complete (not pending) + if (info != null || !_prefs.Current.AutoEffectsTier) + { + _resolutionLogged = true; + Serilog.Log.Information( + "[EFFECTS] tier={Tier} auto={Auto} dev={DevOverride} battery={BatteryOverride} nvidia={Nvidia} amd={Amd} modern={Modern} gpu={GpuName}", + result, + _prefs.Current.AutoEffectsTier, + _devOverride.HasValue, + _batteryOverride.HasValue, + info?.HasNvidia ?? false, + info?.HasAmd ?? false, + info?.HasModernGpu ?? false, + info?.GpuName ?? "none"); + } + } + return result; } } diff --git a/Services/SmartSorting/HardwareDetector.cs b/Services/SmartSorting/HardwareDetector.cs index c12426e..87dae65 100644 --- a/Services/SmartSorting/HardwareDetector.cs +++ b/Services/SmartSorting/HardwareDetector.cs @@ -86,6 +86,29 @@ private long GetTotalRamGB() return 4; } + internal static bool IsModernGpuName(string n) => + !n.Contains("Basic Render", StringComparison.OrdinalIgnoreCase) && + !n.Contains("Basic Display", StringComparison.OrdinalIgnoreCase) && + (new[] { "Iris", "UHD", " Xe", "Arc", "Radeon", "Vega", "GeForce", "RTX", "GTX", "Quadro", "Adreno" } + .Any(k => n.Contains(k, StringComparison.OrdinalIgnoreCase)) + || System.Text.RegularExpressions.Regex.IsMatch(n, @"HD Graphics\s+[56]\d\d")); + + private static void DetectLinuxGpuVendors(HardwareInfo info) + { + try + { + foreach (var dir in Directory.EnumerateDirectories("/sys/class/drm", "card?")) + { + var f = Path.Combine(dir, "device", "vendor"); + if (!File.Exists(f)) continue; + var v = File.ReadAllText(f).Trim().ToLowerInvariant(); + if (v is "0x10de" or "0x1002" or "0x8086") + info.HasModernGpu = true; + } + } + catch { } + } + private async Task GetGpuInfoAsync(HardwareInfo info, CancellationToken ct) { // 1. Try NVIDIA @@ -152,8 +175,15 @@ private async Task GetGpuInfoAsync(HardwareInfo info, CancellationToken ct) { var name = parts[0]; if (string.IsNullOrWhiteSpace(name)) continue; + + if (info.GpuName == "Unknown") + info.GpuName = name; + + // Check if this is a modern GPU (Intel UHD/Iris, AMD Vega, etc.) + if (IsModernGpuName(name)) + info.HasModernGpu = true; - // FIX: Require discrete GPU signal to avoid integrated APUs + // FIX: Require discrete GPU signal to avoid integrated APUs for AI bool isDiscreteAmd = name.Contains("Radeon RX", StringComparison.OrdinalIgnoreCase) || name.Contains("Radeon Pro", StringComparison.OrdinalIgnoreCase); bool isDiscreteIntel = name.Contains("Arc", StringComparison.OrdinalIgnoreCase); @@ -184,6 +214,12 @@ private async Task GetGpuInfoAsync(HardwareInfo info, CancellationToken ct) } catch (Exception ex) { Log.Warning(ex, "[HardwareDetector] Windows GPU detection failed"); } } + + // 4. Linux: detect modern GPU vendors from /sys/class/drm + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + DetectLinuxGpuVendors(info); + } } private static Task? _cachedInfoTask; diff --git a/Services/SmartSorting/HardwareInfo.cs b/Services/SmartSorting/HardwareInfo.cs index 59054de..9945501 100644 --- a/Services/SmartSorting/HardwareInfo.cs +++ b/Services/SmartSorting/HardwareInfo.cs @@ -8,6 +8,8 @@ public class HardwareInfo public long RamGB { get; set; } public long GpuVramGB { get; set; } public string GpuType { get; set; } = "Unknown"; + public string GpuName { get; set; } = "Unknown"; + public bool HasModernGpu { get; set; } public bool HasNvidia { get; set; } public bool HasAmd { get; set; } diff --git a/Themes/ControlStyles.axaml b/Themes/ControlStyles.axaml index 15012b6..9cdb2fc 100644 --- a/Themes/ControlStyles.axaml +++ b/Themes/ControlStyles.axaml @@ -478,20 +478,37 @@ - + - + + + @@ -733,7 +750,7 @@ - + @@ -751,7 +768,7 @@ - + - + + - - - - + - - - - - - - - - - - - + + - - - @@ -117,6 +55,11 @@ Width="{Binding SettingsSidebarWidth}" BorderBrush="{StaticResource BrushBorder}" BorderThickness="0,0,1,0"> + + + + + - + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + + - - - - - - - - - - - - - - - - + diff --git a/Views/SettingsWindow.axaml.cs b/Views/SettingsWindow.axaml.cs index 2d50038..c6baf22 100644 --- a/Views/SettingsWindow.axaml.cs +++ b/Views/SettingsWindow.axaml.cs @@ -1,32 +1,108 @@ +using System.ComponentModel; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Animation; using Avalonia.Controls; +using Avalonia.Controls.Shapes; using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; using NullWave.Helpers; using NullWave.Helpers.Diagnostics; +using NullWave.Models; using NullWave.Services; +using NullWave.ViewModels; +using NullWave.Views.Settings; namespace NullWave.Views; public partial class SettingsWindow : Window { + private readonly Dictionary _pages = new(); + private SettingsViewModel? _vm; + public SettingsWindow() { InitializeComponent(); WallpaperChromeSync.Attach(this); - // CRITICAL: single-host toast routing - while Settings is open, - // toasts render here ONLY (MainWindow overlay hides itself). Opened += (_, _) => ToastService.Instance.SetActiveHost(true); - Closed += (_, _) => ToastService.Instance.SetActiveHost(false); + Closed += (_, _) => { ToastService.Instance.SetActiveHost(false); Detach(); }; - // F3 debugging works inside Settings too (modal dialog steals focus, - // so MainWindow's KeyDown never sees it). KeyDown += (_, e) => { - if (e.Key == Key.F3) - { - PerfOverlayController.Toggle(); - e.Handled = true; - } + if (e.Key == Key.F3) { PerfOverlayController.Toggle(); e.Handled = true; } + }; + + DataContextChanged += (_, _) => + { + Detach(); + _vm = DataContext as SettingsViewModel; + if (_vm == null) return; + _vm.PropertyChanged += OnVmChanged; + ShowPage(_vm.CurrentSettingsPage); }; } + + private void Detach() + { + if (_vm != null) _vm.PropertyChanged -= OnVmChanged; + _vm = null; + } + + private void OnVmChanged(object? s, PropertyChangedEventArgs e) + { + if (_vm == null) return; + if (e.PropertyName == nameof(SettingsViewModel.CurrentSettingsPage)) + ShowPage(_vm.CurrentSettingsPage); + else if (e.PropertyName == nameof(SettingsViewModel.ShowWaveSpine)) + ScheduleBead(); + } + + private static Control Build(string k) => k switch + { + "General" => new GeneralTab(), + "Appearance" => new AppearanceTab(), + "Audio" => new AudioTab(), + "Downloads" => new DownloadsTab(), + "Integrations" => new IntegrationsTab(), + "ApiKeys" => new ApiKeysTab(), + "SmartFeatures" => new SmartSortingTab(), + "Maintenance" => new MaintenanceTab(), + "Updates" => new UpdatesTab(), + "About" => new AboutTab(), + "Help" => new HelpTab(), + _ => new DevTab() + }; + + private void ShowPage(string key) + { + if (!_pages.TryGetValue(key, out var page)) + { + page = Build(key); + page.DataContext = DataContext; + _pages[key] = page; + } + PageHost.PageTransition = EffectsTierResolver.Shared.Current == EffectsTier.Minimal + ? null + : new CrossFade(System.TimeSpan.FromMilliseconds(200)); + PageHost.Content = page; + ScheduleBead(); + } + + private void ScheduleBead() + { + Dispatcher.UIThread.Post(UpdateBead, DispatcherPriority.Loaded); + DispatcherTimer.RunOnce(UpdateBead, System.TimeSpan.FromMilliseconds(230)); + } + + private void UpdateBead() + { + var active = this.GetVisualDescendants().OfType