diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..79eaa09 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [ main, "release/**", "feature/**" ] + pull_request: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-and-test: + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore -warnaserror:RS0030 + + - name: Test + 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 \ No newline at end of file diff --git a/.github/workflows/cl.yml b/.github/workflows/cl.yml deleted file mode 100644 index afcdb38..0000000 --- a/.github/workflows/cl.yml +++ /dev/null @@ -1,21 +0,0 @@ - codeql-analysis: - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - strategy: - fail-fast: false - matrix: - language: [ 'csharp' ] - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - - name: Autobuild - uses: github/codeql-action/autobuild@v4 - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 \ No newline at end of file diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml deleted file mode 100644 index e41528f..0000000 --- a/.github/workflows/security.yml +++ /dev/null @@ -1,17 +0,0 @@ -jobs: - build-and-test: - runs-on: windows-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.0.x' - - name: Restore dependencies - run: dotnet restore - - name: Build - run: dotnet build --no-restore - - name: Test - run: dotnet test --no-build --verbosity normal \ No newline at end of file diff --git a/App.axaml b/App.axaml index 1ccd288..97f04bf 100644 --- a/App.axaml +++ b/App.axaml @@ -24,5 +24,16 @@ + + + + + + \ No newline at end of file diff --git a/App.axaml.cs b/App.axaml.cs index 30dd964..66ae471 100644 --- a/App.axaml.cs +++ b/App.axaml.cs @@ -41,6 +41,12 @@ 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 + _ = EffectsTierResolver.Shared.AttachDetection(NullWave.Services.SmartSorting.HardwareDetector.GetCachedInfoAsync()); + var prefs = PreferencesService.Shared; if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { // FIX (What's New every launch): read + persist LastSeenVersion BEFORE @@ -49,7 +55,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/CHANGELOG.md b/CHANGELOG.md index 4c3aaf9..7a6c82c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,89 @@ 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] - 07-Nov-2026 + +### Added +- **Plugin Contracts SDK**: Extracted `NullWave.Plugins.Contracts` as a standalone NuGet-packageable project containing `IPlugin`, `IPluginHost`, `PluginManifest`, `ProviderKind`, and `PluginState` — the public API surface for third-party plugin authors. +- **Plugin Discovery Engine**: `PluginDiscovery` scans the `plugins/` directory at startup, loads assemblies via isolated `AssemblyLoadContext`, validates manifests, and initializes plugins with per-plugin `PluginHost` instances. +- **OpenWeather Reference Plugin**: First external plugin (`NullWave.Plugins.OpenWeather`) demonstrating the full plugin lifecycle — manifest declaration, `AssemblyLoadContext` isolation, host API usage, and graceful state transitions. +- **Built-in Plugin Manifest Factory**: `BuiltInPluginManifest.FromBuiltIn()` generates manifests from existing `IProvider` implementations, bridging the legacy provider system with the new plugin architecture. +- **Plugin Host Implementation**: `PluginHost` provides plugins with scoped access to API keys (permission-checked), logging (prefixed), toast notifications, and future library/process APIs. +- **Provider Interface Contracts**: `IDownloadProvider`, `IMetadataProvider`, `IAIProvider`, `IWeatherProvider` now extend `IProvider : IPlugin` from the Contracts project, unifying the provider and plugin type hierarchies. +- **Unified FileNamer**: one naming rule for download completion, Sync, import and export; + 255-byte UTF-8 limit, FAT-safe characters, surrogate-safe truncation, keeps the [nw-id] suffix. +- **TagWriteMode**: Never / Downloads only (default) / Always, through one central rule. +- **Import modes**: Keep in place (default) or Copy into the managed library, with a duplicate + check before any copy; operations recorded in file-ops.json (audit only; undo not implemented). +- **Recycle Bin removal**: removal can send the file to the OS Recycle Bin / Trash; unmanaged + files are never recycled or deleted. +- **Safe export**: copies are renamed and retagged (ID3v2.3) with a relative M3U8; originals + are never modified. +- **Install consent**: package-manager installs show the exact command and need your confirmation; + declining runs nothing; the choice can be remembered. +- **Keystore honesty**: a notice appears when the weaker fallback key storage is in use; deleting + keys on Linux also clears the Secret Service entry. +- **CI matrix**: build and full test suite on Windows and Ubuntu with banned-API enforcement. + +### Changed +- **Plugin Lifecycle Signature**: `IPlugin.InitializeAsync` now accepts `IPluginHost` as the first parameter, giving plugins access to host services without tight coupling to the main app. +- **EffectsTierResolver Singleton**: Converted to a process-wide singleton (`EffectsTierResolver.Shared`) to ensure consistent tier resolution across the main app, Settings window, and wallpaper service. +- **Plugin State Preservation**: `PluginManager` no longer overwrites `Unavailable` or `Disabled` states with `Error` when initialization returns false — plugins that intentionally decline startup (missing API keys, disabled by preference) retain their honest state. +- **Type Migration**: `IPlugin`, `PluginState`, `ProviderKind`, and `PluginManifest` moved from `NullWave.Services.Plugins` to `NullWave.Plugins.Contracts` namespace, establishing a clean dependency boundary between host and plugins. +- **Settings ViewModel**: Added `EffectsTierLabel`, `CanForceStandardTier`, and `ForceStandardEffectsTierCommand` for the new Effects Tier visibility card. +- **Settings reorganization**: Plugins and Sources merged into Integrations; new Downloads tab; + Advanced dissolved into Maintenance and Help. +- **Honest updates**: exact package-manager exit codes, parallel dependency checks, pre-release + detection. The yt-dlp updater no longer falls back silently to winget; update it manually. +- **Localization**: Downloads, Integrations, Maintenance, General and new dialogs fully translated + EN/RU; the experimental banner describes only available features. + +### Fixed +- **Dependency Isolation**: Plugins load in separate `AssemblyLoadContext` instances, preventing dependency version conflicts between plugins and the host app. +- **Permission-Gated API Access**: `PluginHost.GetApiKey()` checks the plugin's declared `Permissions.AccessApiKeys` before returning credentials, with all access logged for audit. +- **Manifest-Driven Discovery**: Plugins declare their identity, capabilities, and permissions in `plugin.json`, enabling future store integration and automated security scanning. +- **Orphan sweep safety**: previews and confirms before moving orphaned files to `_orphans`; files + are never deleted. +- **Keystore (Linux)**: data-loss path closed; locked or missing secret store falls back to the + legacy protector; unknown or newer formats open read-only and are never quarantined; temp files + are created 0600. +- **Consent ghost rows**: consent is verified before the library row is inserted. +- **Playback stalls**: native LibVLC Stop runs off the UI thread under a generation lock. + +## [0.6.5-beta.1] - 31-Oct-2026 + +### Added +- **Keystore v2**: Migrated from machine-ID-derived keys to OS-native secret storage. Windows uses DPAPI, Linux uses Secret Service when available, with safe legacy fallback. Existing keys auto-migrate on first launch. +- **Download Consent Gate**: Downloads now require explicit first-time consent. A dialog explains that NullWave uses yt-dlp and that you're responsible for respecting copyright. +- **.NET 10 LTS**: Retargeted from .NET 8 to .NET 10 LTS (supported until Nov 2028). +- **Smart Playlists**: Three virtual, auto-updating playlists in the "AI Playlists" folder (Recently Added, Most Played, Forgotten Favorites). +- **M3U Playlist Support**: Full import/export with preview dialog, relative/absolute paths, and stream URL preservation. +- **Provider Registry Foundation**: Architecture for managing content providers (Download, Metadata, Mood, Weather) with active-selection semantics (UI preview in this release, routing in v0.6.6). +- **New Audio Sources**: Added native yt-dlp support for Bandcamp, Audiomack, Archive.org, and Mixcloud. + +### Changed +- **Radio Detection**: Early-return for known music sources prevents false positives (e.g., `radiohead.bandcamp.com` or `/track/streams-of-light`). +- **yt-dlp Runtime**: Bun removed (deprecated). Detection order: Deno (≥2.3.0) → Node (≥22.0.0). +- **429 Detection**: Tightened to match `HTTP Error 429` only, preventing false backoff. +- **Privacy Documentation**: `PRIVACY.md` now lists all network hosts contacted by the app. +- **Test Suite**: Expanded to 533 passing tests. + +### Fixed +- **List Parameter Stripping**: YouTube URLs with `list=` parameter are now safely stripped using a proper URI parser, allowing single-track downloads from playlist pages without breaking the URL. +- **Semaphore Shrink-Debt**: Fixed an edge case where changing the concurrent download limit mid-download would permanently stall the queue. +- **Keystore Data Safety**: "Delete All Keys" now securely removes backup and quarantined files. First-run directory creation is guaranteed. +- **Provider Descriptors**: All 4 providers now expose `Descriptor` properties for registry integration. +- **CA2024 Warning**: Fixed `reader.EndOfStream` usage in async methods. +- **Culture-Invariant Parsing**: Fixed `FormatException` crashes on non-US systems (e.g., Russian/German) by enforcing `CultureInfo.InvariantCulture` across hardware detection, Spotify parsing, M3U import, and sleep timers. +- **Non-English Search**: Added accent-insensitive search (`é`→`e`, `ё`→`е`) and FTS5 normalization. Searching "cafe" now finds "café". +- **Font Fallbacks**: Added global font fallback chain (Segoe UI, Noto Sans, Microsoft YaHei) to properly render Cyrillic, CJK, and emoji characters in the UI. +- **Download File Recovery**: Injected a unique `[nw-{hash}]` tag into yt-dlp output templates, guaranteeing file recovery even if stdout path printing fails on Bandcamp/Mixcloud. +- **OGG Format Fix**: Mapped "ogg" to "vorbis" for yt-dlp, preventing `invalid audio format` errors. +- **Metadata Race Conditions**: Fixed edge cases where fast consecutive URL pastes could overwrite the wrong track's metadata or input fields. +- **Live Toast Activities**: Download and playlist progress toasts no longer auto-dismiss while the operation is still running. +- **YouTube Fallback**: Added automatic yt-dlp fallback when the YouTube Data API returns `BadRequest` or empty metadata. + +--- ## [0.6.4] - 25-Oct-2026 🏗️ "Foundations" diff --git a/DISCLAIMER.md b/DISCLAIMER.md index d7a06c6..57ed5b0 100644 --- a/DISCLAIMER.md +++ b/DISCLAIMER.md @@ -32,6 +32,18 @@ OF THIS SOFTWARE. ## Third-Party Services -NullWave interacts with third-party APIs (YouTube Data API, Spotify Web API, SoundCloud -API). Use of these services is governed by their respective terms of service. The +NullWave interacts with third-party APIs (YouTube Data API) and public web pages (Spotify, SoundCloud). +Use of these services is governed by their respective terms of service. The author(s) of NullWave have no affiliation with these services. + +## Third-Party Plugins + +NullWave supports third-party plugins that extend its functionality. By installing plugins, you acknowledge that: + +1. **Plugins are not developed or maintained by ZenQuant** unless explicitly marked as "Official" or "Verified" +2. **You install plugins at your own risk** - ZenQuant is not liable for damages, data loss, or security breaches caused by third-party plugins +3. **Plugins may request access to sensitive data** including API keys, file system access, and network requests. Review permissions carefully before installation +4. **You are responsible for ensuring plugin licenses are compatible** with your intended use (commercial, educational, personal) +5. **Malicious plugins will be removed** from the store when reported. Use the "Report Plugin" feature to flag suspicious behavior + +ZenQuant reserves the right to disable or remove any plugin that violates our terms of service, contains malware, or infringes on intellectual property rights. diff --git a/Directory.Packages.props b/Directory.Packages.props index fec0eb0..c87fdd8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,17 +6,19 @@ - - - - + + + + - + + + - - + + @@ -28,11 +30,11 @@ - - - - - - + + + + + + \ No newline at end of file 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/Helpers/Converters/Booleans/IntEqualsConverter.cs b/Helpers/Converters/Booleans/IntEqualsConverter.cs index 821506a..2a3adf9 100644 --- a/Helpers/Converters/Booleans/IntEqualsConverter.cs +++ b/Helpers/Converters/Booleans/IntEqualsConverter.cs @@ -11,7 +11,7 @@ public class IntEqualsConverter : IValueConverter public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { - if (value is int v && parameter is string s && int.TryParse(s, NumberStyles.Integer, culture, out var target)) + if (value is int v && parameter is string s && int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var target)) return v == target; return false; } diff --git a/Helpers/Converters/Media/SourceToBadgeConverters.cs b/Helpers/Converters/Media/SourceToBadgeConverters.cs index 4518efb..ae8e994 100644 --- a/Helpers/Converters/Media/SourceToBadgeConverters.cs +++ b/Helpers/Converters/Media/SourceToBadgeConverters.cs @@ -20,6 +20,11 @@ public static class BadgeColors TrackSource.Spotify => light ? (Parse("#1A1DB954"), Parse("#0E6B30")) : (Parse("#291DB954"), Parse("#7BE0A2")), TrackSource.LastFm => light ? (Parse("#1AD51007"), Parse("#A00C05")) : (Parse("#29D51007"), Parse("#FF7A72")), TrackSource.Local => light ? (Parse("#1A4E9BFF"), Parse("#1A5276")) : (Parse("#294E9BFF"), Parse("#8BB8FF")), + // NEW sources + TrackSource.Bandcamp => light ? (Parse("#1A1DA0C3"), Parse("#0A5A7A")) : (Parse("#291DA0C3"), Parse("#62A4C7")), + TrackSource.Audiomack => light ? (Parse("#1AFFA200"), Parse("#B37200")) : (Parse("#29FFA200"), Parse("#FFD080")), + TrackSource.ArchiveOrg => light ? (Parse("#1A333333"), Parse("#1A1A1A")) : (Parse("#29333333"), Parse("#AAAAAA")), + TrackSource.Mixcloud => light ? (Parse("#1A5000FF"), Parse("#2A0080")) : (Parse("#295000FF"), Parse("#A080FF")), _ => light ? (Parse("#1A6B7280"), Parse("#374151")) : (Parse("#296B7280"), Parse("#A8B4CC")), }; diff --git a/Helpers/Diagnostics/UiHangDetector.cs b/Helpers/Diagnostics/UiHangDetector.cs index cd28f51..784fb9b 100644 --- a/Helpers/Diagnostics/UiHangDetector.cs +++ b/Helpers/Diagnostics/UiHangDetector.cs @@ -10,40 +10,50 @@ namespace NullWave.Helpers; public class UiHangDetector : IDisposable { private readonly CancellationTokenSource _cts = new(); - private readonly TimeSpan _interval = TimeSpan.FromSeconds(2); - private readonly TimeSpan _threshold = TimeSpan.FromMilliseconds(500); + private long _lastPong = Stopwatch.GetTimestamp(); + private int _pending; + private bool _reported; public UiHangDetector() { + var ct = _cts.Token; Task.Run(async () => { - while (!_cts.Token.IsCancellationRequested) + while (!ct.IsCancellationRequested) { try { - var sw = Stopwatch.StartNew(); - await Dispatcher.UIThread.InvokeAsync(() => + if (Interlocked.Exchange(ref _pending, 1) == 0) { - sw.Stop(); - }); - - if (sw.Elapsed > _threshold) - { - Log.Warning("[UiHangDetector] UI thread blocked for {ElapsedMs}ms", sw.ElapsedMilliseconds); + Dispatcher.UIThread.Post(() => + { + Interlocked.Exchange(ref _lastPong, Stopwatch.GetTimestamp()); + Volatile.Write(ref _pending, 0); + }); } - await Task.Delay(_interval, _cts.Token); - } - catch (TaskCanceledException) - { - break; + await Task.Delay(500, ct); + + var ms = (long)Stopwatch.GetElapsedTime(Interlocked.Read(ref _lastPong)).TotalMilliseconds; + if (ms > 2000 && !_reported) + { + _reported = true; + Log.Warning("[UiHangDetector] UI thread STILL blocked after {Ms} ms", ms); + } + else if (ms <= 2000 && _reported) + { + _reported = false; + Log.Information("[UiHangDetector] UI thread recovered"); + } } - catch (Exception ex) - { - Log.Error(ex, "[UiHangDetector] Error in ping loop"); + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + Log.Error(ex, "[UiHangDetector] ping loop"); + try { await Task.Delay(5000, ct); } catch { break; } } } - }); + }, ct); } public void Dispose() diff --git a/Helpers/FileNamer.cs b/Helpers/FileNamer.cs new file mode 100644 index 0000000..e348e77 --- /dev/null +++ b/Helpers/FileNamer.cs @@ -0,0 +1,136 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Collections.Generic; +using Serilog; + +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 (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 via TryRelink +/// +public static class FileNamer +{ + private const int MaxFilenameBytes = 255; + + // 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: {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)); + + if (!extension.StartsWith(".")) + extension = "." + extension; + + var id8 = ExtractId8(trackId); + var cleanArtist = Sanitize(artist ?? "Unknown Artist"); + var cleanTitle = Sanitize(title ?? "Unknown Title"); + + // The suffix we MUST preserve so TryRelink can find the file later. + var suffix = $" [nw-{id8}]{extension}"; + var suffixBytes = Encoding.UTF8.GetByteCount(suffix); + + var availableBytes = MaxFilenameBytes - suffixBytes; + if (availableBytes < 20) + { + Log.Warning("[FileNamer] Suffix too long, truncating ID"); + suffix = $" [nw]{extension}"; + suffixBytes = Encoding.UTF8.GetByteCount(suffix); + availableBytes = MaxFilenameBytes - suffixBytes; + } + + var baseName = TruncateToByteLimit($"{cleanArtist} - {cleanTitle}", availableBytes); + var result = baseName + suffix; + + if (Encoding.UTF8.GetByteCount(result) > MaxFilenameBytes) + { + Log.Warning("[FileNamer] Generated filename exceeds limit, forcing truncation"); + result = TruncateToByteLimit(result, MaxFilenameBytes - suffixBytes) + suffix; + } + + return result; + } + + private static string ExtractId8(string trackId) + { + if (string.IsNullOrWhiteSpace(trackId)) + return "00000000"; + + if (Guid.TryParse(trackId, out var guid)) + return guid.ToString("N")[..8]; + + var cleaned = Regex.Replace(trackId, @"[^a-zA-Z0-9]", ""); + return cleaned.Length >= 8 ? cleaned[..8] : cleaned.PadRight(8, '0'); + } + + private static string Sanitize(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return "Unknown"; + + // 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(); + sanitized = sanitized.Trim('.', ' '); + + return string.IsNullOrWhiteSpace(sanitized) ? "Unknown" : sanitized; + } + + private static string TruncateToByteLimit(string input, int maxBytes) + { + if (string.IsNullOrEmpty(input)) return input; + + if (Encoding.UTF8.GetByteCount(input) <= maxBytes) return input; + + int low = 0, high = input.Length, bestLength = 0; + while (low <= high) + { + int mid = (low + high) / 2; + if (Encoding.UTF8.GetByteCount(input.AsSpan(0, mid)) <= maxBytes) + { + bestLength = mid; + low = 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(); + + if (bestLength < input.Length && bestLength > 3 + && Encoding.UTF8.GetByteCount(result) + Encoding.UTF8.GetByteCount("...") <= maxBytes) + { + result += "..."; + } + + return result; + } + + public static bool IsValidFilename(string filename) + { + 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/Helpers/Localization/Locales.cs b/Helpers/Localization/Locales.cs index 94a3ebd..4f61799 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", @@ -257,11 +262,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", @@ -376,6 +391,10 @@ public static Dictionary GetDictionary(string code) ["Settings_ApiKeys_ConfirmAuth"] = "I've Authorized It - Finish Connecting", ["Settings_ApiKeys_AccountInfo"] = "Scrobbling requires both an API key + shared secret (above) AND a connected account. Get your key/secret at last.fm/api/account/create.", ["Settings_ApiKeys_SecurityNotice"] = "Keys are stored encrypted with AES-256-GCM in keys.enc and never appear in logs or version control.", + ["Settings_ApiKeys_WeakerStorageLinux"] = "Warning: API keys are protected with a weaker machine-derived key. Fix: install libsecret-tools (Debian/Ubuntu) or libsecret (Fedora), unlock or start GNOME Keyring or KWallet, then restart NullWave.", + ["Settings_ApiKeys_WeakerStorageWindows"] = "Warning: API keys are protected with a weaker machine-derived key. Fix: sign in to your Windows user profile and restart NullWave to restore DPAPI protection.", + ["Settings_ApiKeys_WeakerStorageOther"] = "Warning: API keys are protected with a weaker machine-derived key. Fix: enable a supported OS secure-key service before saving keys.", + ["Settings_ApiKeys_SecretServiceClearFailed"] = "API keys were deleted locally, but Secret Service cleanup failed. Unlock or start your keyring and try again.", ["Settings_ApiKeys_Save"] = "Save API Keys", // Shared Dialog Buttons & Words @@ -1033,5 +1052,167 @@ public static Dictionary GetDictionary(string code) ["Profile_Badge_Bookworm_Desc"] = "50+ hours of audiobooks.", ["Player_Status_Buffering"] = "Buffering…", + + ["Settings_Nav_Sources"] = "Sources", + ["Settings_Sources_Header"] = "Content Sources", + ["Settings_Sources_Description"] = "Choose which engines power each feature. Single-source kinds use one provider at a time; multi-source kinds can use several in parallel.", + ["Settings_PageTitle_Sources"] = "Sources", + ["Settings_PageDesc_Sources"] = "Manage download, metadata, search, and AI providers", + + ["Settings_Nav_Downloads"] = "Downloads", + ["Settings_Nav_Integrations"] = "Integrations", + ["Settings_PageTitle_Downloads"] = "Downloads", + ["Settings_PageDesc_Downloads"] = "Manage the download folder, engine behaviour, and network settings.", + ["Settings_PageTitle_Integrations"] = "Integrations", + ["Settings_PageDesc_Integrations"] = "Configure core services and provider routing.", + + ["Settings_Downloads_Section_Terms"] = "DOWNLOADER TERMS", + ["Settings_Downloads_Terms_Title"] = "Copyright & Platform Terms", + ["Settings_Downloads_Terms_Desc"] = "Downloads require explicit acceptance of copyright and platform terms. NullWave does not host or provide any content.", + ["Settings_Downloads_Revoke_Button"] = "Revoke Consent", + ["Settings_Downloads_Section_Engine"] = "ENGINE & BEHAVIOUR", + + ["Settings_Downloads_TagWriteMode_Title"] = "Write Metadata Tags to Files", + ["Settings_Downloads_TagWriteMode_Desc"] = "Downloads Only: only files NullWave downloaded. Always: includes your imported music. Never: database only.", + ["Settings_Downloads_TagWriteMode_Never"] = "Never", + ["Settings_Downloads_TagWriteMode_DownloadsOnly"] = "Downloads only", + ["Settings_Downloads_TagWriteMode_Always"] = "Always", + ["Settings_Downloads_Consent_Accepted"] = "Accepted on {0} (v{1})", + ["Settings_Downloads_Consent_NotAccepted"] = "Not accepted", + ["Settings_Downloads_Consent_Revoked"] = "Downloader consent revoked. You will be prompted on the next download.", + ["Settings_Downloads_Picker_Title"] = "Select Download Directory", + + ["Settings_Integrations_CoreServices"] = "CORE SERVICES", + ["Settings_Integrations_ProviderRouting"] = "Provider Routing", + ["Settings_Integrations_ProviderRouting_Desc"] = "Choose and save the preferred provider for each task. Available services and choices depend on installed providers.", + ["Settings_Integrations_Single"] = "Single", + ["Settings_Integrations_Multi"] = "Multi", + ["Settings_Integrations_PairedWith"] = "Locked to: {0}", + ["Settings_Integrations_Group_Download"] = "Download", + ["Settings_Integrations_Group_Download_Desc"] = "Audio download engines", + ["Settings_Integrations_Group_Metadata"] = "Metadata", + ["Settings_Integrations_Group_Metadata_Desc"] = "Track information, tags, and artwork", + ["Settings_Integrations_Group_Search"] = "Search", + ["Settings_Integrations_Group_Search_Desc"] = "Multi-source track search", + ["Settings_Integrations_Group_Mood"] = "Mood / AI", + ["Settings_Integrations_Group_Mood_Desc"] = "AI ranking and mood analysis", + ["Settings_Integrations_Group_Weather"] = "Weather", + ["Settings_Integrations_Group_Weather_Desc"] = "Weather data for mood playlists", + ["Settings_Integrations_Group_Radio"] = "Radio", + ["Settings_Integrations_Group_Radio_Desc"] = "Radio station directories", + ["Settings_Integrations_Group_PlaylistImport"] = "Playlist Import", + ["Settings_Integrations_Group_PlaylistImport_Desc"] = "M3U and playlist file import", + ["Settings_Integrations_Provider_YtDlp"] = "yt-dlp", + ["Settings_Integrations_Provider_LastFm"] = "Last.fm", + ["Settings_Integrations_Provider_OpenWeather"] = "OpenWeather", + ["Settings_Integrations_Provider_Ollama"] = "Ollama Local AI", + ["Settings_Integrations_Status_Available"] = "Available", + ["Settings_Integrations_Status_Loading"] = "Loading...", + ["Settings_Integrations_Status_Error"] = "Error", + ["Settings_Integrations_Status_Unavailable"] = "Not installed", + ["Settings_Integrations_Status_Disabled"] = "Disabled", + ["Settings_Integrations_Status_Unknown"] = "Unknown", + ["Settings_Integrations_Plugin_YtDlp_Name"] = "yt-dlp Downloader", + ["Settings_Integrations_Plugin_YtDlp_Desc"] = "Downloads audio from YouTube, SoundCloud, and other supported sites", + ["Settings_Integrations_Plugin_LastFm_Name"] = "Last.fm", + ["Settings_Integrations_Plugin_LastFm_Desc"] = "Metadata enrichment, artist bios, and scrobbling", + ["Settings_Integrations_Plugin_OpenWeather_Name"] = "OpenWeather", + ["Settings_Integrations_Plugin_OpenWeather_Desc"] = "Fetches local weather for AI mood playlists", + ["Settings_Integrations_Plugin_Ollama_Name"] = "Ollama Local AI", + ["Settings_Integrations_Plugin_Ollama_Desc"] = "On-device AI for smart shuffle and mood playlist ranking", + ["Settings_Integrations_Plugin_Connected"] = "{0} connected successfully.", + ["Settings_Integrations_Plugin_Failed"] = "{0} failed to connect - check configuration.", + ["Settings_Integrations_Plugin_Unavailable"] = "{0} is unavailable right now.", + + ["Settings_Maintenance_Diagnostics"] = "DIAGNOSTICS", + ["Settings_Maintenance_VerboseLogging"] = "Verbose Logging", + ["Settings_Maintenance_VerboseLogging_Tooltip"] = "Enables debug-level logging for troubleshooting.", + ["Settings_Maintenance_DataFolders"] = "DATA & FOLDERS", + ["Settings_Maintenance_SearchIndex_Title"] = "Rebuild Search Index", + ["Settings_Maintenance_SearchIndex_Tooltip"] = "Rebuilds the full-text index if search results are missing or incorrect.", + ["Settings_Maintenance_SearchIndex_Desc"] = "Re-indexes titles, artists, albums, and tags for global search.", + ["Settings_Maintenance_SearchIndex_Button"] = "Rebuild Index", + ["Settings_Maintenance_Status_ClearingThumbnails"] = "Clearing thumbnails...", + ["Settings_Maintenance_Status_ScanningPaths"] = "Scanning file paths...", + ["Settings_Maintenance_Status_ScanningDownloads"] = "Scanning download folder...", + ["Settings_Maintenance_Status_ResyncStarting"] = "Clearing cached tags - re-sync starting...", + ["Settings_Maintenance_Status_OptimizingDatabase"] = "Optimizing database...", + ["Settings_Maintenance_Status_CheckingLinks"] = "Checking file links against embedded metadata...", + ["Settings_Maintenance_Status_CleaningTitles"] = "Re-parsing track titles for embedded artist names...", + ["Settings_Maintenance_Status_ScanningDuplicates"] = "Scanning for duplicate tracks...", + ["Settings_Maintenance_Status_RemovingDuplicates"] = "Removing duplicate tracks...", + ["Settings_Maintenance_Status_ScanningFiles"] = "Scanning files...", + ["Settings_Maintenance_Status_SyncingFiles"] = "Syncing files...", + ["Settings_Maintenance_Status_DedupePreview"] = "Found {0} duplicate group(s) ({1} extra track(s) would be removed) out of {2} scanned. Click Remove to clean up.", + ["Settings_Maintenance_Status_DedupeComplete"] = "✓ Removed {0} duplicate track(s) across {1} group(s).", + ["Settings_Maintenance_Status_VacuumComplete"] = "✓ Optimized: {0}KB → {1}KB ({2}KB reclaimed)", + ["Settings_Maintenance_Status_VacuumAlreadyOptimal"] = "✓ Database already optimal ({0}KB)", + ["Settings_Maintenance_Status_SearchIndexComplete"] = "Search index rebuilt successfully.", + ["Settings_Maintenance_Status_SearchIndexFailed"] = "Search index rebuild failed.", + ["Settings_Maintenance_Status_VerifyLinksClean"] = "✓ Checked {0} linked track(s) — no mismatches found.", + ["Settings_Maintenance_Status_VerifyLinksMismatch"] = "⚠ Checked {0} track(s) — found {1} possible mis-link(s). See logs for details.", + ["Settings_Maintenance_Status_NoTitlesToClean"] = "No titles needed cleaning.", + ["Settings_Maintenance_Status_TitlesCleaned"] = "✓ Cleaned {0} track title(s)/artist(s). Spot-check multi-dash titles for accuracy.", + ["Settings_Maintenance_Status_NoDuplicateArtists"] = "No duplicate artist names found.", + ["Settings_Maintenance_Status_ArtistsMerged"] = "Merged {0} artist group(s), updated {1} track(s).", + ["Settings_Maintenance_Status_SyncPreview"] = "Preview: {0} file(s) would be retagged, {1} renamed (of {2} scanned).", + ["Settings_Maintenance_Status_SyncComplete"] = "✓ Synced {0} file(s): {1} retagged, {2} renamed, {3} failed.", + ["Settings_Maintenance_Status_ThumbnailsCleared"] = "Cleared {0} thumbnails - re-fetching in background...", + ["Settings_Maintenance_Status_DurationsUpdated"] = "Updated duration for {0} track(s).", + ["Settings_Maintenance_Status_PathsValid"] = "✓ All {0} file paths are valid.", + ["Settings_Maintenance_Status_PathsMissing"] = "Found {0} missing file(s) - {1} path(s) cleared. Auto-relinked {2} file(s)!", + ["Settings_Maintenance_Status_NoReimportMatches"] = "No new file matches found.", + ["Settings_Maintenance_Status_ReimportComplete"] = "✓ Re-linked {0} track(s).", + ["Settings_Maintenance_Status_ResyncComplete"] = "✓ Cleared tags for {0} track(s) - re-sync running.", + ["Settings_Maintenance_Status_OperationFailed"] = "✗ {0} failed: {1}", + ["Settings_Maintenance_Status_BackupComplete"] = "✓ Database backed up successfully to {0}.", + ["Settings_Maintenance_Status_BackupFailed"] = "✗ Backup failed: {0}", + ["Settings_Maintenance_Status_PreferencesReset"] = "Preferences reset to defaults. Please restart NullWave to apply.", + ["Settings_Maintenance_Status_RestoreCancelled"] = "Restore cancelled.", + ["Settings_Maintenance_Status_RestoreStaged"] = "Backup staged! Please restart NullWave to apply.", + ["Settings_Maintenance_Backup_Picker_Title"] = "Backup Library Database", + ["Settings_Maintenance_Backup_FileType"] = "SQLite Database", + ["Settings_Maintenance_Restore_Picker_Title"] = "Select Database Backup (.db)", + ["Settings_Maintenance_Restore_FileType"] = "SQLite Backup", + ["Settings_Maintenance_Task_Previewing"] = "Previewing {0}", + ["Settings_Maintenance_Task_Running"] = "Running {0}", + ["Settings_Maintenance_Task_Processing"] = "Processing...", + ["Settings_Maintenance_Task_Failed"] = "{0} failed.", + ["Settings_Maintenance_Task_SweepOrphanedFiles"] = "orphan sweep", + ["Settings_Maintenance_Task_VacuumDatabase"] = "database optimization", + ["Settings_Maintenance_Task_RebuildSearchIndex"] = "search index rebuild", + ["Settings_Maintenance_Task_VerifyLinks"] = "file link verification", + ["Settings_Maintenance_Task_RemoveDuplicates"] = "duplicate cleanup", + ["Settings_Maintenance_Task_ForceCleanTitles"] = "title cleanup", + ["Settings_Maintenance_Task_MergeSimilarArtists"] = "artist merge", + ["Settings_Maintenance_Task_ClearThumbnails"] = "thumbnail cleanup", + ["Settings_Maintenance_Task_RepairPaths"] = "file path repair", + ["Settings_Maintenance_Task_ReimportAssets"] = "file reimport", + ["Settings_Maintenance_Task_ForceMetaResync"] = "metadata re-sync", + ["Settings_Maintenance_Task_SyncFiles"] = "file sync", + ["Settings_Maintenance_Task_BackfillDurations"] = "duration backfill", + ["Settings_Maintenance_Task_RestoreDatabase"] = "database restore", + ["Settings_Maintenance_Task_BackupDatabase"] = "database backup", + + ["Settings_General_Experimental_Section"] = "EXPERIMENTAL FEATURES", + ["Settings_General_Experimental_Desc"] = "Experimental settings may change or be removed. Only features currently available in the app are described here.", + ["Settings_General_Experimental_Title"] = "Enable Experimental Features", + ["Settings_General_Experimental_ToggleDesc"] = "Shows settings marked Experimental that are currently available.", + ["Settings_General_ForceAll_Title"] = "Force All Features", + ["Settings_General_ForceAll_Desc"] = "Show all advanced feature sections, including those normally hidden behind the Experimental Features toggle.", + + // Integrations Hub + ["Settings_Plugins_Installed"] = "Installed", + ["Settings_Plugins_Store"] = "Store", + ["Settings_Plugins_Search"] = "Search plugins…", + ["Settings_Plugins_All"] = "All", + ["Settings_Plugins_BuiltIn"] = "built-in", + ["Settings_Plugins_NeedsSetup"] = "Needs setup", + ["Settings_Plugins_SetUp"] = "Set up…", + ["Settings_Plugins_Install"] = "Install", + ["Settings_Plugins_InstalledLabel"] = "Installed", + ["Settings_Plugins_ComingSoon"] = "Coming soon", + ["Settings_Plugins_NoResults"] = "No plugins match your search.", + ["Settings_Plugins_StorePreview"] = "Store preview: plugin installation arrives in a later release. These are the planned plugins.", }; } \ No newline at end of file diff --git a/Helpers/Localization/Locales.ru.cs b/Helpers/Localization/Locales.ru.cs index c2b21f0..63c1673 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"] = "ЗАГРУЗКА", @@ -236,11 +241,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"] = "Найти треки с одинаковым названием и исполнителем, оставив лучшую версию", @@ -355,6 +370,10 @@ public static partial class Locales ["Settings_ApiKeys_ConfirmAuth"] = "Я подтвердил доступ - Завершить подключение", ["Settings_ApiKeys_AccountInfo"] = "Для скробблинга требуется как API key + shared secret (выше), так и подключенный аккаунт. Получите ключ и секрет на last.fm/api/account/create.", ["Settings_ApiKeys_SecurityNotice"] = "Ключи хранятся в зашифрованном виде с использованием AES-256-GCM в файле keys.enc и никогда не попадают в логи или системы контроля версий.", + ["Settings_ApiKeys_WeakerStorageLinux"] = "Предупреждение: ключи API защищены более слабым ключом, производным от данных устройства. Исправление: установите libsecret-tools (Debian/Ubuntu) или libsecret (Fedora), разблокируйте или запустите GNOME Keyring либо KWallet, затем перезапустите NullWave.", + ["Settings_ApiKeys_WeakerStorageWindows"] = "Предупреждение: ключи API защищены более слабым ключом, производным от данных устройства. Исправление: войдите в профиль Windows и перезапустите NullWave, чтобы восстановить защиту DPAPI.", + ["Settings_ApiKeys_WeakerStorageOther"] = "Предупреждение: ключи API защищены более слабым ключом, производным от данных устройства. Исправление: включите поддерживаемую ОС-службу безопасного хранения ключей перед их сохранением.", + ["Settings_ApiKeys_SecretServiceClearFailed"] = "Ключи API удалены локально, но очистить Secret Service не удалось. Разблокируйте или запустите связку ключей и повторите попытку.", ["Settings_ApiKeys_Save"] = "Сохранить ключи API", // Shared Dialog Buttons & Words @@ -1016,5 +1035,167 @@ public static partial class Locales ["MainWindow_Startup_Loading"] = "Пробуждаем библиотеку…", ["Player_Status_Buffering"] = "Буферизация…", + + ["Settings_Nav_Sources"] = "Источники", + ["Settings_Sources_Header"] = "Источники контента", + ["Settings_Sources_Description"] = "Выберите, какие движки обеспечивают каждую функцию. Одиночные источники используют один провайдер; множественные могут использовать несколько параллельно.", + ["Settings_PageTitle_Sources"] = "Источники", + ["Settings_PageDesc_Sources"] = "Управление провайдерами загрузки, метаданных, поиска и ИИ", + + ["Settings_Nav_Downloads"] = "Загрузки", + ["Settings_Nav_Integrations"] = "Интеграции", + ["Settings_PageTitle_Downloads"] = "Загрузки", + ["Settings_PageDesc_Downloads"] = "Управление папкой загрузки, поведением движка и настройками сети.", + ["Settings_PageTitle_Integrations"] = "Интеграции", + ["Settings_PageDesc_Integrations"] = "Настройка основных сервисов и маршрутизации провайдеров.", + + ["Settings_Downloads_Section_Terms"] = "УСЛОВИЯ ЗАГРУЗКИ", + ["Settings_Downloads_Terms_Title"] = "Авторские права и условия платформ", + ["Settings_Downloads_Terms_Desc"] = "Загрузки требуют явного согласия с условиями авторских прав и платформ. NullWave не размещает и не предоставляет контент.", + ["Settings_Downloads_Revoke_Button"] = "Отозвать согласие", + ["Settings_Downloads_Section_Engine"] = "ДВИЖОК И ПОВЕДЕНИЕ", + + ["Settings_Downloads_TagWriteMode_Title"] = "Записывать метаданные в файлы", + ["Settings_Downloads_TagWriteMode_Desc"] = "Только загрузки: лишь файлы, скачанные NullWave. Всегда: включая импортированную музыку. Никогда: только в базе данных.", + ["Settings_Downloads_TagWriteMode_Never"] = "Никогда", + ["Settings_Downloads_TagWriteMode_DownloadsOnly"] = "Только загрузки", + ["Settings_Downloads_TagWriteMode_Always"] = "Всегда", + ["Settings_Downloads_Consent_Accepted"] = "Согласие принято {0} (версия {1})", + ["Settings_Downloads_Consent_NotAccepted"] = "Согласие не принято", + ["Settings_Downloads_Consent_Revoked"] = "Согласие на загрузки отозвано. Запрос появится при следующей загрузке.", + ["Settings_Downloads_Picker_Title"] = "Выберите папку загрузок", + + ["Settings_Integrations_CoreServices"] = "ОСНОВНЫЕ СЕРВИСЫ", + ["Settings_Integrations_ProviderRouting"] = "Маршрутизация провайдеров", + ["Settings_Integrations_ProviderRouting_Desc"] = "Выберите и сохраните предпочтительного провайдера для каждой задачи. Доступные сервисы и варианты зависят от установленных провайдеров.", + ["Settings_Integrations_Single"] = "Один", + ["Settings_Integrations_Multi"] = "Несколько", + ["Settings_Integrations_PairedWith"] = "Закреплено за: {0}", + ["Settings_Integrations_Group_Download"] = "Загрузка", + ["Settings_Integrations_Group_Download_Desc"] = "Движки загрузки аудио", + ["Settings_Integrations_Group_Metadata"] = "Метаданные", + ["Settings_Integrations_Group_Metadata_Desc"] = "Данные треков, теги и обложки", + ["Settings_Integrations_Group_Search"] = "Поиск", + ["Settings_Integrations_Group_Search_Desc"] = "Поиск треков в нескольких источниках", + ["Settings_Integrations_Group_Mood"] = "Настроение / ИИ", + ["Settings_Integrations_Group_Mood_Desc"] = "Ранжирование ИИ и анализ настроения", + ["Settings_Integrations_Group_Weather"] = "Погода", + ["Settings_Integrations_Group_Weather_Desc"] = "Данные о погоде для плейлистов по настроению", + ["Settings_Integrations_Group_Radio"] = "Радио", + ["Settings_Integrations_Group_Radio_Desc"] = "Каталоги радиостанций", + ["Settings_Integrations_Group_PlaylistImport"] = "Импорт плейлистов", + ["Settings_Integrations_Group_PlaylistImport_Desc"] = "Импорт M3U и файлов плейлистов", + ["Settings_Integrations_Provider_YtDlp"] = "yt-dlp", + ["Settings_Integrations_Provider_LastFm"] = "Last.fm", + ["Settings_Integrations_Provider_OpenWeather"] = "OpenWeather", + ["Settings_Integrations_Provider_Ollama"] = "Локальный ИИ Ollama", + ["Settings_Integrations_Status_Available"] = "Доступен", + ["Settings_Integrations_Status_Loading"] = "Загрузка...", + ["Settings_Integrations_Status_Error"] = "Ошибка", + ["Settings_Integrations_Status_Unavailable"] = "Не установлен", + ["Settings_Integrations_Status_Disabled"] = "Отключен", + ["Settings_Integrations_Status_Unknown"] = "Неизвестно", + ["Settings_Integrations_Plugin_YtDlp_Name"] = "Загрузчик yt-dlp", + ["Settings_Integrations_Plugin_YtDlp_Desc"] = "Загрузка аудио с YouTube, SoundCloud и других поддерживаемых сайтов", + ["Settings_Integrations_Plugin_LastFm_Name"] = "Last.fm", + ["Settings_Integrations_Plugin_LastFm_Desc"] = "Обогащение метаданных, биографии исполнителей и скробблинг", + ["Settings_Integrations_Plugin_OpenWeather_Name"] = "OpenWeather", + ["Settings_Integrations_Plugin_OpenWeather_Desc"] = "Получение местной погоды для ИИ-плейлистов по настроению", + ["Settings_Integrations_Plugin_Ollama_Name"] = "Локальный ИИ Ollama", + ["Settings_Integrations_Plugin_Ollama_Desc"] = "Локальный ИИ для умного перемешивания и ранжирования плейлистов по настроению", + ["Settings_Integrations_Plugin_Connected"] = "{0}: подключение выполнено.", + ["Settings_Integrations_Plugin_Failed"] = "{0}: не удалось подключиться. Проверьте настройки.", + ["Settings_Integrations_Plugin_Unavailable"] = "{0} сейчас недоступен.", + + ["Settings_Maintenance_Diagnostics"] = "ДИАГНОСТИКА", + ["Settings_Maintenance_VerboseLogging"] = "Подробное журналирование", + ["Settings_Maintenance_VerboseLogging_Tooltip"] = "Включает журналы уровня отладки для устранения неполадок.", + ["Settings_Maintenance_DataFolders"] = "ДАННЫЕ И ПАПКИ", + ["Settings_Maintenance_SearchIndex_Title"] = "Перестроить поисковый индекс", + ["Settings_Maintenance_SearchIndex_Tooltip"] = "Перестраивает полнотекстовый индекс, если результаты поиска отсутствуют или неверны.", + ["Settings_Maintenance_SearchIndex_Desc"] = "Повторно индексирует названия, исполнителей, альбомы и теги для глобального поиска.", + ["Settings_Maintenance_SearchIndex_Button"] = "Перестроить индекс", + ["Settings_Maintenance_Status_ClearingThumbnails"] = "Очистка миниатюр...", + ["Settings_Maintenance_Status_ScanningPaths"] = "Проверка путей к файлам...", + ["Settings_Maintenance_Status_ScanningDownloads"] = "Сканирование папки загрузок...", + ["Settings_Maintenance_Status_ResyncStarting"] = "Очистка кэшированных тегов — повторная синхронизация начинается...", + ["Settings_Maintenance_Status_OptimizingDatabase"] = "Оптимизация базы данных...", + ["Settings_Maintenance_Status_CheckingLinks"] = "Сверка ссылок на файлы со встроенными метаданными...", + ["Settings_Maintenance_Status_CleaningTitles"] = "Поиск имен исполнителей во встроенных названиях треков...", + ["Settings_Maintenance_Status_ScanningDuplicates"] = "Поиск дубликатов треков...", + ["Settings_Maintenance_Status_RemovingDuplicates"] = "Удаление дубликатов треков...", + ["Settings_Maintenance_Status_ScanningFiles"] = "Сканирование файлов...", + ["Settings_Maintenance_Status_SyncingFiles"] = "Синхронизация файлов...", + ["Settings_Maintenance_Status_DedupePreview"] = "Найдено групп дубликатов: {0} (лишних треков для удаления: {1}) из {2} проверенных. Нажмите «Удалить», чтобы очистить.", + ["Settings_Maintenance_Status_DedupeComplete"] = "✓ Удалено треков-дубликатов: {0} в группах: {1}.", + ["Settings_Maintenance_Status_VacuumComplete"] = "✓ Оптимизировано: {0} КБ → {1} КБ (освобождено {2} КБ)", + ["Settings_Maintenance_Status_VacuumAlreadyOptimal"] = "✓ База данных уже оптимальна ({0} КБ)", + ["Settings_Maintenance_Status_SearchIndexComplete"] = "Поисковый индекс успешно перестроен.", + ["Settings_Maintenance_Status_SearchIndexFailed"] = "Не удалось перестроить поисковый индекс.", + ["Settings_Maintenance_Status_VerifyLinksClean"] = "✓ Проверено связанных треков: {0}; несоответствий не найдено.", + ["Settings_Maintenance_Status_VerifyLinksMismatch"] = "⚠ Проверено треков: {0}; возможных ошибочных ссылок: {1}. Подробности в журналах.", + ["Settings_Maintenance_Status_NoTitlesToClean"] = "Нет названий, требующих очистки.", + ["Settings_Maintenance_Status_TitlesCleaned"] = "✓ Очищены названия или исполнители у треков: {0}. Проверьте названия с несколькими дефисами.", + ["Settings_Maintenance_Status_NoDuplicateArtists"] = "Дубликаты имен исполнителей не найдены.", + ["Settings_Maintenance_Status_ArtistsMerged"] = "Объединено групп исполнителей: {0}; обновлено треков: {1}.", + ["Settings_Maintenance_Status_SyncPreview"] = "Предпросмотр: будут обновлены теги у файлов: {0}, переименованы: {1} (проверено: {2}).", + ["Settings_Maintenance_Status_SyncComplete"] = "✓ Синхронизировано файлов: {0}; обновлены теги: {1}, переименованы: {2}, ошибок: {3}.", + ["Settings_Maintenance_Status_ThumbnailsCleared"] = "Очищено миниатюр: {0}; повторная загрузка начнется в фоне...", + ["Settings_Maintenance_Status_DurationsUpdated"] = "Обновлена длительность треков: {0}.", + ["Settings_Maintenance_Status_PathsValid"] = "✓ Все пути к файлам действительны: {0}.", + ["Settings_Maintenance_Status_PathsMissing"] = "Не найдены файлы: {0}; очищено путей: {1}; повторно привязано файлов: {2}.", + ["Settings_Maintenance_Status_NoReimportMatches"] = "Новых совпадений файлов не найдено.", + ["Settings_Maintenance_Status_ReimportComplete"] = "✓ Повторно привязано треков: {0}.", + ["Settings_Maintenance_Status_ResyncComplete"] = "✓ Очищены теги у треков: {0}; повторная синхронизация выполняется.", + ["Settings_Maintenance_Status_OperationFailed"] = "✗ Не удалось выполнить «{0}»: {1}", + ["Settings_Maintenance_Status_BackupComplete"] = "✓ Резервная копия базы данных сохранена: {0}.", + ["Settings_Maintenance_Status_BackupFailed"] = "✗ Не удалось создать резервную копию: {0}", + ["Settings_Maintenance_Status_PreferencesReset"] = "Настройки сброшены. Перезапустите NullWave, чтобы применить изменения.", + ["Settings_Maintenance_Status_RestoreCancelled"] = "Восстановление отменено.", + ["Settings_Maintenance_Status_RestoreStaged"] = "Резервная копия подготовлена. Перезапустите NullWave, чтобы применить изменения.", + ["Settings_Maintenance_Backup_Picker_Title"] = "Резервная копия базы данных библиотеки", + ["Settings_Maintenance_Backup_FileType"] = "База данных SQLite", + ["Settings_Maintenance_Restore_Picker_Title"] = "Выберите резервную копию базы данных (.db)", + ["Settings_Maintenance_Restore_FileType"] = "Резервная копия SQLite", + ["Settings_Maintenance_Task_Previewing"] = "Предпросмотр: {0}", + ["Settings_Maintenance_Task_Running"] = "Выполняется: {0}", + ["Settings_Maintenance_Task_Processing"] = "Обработка...", + ["Settings_Maintenance_Task_Failed"] = "Не удалось выполнить: {0}.", + ["Settings_Maintenance_Task_SweepOrphanedFiles"] = "перемещение осиротевших файлов", + ["Settings_Maintenance_Task_VacuumDatabase"] = "оптимизация базы данных", + ["Settings_Maintenance_Task_RebuildSearchIndex"] = "перестроение поискового индекса", + ["Settings_Maintenance_Task_VerifyLinks"] = "проверка ссылок на файлы", + ["Settings_Maintenance_Task_RemoveDuplicates"] = "удаление дубликатов", + ["Settings_Maintenance_Task_ForceCleanTitles"] = "очистка названий", + ["Settings_Maintenance_Task_MergeSimilarArtists"] = "объединение исполнителей", + ["Settings_Maintenance_Task_ClearThumbnails"] = "очистка миниатюр", + ["Settings_Maintenance_Task_RepairPaths"] = "исправление путей к файлам", + ["Settings_Maintenance_Task_ReimportAssets"] = "повторный импорт файлов", + ["Settings_Maintenance_Task_ForceMetaResync"] = "повторная синхронизация метаданных", + ["Settings_Maintenance_Task_SyncFiles"] = "синхронизация файлов", + ["Settings_Maintenance_Task_BackfillDurations"] = "заполнение длительности треков", + ["Settings_Maintenance_Task_RestoreDatabase"] = "восстановление базы данных", + ["Settings_Maintenance_Task_BackupDatabase"] = "резервное копирование базы данных", + + ["Settings_General_Experimental_Section"] = "ЭКСПЕРИМЕНТАЛЬНЫЕ ФУНКЦИИ", + ["Settings_General_Experimental_Desc"] = "Экспериментальные настройки могут измениться или быть удалены. Здесь описаны только функции, доступные в приложении сейчас.", + ["Settings_General_Experimental_Title"] = "Включить экспериментальные функции", + ["Settings_General_Experimental_ToggleDesc"] = "Показывает доступные сейчас настройки с пометкой «Экспериментально».", + ["Settings_General_ForceAll_Title"] = "Показать все функции", + ["Settings_General_ForceAll_Desc"] = "Показывает все разделы расширенных функций, включая скрытые настройкой экспериментальных функций.", + + // Integrations Hub + ["Settings_Plugins_Installed"] = "Установленные", + ["Settings_Plugins_Store"] = "Магазин", + ["Settings_Plugins_Search"] = "Поиск плагинов…", + ["Settings_Plugins_All"] = "Все", + ["Settings_Plugins_BuiltIn"] = "встроенный", + ["Settings_Plugins_NeedsSetup"] = "Требуется настройка", + ["Settings_Plugins_SetUp"] = "Настроить…", + ["Settings_Plugins_Install"] = "Установить", + ["Settings_Plugins_InstalledLabel"] = "Установлен", + ["Settings_Plugins_ComingSoon"] = "Скоро", + ["Settings_Plugins_NoResults"] = "Нет подходящих плагинов.", + ["Settings_Plugins_StorePreview"] = "Предпросмотр магазина: установка плагинов появится в одном из следующих релизов. Здесь показаны запланированные плагины.", }; } diff --git a/Helpers/NetworkHosts.cs b/Helpers/NetworkHosts.cs new file mode 100644 index 0000000..4b2347d --- /dev/null +++ b/Helpers/NetworkHosts.cs @@ -0,0 +1,27 @@ +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"), + // NEW: No-API-Key sources + new("bandcamp.com", "Bandcamp audio downloads and metadata", "yt-dlp"), + new("audiomack.com", "Audiomack audio downloads and metadata", "yt-dlp"), + new("archive.org", "Internet Archive audio downloads", "yt-dlp"), + new("mixcloud.com", "Mixcloud audio downloads", "yt-dlp"), + }; +} \ No newline at end of file diff --git a/Helpers/Paths/NullWavePaths.cs b/Helpers/Paths/NullWavePaths.cs index 15c7cdf..beea703 100644 --- a/Helpers/Paths/NullWavePaths.cs +++ b/Helpers/Paths/NullWavePaths.cs @@ -20,22 +20,41 @@ private static string? HomeOverride } } - public static string DataDir => HomeOverride ?? (IsWindows - ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "NullWave") - : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nullwave")); + public static string DataDir + { + get + { + if (HomeOverride is { } home) return home; +#if DEBUG + // SAFETY NET: In debug builds, default to a sandbox folder + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "NullWaveDev"); +#else + return IsWindows + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "NullWave") + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nullwave"); +#endif + } + } - public static string LegacyDataDir => HomeOverride is { } home - ? Path.Combine(home, "legacy-none") - : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nullwave"); + public static string LegacyDataDir + { + get + { + if (HomeOverride is { } home) return Path.Combine(home, "legacy-none"); +#if DEBUG + return Path.Combine(DataDir, "legacy-none"); // sandbox never touches real legacy data +#else + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nullwave"); +#endif + } + } public static string LogsDir => Path.Combine(DataDir, "logs"); public static string DownloadsDir => Path.Combine(DataDir, "downloads"); public static string ArtCacheDir => Path.Combine(DataDir, "art"); public static string BackupsDir => Path.Combine(DataDir, "backups"); - public static string DatabasePath => Path.Combine(DataDir, "library.db"); public static string LegacyDatabasePath => Path.Combine(LegacyDataDir, "library.db"); - public static string KeyStorePath => Path.Combine(DataDir, "keys.enc"); public static string ProfilePath => Path.Combine(DataDir, "profile.json"); public static string AvatarPath => Path.Combine(DataDir, "avatar.png"); diff --git a/Helpers/Platform/ProcessRunner.cs b/Helpers/Platform/ProcessRunner.cs index 1b0f025..0b3a93d 100644 --- a/Helpers/Platform/ProcessRunner.cs +++ b/Helpers/Platform/ProcessRunner.cs @@ -47,6 +47,8 @@ private static async Task RunCoreAsync( { RedirectStandardOutput = true, RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, UseShellExecute = false, CreateNoWindow = true }; diff --git a/Helpers/Platform/ShellHelper.cs b/Helpers/Platform/ShellHelper.cs index e35acd0..57e3517 100644 --- a/Helpers/Platform/ShellHelper.cs +++ b/Helpers/Platform/ShellHelper.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; using Serilog; @@ -31,6 +32,11 @@ public static void OpenFolder(string path) } } + private static readonly HashSet AllowedMediaExts = new(StringComparer.OrdinalIgnoreCase) + { + ".mp3", ".m4a", ".flac", ".wav", ".ogg", ".opus", ".aac", ".wma", ".mp4", ".webm", ".mkv" + }; + /// /// Opens a file with the system's default application. /// @@ -38,6 +44,13 @@ public static void OpenFile(string path) { if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return; + // SECURITY: Prevent arbitrary code execution from imported playlists + var ext = Path.GetExtension(path); + if (!AllowedMediaExts.Contains(ext)) + { + Log.Warning("[ShellHelper] Blocked execution of non-media file: {Path}", path); + return; + } try { Launch(path, isUrl: false, isFolder: false); diff --git a/Helpers/RecycleBin.cs b/Helpers/RecycleBin.cs new file mode 100644 index 0000000..3a1bca3 --- /dev/null +++ b/Helpers/RecycleBin.cs @@ -0,0 +1,224 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using Serilog; + +namespace NullWave.Helpers; + +public static class RecycleBin +{ + public static bool SendToRecycleBin(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + Log.Warning("[RecycleBin] Cannot recycle null or empty path"); + return false; + } + + if (!File.Exists(filePath)) + { + Log.Debug("[RecycleBin] File does not exist, nothing to recycle: {Path}", filePath); + return false; + } + + string fullPath; + try { fullPath = Path.GetFullPath(filePath); } + catch (Exception ex) { Log.Warning(ex, "[RecycleBin] Invalid path: {Path}", filePath); 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); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return SendToRecycleBinLinux(fullPath); + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return SendToRecycleBinMacOS(fullPath); + return FallbackRecycle(fullPath); + } + catch (Exception ex) + { + Log.Error(ex, "[RecycleBin] Failed to recycle {Path}", fullPath); + return false; + } + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern int SHFileOperation(ref SHFILEOPSTRUCT FileOp); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct SHFILEOPSTRUCT + { + public IntPtr hwnd; + public int wFunc; + public string pFrom; + public string? pTo; + public int fFlags; + public bool fAnyOperationsAborted; + public IntPtr hNameMappings; + public string? lpszProgressTitle; + } + + private const int FO_DELETE = 0x0003; + private const int FOF_ALLOWUNDO = 0x0040; + private const int FOF_NOCONFIRMATION = 0x0010; + private const int FOF_SILENT = 0x0004; + + private static bool SendToRecycleBinWindows(string filePath) + { + try + { + var fileOp = new SHFILEOPSTRUCT + { + wFunc = FO_DELETE, + pFrom = filePath + '\0' + '\0', + pTo = null, + fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT, + fAnyOperationsAborted = false, + hNameMappings = IntPtr.Zero, + lpszProgressTitle = null + }; + + int result = SHFileOperation(ref fileOp); + if (result == 0 && !fileOp.fAnyOperationsAborted) + { + Log.Information("[RecycleBin] Successfully recycled (Windows): {Path}", filePath); + return true; + } + Log.Warning("[RecycleBin] Windows SHFileOperation failed with code {Code}", result); + return FallbackRecycle(filePath); + } + catch (Exception ex) + { + Log.Warning(ex, "[RecycleBin] Windows recycle failed, using fallback"); + return FallbackRecycle(filePath); + } + } + + private static bool SendToRecycleBinLinux(string filePath) + { + try + { + // ArgumentList avoids shell injection (titles with " ' ` $ etc). + if (RunProcess("gio", new[] { "trash", filePath }) == 0) + { + Log.Information("[RecycleBin] Successfully recycled (gio): {Path}", filePath); + return true; + } + if (RunProcess("kioclient5", new[] { "move", filePath, "trash:/" }) == 0) + { + Log.Information("[RecycleBin] Successfully recycled (KDE): {Path}", filePath); + return true; + } + 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); + } + catch (Exception ex) + { + Log.Warning(ex, "[RecycleBin] Linux recycle failed, using fallback"); + return FallbackRecycle(filePath); + } + } + + private static bool SendToRecycleBinMacOS(string filePath) + { + try + { + // 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); + } + catch (Exception ex) + { + Log.Warning(ex, "[RecycleBin] macOS recycle failed, using fallback"); + return FallbackRecycle(filePath); + } + } + + private static bool FallbackRecycle(string filePath) + { + try + { + var dir = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(dir)) + { + Log.Error("[RecycleBin] Cannot determine parent directory for {Path}", filePath); + return false; + } + + var trashDir = Path.Combine(dir, ".trash"); + Directory.CreateDirectory(trashDir); + + var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss_fff"); + var fileName = Path.GetFileName(filePath); + var trashPath = Path.Combine(trashDir, $"{timestamp}_{fileName}"); + + File.Move(filePath, trashPath); + Log.Information("[RecycleBin] Fallback recycle to {TrashPath}", trashPath); + return true; + } + catch (Exception ex) + { + Log.Error(ex, "[RecycleBin] Fallback recycle failed for {Path}", filePath); + return false; + } + } + + // Injection-safe: uses ArgumentList, never a single shell-parsed string. + private static int RunProcess(string fileName, string[] arguments) + { + try + { + var psi = new ProcessStartInfo + { + 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; + + // 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; + } + + // 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}", fileName); + return -1; + } + } +} \ No newline at end of file diff --git a/Helpers/SupportedSites.cs b/Helpers/SupportedSites.cs new file mode 100644 index 0000000..751d5d0 --- /dev/null +++ b/Helpers/SupportedSites.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Linq; +using NullWave.Models; + +namespace NullWave.Helpers; + +public sealed record SupportedSite(string Id, string DisplayName, TrackSource Source, string[] Hosts, bool IsYtDlpNative = true) +{ + public bool MatchesHost(string host) => Hosts.Any(h => host == h || host.EndsWith("." + h, System.StringComparison.Ordinal)); +} + +public static class SupportedSites +{ + public static readonly SupportedSite[] All = + { + new("youtube", "YouTube", TrackSource.YouTube, new[]{"youtube.com", "youtu.be", "music.youtube.com"}), + new("soundcloud", "SoundCloud", TrackSource.SoundCloud, new[]{"soundcloud.com", "snd.sc", "on.soundcloud.com"}), + new("spotify", "Spotify", TrackSource.Spotify, new[]{"spotify.com", "open.spotify.com"}, IsYtDlpNative: false), + new("lastfm", "Last.fm", TrackSource.LastFm, new[]{"last.fm"}, IsYtDlpNative: false), + new("bandcamp", "Bandcamp", TrackSource.Bandcamp, new[]{"bandcamp.com"}), + new("audiomack", "Audiomack", TrackSource.Audiomack, new[]{"audiomack.com"}), + new("archive", "Archive.org",TrackSource.ArchiveOrg, new[]{"archive.org"}), + new("mixcloud", "Mixcloud", TrackSource.Mixcloud, new[]{"mixcloud.com"}), + new("local", "Local", TrackSource.Local, System.Array.Empty(), IsYtDlpNative: false), + }; + + public static SupportedSite? FindBySource(TrackSource source) => All.FirstOrDefault(s => s.Source == source); + public static SupportedSite? FindByHost(string host) => All.FirstOrDefault(s => s.MatchesHost(host)); +} \ No newline at end of file diff --git a/Helpers/Text/TrackNormalizer.cs b/Helpers/Text/TrackNormalizer.cs index 37f00a5..12c87ee 100644 --- a/Helpers/Text/TrackNormalizer.cs +++ b/Helpers/Text/TrackNormalizer.cs @@ -17,7 +17,7 @@ public static partial class TrackNormalizer { // --- Regex patterns --- - [GeneratedRegex(@"[\(\[\{«「『][^\)\]\}»」』]*?\b(?:official|video|audio|music|lyric|lyrics|visualizer|clip|explicit|clean|hq|hd|4k|uncensored|download|caption|captions|cc|unreleased|spatial|atmos)s?\b[^\)\]\}»」』]*?[\)\]\}»」』]", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + [GeneratedRegex(@"[\(\[\{«「『][^\)\]\}»」』]*?\b(?:official|video|audio|music|lyric|lyrics|visualizer|clip|explicit|clean|hq|hd|4k|uncensored|download|caption|captions|cc|unreleased|spatial|atmos|официальн\w*|клип|премьера|аудио|видео|текст|лирика)s?\b[^\)\]\}»」』]*?[\)\]\}»」』]", RegexOptions.IgnoreCase | RegexOptions.Compiled)] private static partial Regex BracketGarbageRegex(); [GeneratedRegex(@"\b(official\s+video|official\s+music\s+video|official\s+audio|lyric\s+video|lyrics|official\s+visualizer|unreleased|explicit\s+version|clean\s+version)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)] @@ -320,11 +320,64 @@ private static IEnumerable ExtractContextTokens(string raw) } private static HashSet Tokens(string s) => - Regex.Matches(s.ToLowerInvariant(), @"[a-z0-9]+") + Regex.Matches(s.Normalize(NormalizationForm.FormKC).ToLowerInvariant(), @"[\p{L}\p{M}\p{N}]+") .Select(m => m.Value) .Where(w => w.Length > 1) .ToHashSet(); + /// Search fold: lowercase, strips accents (é→e, ü→u, ё→е) but keeps Cyrillic й. + public static string SearchKey(string? s) + { + if (string.IsNullOrEmpty(s)) return string.Empty; + var d = s.Normalize(NormalizationForm.FormD); + var sb = new StringBuilder(d.Length); + char prev = '\0'; + foreach (var c in d) + { + if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) + { + bool cyrillicBase = prev >= '\u0400' && prev <= '\u04FF'; + if (!cyrillicBase || c == '\u0308') continue; + } + sb.Append(c); + prev = c; + } + return sb.ToString().Normalize(NormalizationForm.FormC).ToLowerInvariant(); + } + + /// Like TrySplit, but uses the artist we already know (yt-dlp channel) to resolve + /// "Song – Artist" vs "Artist – Song". + public static (string Artist, string Title)? SplitWithKnownArtist(string rawTitle, string? knownArtist) + { + var split = TrySplit(rawTitle); + if (split is null || IsPlaceholderArtist(knownArtist)) return split; + + var known = NormalizeForCompare(CleanArtist(knownArtist!)); + if (known.Length == 0) return split; + + bool Same(string s) + { + var n = NormalizeForCompare(s); + if (n.Length == 0) return false; + if (n == known) return true; + var (shorter, longer) = n.Length <= known.Length ? (n, known) : (known, n); + return shorter.Length >= 4 && longer.Contains(shorter, StringComparison.Ordinal); + } + + var (artist, title) = split.Value; + if (!Same(artist) && Same(title)) return (title, artist); // "Song – Artist" -> swap! + return split; + } + + /// Central fallback: tries to split, otherwise uses the known artist and cleans the title. + public static (string Artist, string Title) ResolveArtistTitle(string rawTitle, string? knownArtist) + { + if (SplitWithKnownArtist(rawTitle, knownArtist) is { } s) return s; + var artist = IsPlaceholderArtist(knownArtist) ? "Unknown" : CleanArtist(knownArtist!); + var t = CleanTitle(rawTitle); + return (artist, string.IsNullOrWhiteSpace(t) ? rawTitle.Trim() : t); + } + public static bool HasExoticSeparator(string s) => s.Contains('~') || s.Contains('∞') || s.Contains('·') || s.Contains('•') || s.Contains('⧸') || s.Contains("///"); } \ No newline at end of file diff --git a/Helpers/Url/SourceDetector.cs b/Helpers/Url/SourceDetector.cs index 1dfaa9c..d6c936b 100644 --- a/Helpers/Url/SourceDetector.cs +++ b/Helpers/Url/SourceDetector.cs @@ -1,34 +1,68 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; using NullWave.Models; +using NullWave.Services; namespace NullWave.Helpers; public static class SourceDetector { - // Matches hosts pasted without a scheme, e.g. "youtu.be/abc" or "soundcloud.com/a/b". private static readonly Regex BareHost = new( @"^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}(/|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - /// Detects the source from the URL's host, not from any substring of the text. + private static readonly Regex MarkdownLink = new(@"\]\((https?://[^)\s]+)\)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly HashSet MixcloudReservedUsers = new(StringComparer.OrdinalIgnoreCase) + { "discover", "search", "live", "categories", "select", "tag" }; + + private static readonly HashSet MixcloudReservedPages = new(StringComparer.OrdinalIgnoreCase) + { "uploads", "favorites", "listens", "playlists", "stream", "followers", "following", "reposts", "activity" }; + + /// + /// Cleans up pasted markdown links, angle brackets, and adds https:// to known bare hosts. + /// + public static string NormalizeUrl(string? input, bool keepListParam = false) + { + var text = (input ?? "").Trim(); + var md = MarkdownLink.Match(text); + if (md.Success) text = md.Groups[1].Value; + + text = text.Trim('<', '>', '"', '\''); + + if (!text.Contains("://") && Detect(text) is not (TrackSource.Unknown or TrackSource.Local)) + text = "https://" + text; + + // Only strip list= if keepListParam is false + if (!keepListParam && + text.Contains("v=", StringComparison.OrdinalIgnoreCase) + && text.Contains("list=", StringComparison.OrdinalIgnoreCase) + && !text.Contains("/playlist", StringComparison.OrdinalIgnoreCase) + && Detect(text) == TrackSource.YouTube) + { + text = DownloadService.StripListParam(text); + } + + return text; + } + public static TrackSource Detect(string url) { var host = GetHost(url); if (host is null) return TrackSource.Unknown; - if (HostIs(host, "youtube.com") || HostIs(host, "youtu.be")) return TrackSource.YouTube; - if (HostIs(host, "spotify.com")) return TrackSource.Spotify; - if (HostIs(host, "soundcloud.com") || HostIs(host, "snd.sc")) return TrackSource.SoundCloud; - if (HostIs(host, "last.fm")) return TrackSource.LastFm; + var site = SupportedSites.FindByHost(host); + return site?.Source ?? TrackSource.Unknown; + } - return TrackSource.Unknown; + public static bool IsYtDlpSource(TrackSource source) + { + var site = SupportedSites.FindBySource(source); + return site?.IsYtDlpNative ?? false; } - /// - /// Returns true only if the URL is a valid, playable media URL. - /// Rejects bare domain roots like https://www.youtube.com/ - /// public static bool IsPlayableUrl(string url) { if (string.IsNullOrWhiteSpace(url)) return false; @@ -36,11 +70,10 @@ public static bool IsPlayableUrl(string url) var source = Detect(url); - // Host plus path segments without the scheme: ["soundcloud.com", "c418", "axolotl"] var parts = Regex.Replace(url, "^https?://", "", RegexOptions.IgnoreCase) - .TrimEnd('/') - .Split('/'); - + .TrimEnd('/') + .Split('/'); + var shortHost = parts[0].StartsWith("on.", StringComparison.OrdinalIgnoreCase) || parts[0].Equals("snd.sc", StringComparison.OrdinalIgnoreCase); @@ -49,17 +82,27 @@ public static bool IsPlayableUrl(string url) TrackSource.YouTube => url.Contains("v=") || url.Contains("youtu.be/") || url.Contains("list=") || url.Contains("/shorts/") || url.Contains("/live/") || url.Contains("/embed/"), - // Share links (on.soundcloud.com/xyz) have one segment; normal tracks have artist/track. TrackSource.SoundCloud => parts.Length >= (shortHost ? 2 : 3), TrackSource.Spotify => url.Contains("/track/") || url.Contains("/album/") || url.Contains("/playlist/"), TrackSource.LastFm => url.Contains("/music/"), + TrackSource.Bandcamp => + url.Contains("/track/", StringComparison.OrdinalIgnoreCase) || + url.Contains("/album/", StringComparison.OrdinalIgnoreCase), + TrackSource.Audiomack => + url.Contains("/song/", StringComparison.OrdinalIgnoreCase), + TrackSource.ArchiveOrg => + url.Contains("/details/", StringComparison.OrdinalIgnoreCase), + TrackSource.Mixcloud => + parts.Length >= 3 && + !MixcloudReservedUsers.Contains(parts[1]) && + !MixcloudReservedPages.Contains(parts[2].Split('?', '#')[0]), + // FIXED: Reject text that isn't a valid HTTP URL or local path TrackSource.Unknown => - // Accept non-HTTP local paths, or validate HTTP URLs properly - !url.StartsWith("http", StringComparison.OrdinalIgnoreCase) || - Uri.TryCreate(url, UriKind.Absolute, out _), + (Uri.TryCreate(url, UriKind.Absolute, out var u) && (u.Scheme == Uri.UriSchemeHttp || u.Scheme == Uri.UriSchemeHttps)) + || Regex.IsMatch(url, @"^([A-Za-z]:[\\/]|/|\\\\)"), _ => true }; } @@ -67,18 +110,13 @@ public static bool IsPlayableUrl(string url) private static string? GetHost(string? url) { if (string.IsNullOrWhiteSpace(url)) return null; - var text = url.Trim(); if (!text.Contains("://") && BareHost.IsMatch(text)) text = "https://" + text; - // Local paths parse as file URIs (or fail), so the scheme check keeps them Unknown. return Uri.TryCreate(text, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) ? uri.Host.ToLowerInvariant() : null; } - - private static bool HostIs(string host, string domain) => - host == domain || host.EndsWith("." + domain, StringComparison.Ordinal); } \ No newline at end of file diff --git a/Helpers/Url/YouTubeListLink.cs b/Helpers/Url/YouTubeListLink.cs new file mode 100644 index 0000000..e5ecf37 --- /dev/null +++ b/Helpers/Url/YouTubeListLink.cs @@ -0,0 +1,30 @@ +using System; + +namespace NullWave.Helpers; + +public enum YouTubeListKind { None, Mix, Playlist, Private } + +public static class YouTubeListLink +{ + public static YouTubeListKind Classify(string? url) + { + if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url.Trim(), UriKind.Absolute, out var u)) + return YouTubeListKind.None; + var host = u.Host.ToLowerInvariant(); + var isShort = host == "youtu.be"; + if (!isShort && host != "youtube.com" && !host.EndsWith(".youtube.com")) return YouTubeListKind.None; + + var q = System.Web.HttpUtility.ParseQueryString(u.Query); + var list = q["list"]; + if (string.IsNullOrEmpty(list)) return YouTubeListKind.None; + + var hasVideo = isShort + ? u.AbsolutePath.Length > 1 + : u.AbsolutePath.Equals("/watch", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(q["v"]); + if (!hasVideo) return YouTubeListKind.None; + + if (list is "LL" or "WL") return YouTubeListKind.Private; + if (list.StartsWith("RD", StringComparison.Ordinal) || q["start_radio"] == "1") return YouTubeListKind.Mix; + return YouTubeListKind.Playlist; + } +} \ No newline at end of file diff --git a/Helpers/YtDlpArgs.cs b/Helpers/YtDlpArgs.cs new file mode 100644 index 0000000..229a164 --- /dev/null +++ b/Helpers/YtDlpArgs.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using NullWave.Services; +using NullWave.Models; + +namespace NullWave.Helpers; + +internal static class YtDlpArgs +{ + public static void AppendNetworkArgs(List args, Preferences p) + { + // Force UTF-8 output to prevent dropping fullwidth/special characters on non-UTF-8 locales (e.g. cp1251) + args.Add("--encoding"); + args.Add("utf-8"); + + if (!string.IsNullOrWhiteSpace(p.YtDlpProxy)) + { + args.Add("--proxy"); + args.Add(p.YtDlpProxy.Trim()); + } + if (!string.IsNullOrWhiteSpace(p.YtDlpGeoProxy)) + { + args.Add("--geo-verification-proxy"); + args.Add(p.YtDlpGeoProxy.Trim()); + } + } +} \ No newline at end of file diff --git a/Models/Library/Track.cs b/Models/Library/Track.cs index 83a4df5..82d15fe 100644 --- a/Models/Library/Track.cs +++ b/Models/Library/Track.cs @@ -26,6 +26,7 @@ public class Track : NotifyBase private List _tags = new(); private string? _notes; private bool _titleForceCleaned; + private bool _isOffline; public Guid Id { get; set; } = Guid.NewGuid(); @@ -79,6 +80,7 @@ public MediaType MediaType public List Tags { get => _tags; set => SetField(ref _tags, value); } public string? Notes { get => _notes; set => SetField(ref _notes, value); } public bool TitleForceCleaned { get => _titleForceCleaned; set => SetField(ref _titleForceCleaned, value); } + public bool IsOffline { get => _isOffline; set => SetField(ref _isOffline, value); } public string DisplayDuration => Duration.ToString(@"m\:ss"); } \ No newline at end of file diff --git a/Models/Library/TrackSource.cs b/Models/Library/TrackSource.cs index 31602d2..5e9def5 100644 --- a/Models/Library/TrackSource.cs +++ b/Models/Library/TrackSource.cs @@ -7,5 +7,14 @@ public enum TrackSource SoundCloud, LastFm, Local, - Unknown + Unknown, + // --- No-API-Key Sources (yt-dlp native) --- + Bandcamp, + Audiomack, + ArchiveOrg, + Mixcloud, + // --- API-Backed Sources (future) --- + // Jamendo, + // FreeMusicArchive, + // Deezer } \ No newline at end of file diff --git a/Models/Records/TrackRecord.cs b/Models/Records/TrackRecord.cs index a4c18d6..e18f645 100644 --- a/Models/Records/TrackRecord.cs +++ b/Models/Records/TrackRecord.cs @@ -38,6 +38,7 @@ public class TrackRecord public bool TitleForceCleaned { get; set; } = false; public string? Album { get; set; } public int TrackNumber { get; set; } = 0; + public bool IsOffline { get; set; } public static TrackRecord FromTrack(Track t) => new() { @@ -60,6 +61,7 @@ public class TrackRecord MediaType = t.MediaType.ToString(), PlaybackPositionTicks = t.PlaybackPositionTicks, TitleForceCleaned = t.TitleForceCleaned, + IsOffline = t.IsOffline, Album = t.Album, TrackNumber = t.TrackNumber, }; @@ -87,6 +89,7 @@ public class TrackRecord MediaType = Enum.TryParse(MediaType, out var mt) ? mt : global::NullWave.Models.MediaType.Music, PlaybackPositionTicks = PlaybackPositionTicks, TitleForceCleaned = TitleForceCleaned, + IsOffline = IsOffline, Album = Album, TrackNumber = TrackNumber, }; 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..ba7ec7a 100644 --- a/Models/Settings/Preferences.cs +++ b/Models/Settings/Preferences.cs @@ -82,4 +82,9 @@ public class Preferences public string WallpaperBuiltInId { get; set; } = string.Empty; public List UnlockedExclusiveWallpapers { get; set; } = new(); public bool EnableExperimentalFeatures { get; set; } = false; + public bool ForceAllFeatures { 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/Models/Settings/TagWriteMode.cs b/Models/Settings/TagWriteMode.cs new file mode 100644 index 0000000..5005c38 --- /dev/null +++ b/Models/Settings/TagWriteMode.cs @@ -0,0 +1,16 @@ +namespace NullWave.Models; + +/// +/// Controls when NullWave writes ID3/metadata tags to audio files. +/// +public enum TagWriteMode +{ + /// Never write tags to files. Metadata stays in the database only. + Never = 0, + + /// Write tags only to files NullWave downloaded (default, safest). + DownloadsOnly = 1, + + /// Write tags to all files, including user-imported music (requires explicit opt-in). + Always = 2 +} \ 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..39b7a7f 100644 --- a/NullWave.csproj +++ b/NullWave.csproj @@ -2,14 +2,14 @@ Exe WinExe - net8.0 + net10.0 enable app.manifest true en;ru - 0.6.4 + 0.6.5-beta.2 ZenQuant NullWave @@ -19,6 +19,7 @@ + @@ -39,6 +40,7 @@ + @@ -53,4 +55,7 @@ + + + diff --git a/NullWave.slnx b/NullWave.slnx index b852aad..3c504c2 100644 --- a/NullWave.slnx +++ b/NullWave.slnx @@ -6,5 +6,9 @@ + + + + - + \ No newline at end of file diff --git a/PRIVACY.md b/PRIVACY.md index 7a45c54..fb72eab 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,23 +1,46 @@ # 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, Audiomack, Archive.org, Mixcloud, and 1000+ other sites supported by yt-dlp +- **bandcamp.com**: Bandcamp audio downloads and metadata +- **audiomack.com**: Audiomack audio downloads and metadata +- **archive.org**: Internet Archive audio downloads +- **mixcloud.com**: Mixcloud audio downloads +- **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/README.md b/README.md index 7babc19..0dd9105 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,13 @@ NullWave — Oxeye Daisy

-A personal music organizer with download, playback, and AI-powered smart sorting capabilities, built with C#/.NET 8 and Avalonia UI on Linux. +[![CI](https://github.com/ZenQuantDev/NullWave/actions/workflows/ci.yml/badge.svg)](https://github.com/ZenQuantDev/NullWave/actions/workflows/ci.yml) + +A personal music organizer with download, playback, and AI-powered smart sorting capabilities, built with C#/.NET 10 and Avalonia UI on Linux. ## Version -v0.6.4 "Oxeye Daisy" "Foundations" +v0.6.5-beta.2 "Oxeye Daisy" ## About @@ -68,7 +70,7 @@ NullWave lets you save, organize, download, and play music from YouTube, Last.fm ### 🛠️ Maintenance Suite -- Orphaned file sweep (dry-run preview + real deletion) +- Orphaned file sweep (dry-run preview; orphans move to an `_orphans` folder and are never deleted) - Duplicate detection with smart keeper selection - Link verification (stored metadata vs embedded tags) - Path repair, asset reimport, and database vacuum optimization @@ -80,13 +82,13 @@ NullWave lets you save, organize, download, and play music from YouTube, Last.fm | Layer | Technology | | -------------------- | ----------------------------------------------- | -| Language / Framework | C# 12, .NET 8, Avalonia UI 12 (MVVM) | +| Language / Framework | C# 12, .NET 10 LTS, Avalonia UI 12 (MVVM) | | Playback | LibVLCSharp + system libVLC | | Download | yt-dlp | | Metadata | TagLib# (ID3), YouTube Data API v3, Last.fm API | | Smart Sorting | Ollama (Local AI), OpenWeather API | | Logging | Serilog (3 sinks: system, user-actions, errors) | -| Security | AES-256-GCM encrypted KeyStore | +| Security | DPAPI on Windows; Secret Service (`secret-tool`) on Linux; falls back to a weaker machine-derived key if unavailable | | Persistence | SQLite-net-pcl | --- @@ -105,21 +107,23 @@ export NULLWAVE_OPENWEATHER_KEY="your_key" ## Requirements -- .NET 8 SDK +- **.NET 10 Runtime** (LTS, supported until Nov 2028) - **libVLC** (Bundled in Windows installer; Linux requires `sudo dnf install vlc-libs` or `sudo apt install libvlc-dev`) - **yt-dlp** (`winget install yt-dlp.yt-dlp` or `pip install yt-dlp`) -- **JavaScript Runtime** (Node.js, Deno, or Bun on PATH - *Required to solve YouTube signature challenges*) +- **JavaScript Runtime** (Deno ≥2.3.0 **recommended**, or Node.js ≥22.0.0, or QuickJS on PATH — required for YouTube signature challenges) - **FFmpeg** (Optional, but highly recommended for audio transcoding and embedding metadata/album art) -**Windows note:** Requires .NET 8 SDK (or .NET 10 with `Major` in csproj). LibVLC native libraries auto-installed via NuGet. yt-dlp installed via winget. +**Windows note:** Requires .NET 10 Runtime. LibVLC native libraries auto-installed via NuGet. yt-dlp installed via winget. -**Fedora note:** libVLC installs to `/usr/lib64`. NullWave expects `/usr/lib`. Create symlinks: +**Linux note:** libVLC installs to `/usr/lib64` on Fedora. NullWave expects `/usr/lib`. Create symlinks if needed: ```bash sudo ln -s /usr/lib64/libvlc.so /usr/lib/libvlc.so sudo ln -s /usr/lib64/libvlccore.so /usr/lib/libvlccore.so ``` +Optional secure key storage requires `libsecret-tools` on Debian/Ubuntu or `libsecret` on Fedora. + --- ## Building @@ -140,6 +144,8 @@ Everything NullWave writes lives under `~/.nullwave/`: ``` ~/.nullwave/ keys.enc ← encrypted API keys + keys.enc.v1.bak ← one-time legacy key backup during migration + file-ops.json ← file-operation audit journal profile.json ← local user profile (username, bio, avatar path) avatar.png ← profile picture library.db ← SQLite music library @@ -151,6 +157,15 @@ Everything NullWave writes lives under `~/.nullwave/`: Errors-DDMMYYYY.log ← errors with source attribution ``` +Set `NULLWAVE_HOME` to use a separate sandbox data directory for development or tests. + +## File Safety + +Imported files kept in place and source files used for export remain unchanged; Copy and export +work with separate files. Tag writes follow the selected TagWriteMode. Explicit removal sends +managed audio to the OS Recycle Bin or Trash, while unmanaged files are left untouched. The +file-operation journal is for auditing only; undo is not implemented. + --- ## Project Structure diff --git a/ROADMAP.md b/ROADMAP.md index aedb4e3..884b2f6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # NullWave - Roadmap -> Last updated: 24-Oct-2026 +> Last updated: 07-Nov-2026 --- @@ -26,8 +26,8 @@ | **v0.6.1** | "Stability & Safety" | Shipped 23-Sep-2026: xUnit test foundation (196 tests), P0 data safety (atomic writes/quarantine), P0 security (badge pinning), P1 download stability. | | **v0.6.2** | Correctness | Shipped 02-Oct-2026: DownloadService concurrency fixes, Mood AI prompt schema, unified TagTaxonomy, Spotify bridge fixes. | | **v0.6.3** | Responsiveness | Shipped 11-Oct-2026: Async hardware detection, LibraryService hot-path optimization, clean-machine first-run onboarding. | -| **v0.6.4** | "Foundations" | Shipped 24-Oct-2026: Architecture overhaul, O(1) duplicate detection, strict UTC enforcement, badge catalog, radio hardening, download reliability. | -| **v0.6.5** | Premium & Performance | "Effects: Reduced" tier, visual audit, performance budgets for low-end hardware. | +| **v0.6.4** | "Foundations" | Shipped 24-Oct-2026: Architecture overhaul, O(1) duplicate detection, strict UTC enforcement, badge catalog, radio hardening, download reliability. | +| **v0.6.5** | Hardening & File Safety | beta.1 shipped 31-Oct; beta.2 in review (file safety N1-N6, playback hardening, settings reorg, CI matrix). | | **v0.6.6** | Launch Readiness | Feature freeze, `/docs` wiki, Velopack installers, Reddit launch prep. | | **v0.7.0+** | Ecosystem | Media keys (MPRIS/SMTC), macOS build, Subsonic server, multi-source search plugin. | @@ -474,10 +474,10 @@ - ✅ Badge catalog JSON with v2 signing, KeyId rotation support and expired-badge handling. - ✅ v9/v10 migrations (UTC LastPlayed) run synchronously in one transaction. - ✅ yt-dlp JS runtime auto-detection; silent duplicate clicks; playback-hijack guard; duplicate import guard with YouTube URL canonicalization; "- Topic" stripping on single imports. ---- +--- -## Phase 25 - v0.6.4 "Notifications" 📋 +## Phase 25 - Notifications (slipped from v0.6.4, unscheduled, post-0.6.6) 📋 **Branch:** `feature/20-notifications` **Goal:** Configurable, localized notifications with in-app and native OS support. @@ -489,7 +489,7 @@ --- -## Phase 26 - v0.6.4 "Factory Reset" 📋 +## Phase 26 - Factory Reset (slipped from v0.6.4, unscheduled, post-0.6.6) 📋 **Branch:** `feature/20-factory-reset` **Goal:** Safe wipe with three tiers (Settings, Library, Factory). @@ -499,12 +499,16 @@ --- -## Phase 27 - v0.6.5 "Premium Look & Performance" 📋 +## Phase 27 - v0.6.5 "Hardening & File Safety" 🔄 -**Goal:** High-end aesthetics that respect the performance floor (i3 380M, 8GB RAM). +**Goal:** Ship N1-N6 file safety, playback hardening, settings reorganization, and the Windows/Ubuntu CI matrix in v0.6.5; these items belong to v0.6.5, not v0.6.6. - ✅ **Wallpaper v1 pulled forward into v0.6.3**: Whole-window wallpaper, class-gated chrome, readability floors, presets. -- 📋 **Effects Tier**: "Full" vs "Reduced" appearance setting (respects OS animation settings). +- 🔄 **File safety N1–N6**: unified FileNamer, TagWriteMode, import modes, Recycle Bin removal, safe export pipeline +- 🔄 **Playback hardening**: native Stop off UI thread, consent-before-add, UI watchdog +- 🔄 **Settings reorganization**: Plugins+Sources merged into Integrations, Downloads tab, Maintenance absorbs diagnostics +- 🔄 **CI matrix**: Windows + Ubuntu with banned-API gate +- 📋 **Effects Tier**: "Full" vs "Reduced" appearance setting (respects OS animation settings) - 🔄 **Visual Audit**: Display-size, HiDPI-aware async decoding, sidecars, and bitmap prewarming are in place. Blur, per-row shadows, and virtualization audit remain open. - 🔄 **Performance Budgets**: Define and hit targets for cold start, idle RAM, 5k track scroll smoothness, and zero >150ms UI stalls. @@ -529,10 +533,11 @@ - 💡 **Synced Lyrics**: LRCLIB integration with playback-position line highlighting. - 💡 **Advanced Audio**: 10-band EQ (LibVLC) and ReplayGain/volume normalization. -- 💡 **Smart Playlists**: SQL-driven dynamic playlists (forgotten, most played, recently added). -- 💡 **More Sources**: Bandcamp import, M3U/M3U8 import and export. +- ✅ **M3U Import/Export**: M3U/M3U8 import and export (shipped in beta.1). +- ✅ **Smart Playlists**: SQL-driven dynamic playlists (forgotten, most played, recently added). +- ✅ **More Sources**: Bandcamp, Audiomack, Archive.org, and Mixcloud shipped in beta.1. - 💡 **Export & Sharing**: Batch export/archiving, P2P/Torrent-like sharing, embedded tag sync. -- 💡 YouTube Data API fallback to yt-dlp embedded metadata when the API returns BadRequest (kills "Unknown Title" imports). +- ✅ YouTube Data API BadRequest fallback to yt-dlp embedded metadata shipped in beta.1. - 💡 Locked badges with progress bars and unlock toasts (persisted seen-set). - 💡 yt-dlp child-process kill watchdog on cancel (no leaked processes). - 💡 BadgeIssuer private key from env var or file instead of CLI argument. @@ -585,11 +590,11 @@ main ← always stable, builds clean | Package | Version | Notes | | ------------------------- | ------------- | --------------------------------------- | -| .NET | 8.0 | LTS | -| Avalonia | 12.0.4 | | -| LibVLCSharp | 3.9.7.1 | Fedora: symlinks to /usr/lib required | +| .NET | 10.0 | LTS (supported until Nov 2028) | +| Avalonia | 12.1.4 | | +| LibVLCSharp | 3.10.2 | Fedora: symlinks to /usr/lib required | | TagLib# | 2.3.0 | | | Serilog | latest stable | + Serilog.Filters.Expressions | -| sqlite-net-pcl | 1.9.172 | Packages installed, implementation next | +| sqlite-net-pcl | 1.9.172 | In use for all persistence | | SQLitePCLRaw.bundle_green | 2.1.11 | Native SQLite provider for Linux | -| CommunityToolkit.Mvvm | latest stable | RelayCommand, ObservableProperty | +| CommunityToolkit.Mvvm | 8.4.2 | RelayCommand, ObservableProperty | diff --git a/SECURITY.md b/SECURITY.md index e41e5a3..d249a94 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,14 +1,35 @@ -# Security Policy +## Plugin Security Model -## Supported Versions -Only the latest \ 0.6.x\ release is supported with security updates. +NullWave uses a multi-layer security model for plugins: -## Reporting a Vulnerability -NullWave is a local-first desktop application, but we take security seriously. -If you discover a security vulnerability (e.g., command injection in download pipeline, path traversal in library import, or leaked API keys), please report it responsibly. +### Layer 1: Manifest Validation +- All plugins must declare their permissions in `plugin.json` +- Permissions are reviewed during store submission +- Users are prompted to grant permissions on first install -**Do not open a public issue for security vulnerabilities.** +### Layer 2: Runtime Isolation +- **First-party plugins (C#):** Loaded in separate `AssemblyLoadContext` for dependency isolation +- **Community plugins (JavaScript):** Executed in Jint sandbox with no CLR access +- Plugins cannot directly access NullWave internals; they use the `IPluginHost` API -Instead, please email: **aleksandrgardaphadze@gmail.com or use the [GitHub Security Advisories](https://github.com/ZenQuantDev/NullWave/security/advisories/new) feature. +### Layer 3: API Key Protection +- Plugins request API keys via `IPluginHost.GetApiKey(keyName)` +- Access is logged and can be revoked by the user in Settings → API Keys → Plugin Permissions +- Plugins never see the raw key; they receive a scoped proxy -We will acknowledge receipt within 48 hours and provide a detailed response within 5 business days. +### Layer 4: Automated Scanning +- All store submissions are scanned for: + - Known malware signatures (ClamAV, YARA) + - Vulnerable dependencies (NuGet audit) + - Suspicious network patterns (static analysis) + - Hardcoded secrets (regex patterns) + +### Reporting Security Issues +If you discover a security vulnerability in a plugin: +1. **Do not** open a public GitHub issue +2. Email security@zenquant.dev with: + - Plugin ID and version + - Description of the vulnerability + - Steps to reproduce +3. We will contact the plugin author and remove the plugin if necessary +4. You will be credited in our security advisories (unless you request anonymity) \ No newline at end of file diff --git a/Services/Audio/PlaybackNavigator.cs b/Services/Audio/PlaybackNavigator.cs index 7d02545..244dad8 100644 --- a/Services/Audio/PlaybackNavigator.cs +++ b/Services/Audio/PlaybackNavigator.cs @@ -28,6 +28,11 @@ public partial class PlaybackNavigator : ObservableObject private IReadOnlyList _cachedQueue = new List(); private Dictionary _trackIndexMap = new(); + // Patch C: pool cache. + private int _poolVersion = -1; + private string? _poolKey; + private IReadOnlyList _pool = Array.Empty(); + [ObservableProperty] private bool _isShuffle; [ObservableProperty] private bool _isSmartShuffle; [ObservableProperty] private RepeatMode _repeatMode = RepeatMode.None; @@ -38,7 +43,10 @@ public partial class PlaybackNavigator : ObservableObject public PlaybackNavigator(LibraryService library) : this(() => library.GetAll(), () => library.StateVersion) { } - public PlaybackNavigator(Func> getAllTracks, Func getStateVersion, Random? rng = null) + public PlaybackNavigator( + Func> getAllTracks, + Func getStateVersion, + Random? rng = null) { _getAllTracks = getAllTracks; _getStateVersion = getStateVersion; @@ -66,20 +74,55 @@ public void InvalidateDeck() _shuffleIndex = -1; } + // Patch C: cache by StateVersion and pool key. private IReadOnlyList NavigationPool(Track? context = null) { var current = context ?? CurrentTrack; var mediaType = current?.MediaType ?? MediaType.Music; + + var key = mediaType == MediaType.Audiobook && current != null + ? $"A:{current.Id}" + : mediaType.ToString(); + + var version = _getStateVersion(); + + if (version != _poolVersion || key != _poolKey) + { + _pool = BuildPool(current, mediaType); + _poolVersion = version; + _poolKey = key; + } + + return _pool; + } + + private IReadOnlyList BuildPool(Track? current, MediaType mediaType) + { var all = _getAllTracks(); if (mediaType == MediaType.Audiobook && current != null) { if (!string.IsNullOrWhiteSpace(current.Album) && !string.IsNullOrWhiteSpace(current.Artist)) - return all.Where(t => t.MediaType == MediaType.Audiobook && string.Equals(t.Album, current.Album, StringComparison.OrdinalIgnoreCase) && string.Equals(t.Artist, current.Artist, StringComparison.OrdinalIgnoreCase)).ToList(); + { + return all.Where(t => + t.MediaType == MediaType.Audiobook && + string.Equals(t.Album, current.Album, StringComparison.OrdinalIgnoreCase) && + string.Equals(t.Artist, current.Artist, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + var currentDirectory = string.IsNullOrWhiteSpace(current.FilePath) + ? null + : Path.GetDirectoryName(current.FilePath); - var currentDirectory = string.IsNullOrWhiteSpace(current.FilePath) ? null : Path.GetDirectoryName(current.FilePath); if (!string.IsNullOrWhiteSpace(currentDirectory)) - return all.Where(t => t.MediaType == MediaType.Audiobook && !string.IsNullOrWhiteSpace(t.FilePath) && string.Equals(Path.GetDirectoryName(t.FilePath), currentDirectory, StringComparison.OrdinalIgnoreCase)).ToList(); + { + return all.Where(t => + t.MediaType == MediaType.Audiobook && + !string.IsNullOrWhiteSpace(t.FilePath) && + string.Equals(Path.GetDirectoryName(t.FilePath), currentDirectory, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } return new[] { current }; } @@ -93,6 +136,7 @@ private void EnsureIndexMap(IReadOnlyList queue) { _cachedLibraryVersion = _getStateVersion(); _cachedQueue = queue; + _trackIndexMap.Clear(); for (int i = 0; i < queue.Count; i++) _trackIndexMap[queue[i].Id] = i; @@ -116,14 +160,17 @@ private void BuildShuffleDeck() var filtered = allTracks.Where(t => EffectiveSkips(t) < SkipPenaltyCap).ToList(); _shuffleDeck = (SkipPenaltyCap > 0 && filtered.Count >= MinEligiblePoolSize ? filtered : allTracks) - .Select(t => t.Id).ToList(); + .Select(t => t.Id) + .ToList(); for (int i = _shuffleDeck.Count - 1; i > 0; i--) { int j = _rng.Next(i + 1); (_shuffleDeck[i], _shuffleDeck[j]) = (_shuffleDeck[j], _shuffleDeck[i]); } + _shuffleIndex = -1; + if (CurrentTrack != null && _shuffleDeck.Count > 1) _shuffleDeck.Remove(CurrentTrack.Id); } @@ -132,26 +179,35 @@ public List GenerateUpcoming(int count) { var queue = NavigationPool(); if (queue.Count == 0) return new List(); + EnsureIndexMap(queue); if (!IsShuffle) { - var startIdx = CurrentTrack != null && _trackIndexMap.TryGetValue(CurrentTrack.Id, out var idx) ? idx + 1 : 0; + var startIdx = CurrentTrack != null && _trackIndexMap.TryGetValue(CurrentTrack.Id, out var idx) + ? idx + 1 + : 0; + var result = new List(); + for (int i = 0; i < count; i++) { var pos = startIdx + i; + if (pos >= queue.Count) { if (RepeatMode != RepeatMode.All) break; pos %= queue.Count; } + result.Add(queue[pos]); } + return result; } var shuffleResult = new List(); + for (int i = 0; i < count; i++) { if (_shuffleDeck.Count == 0 || _shuffleIndex >= _shuffleDeck.Count - 1) @@ -159,9 +215,14 @@ public List GenerateUpcoming(int count) _shuffleIndex++; var nextId = _shuffleDeck[_shuffleIndex]; - Track? track = _trackIndexMap.TryGetValue(nextId, out var qIdx) ? queue[qIdx] : queue.FirstOrDefault(t => t.Id == nextId); + + Track? track = _trackIndexMap.TryGetValue(nextId, out var qIdx) + ? queue[qIdx] + : queue.FirstOrDefault(t => t.Id == nextId); + if (track != null) shuffleResult.Add(track); } + return shuffleResult; } @@ -169,6 +230,7 @@ public List GenerateUpcoming(int count) { var queue = NavigationPool(currentTrack); if (queue.Count == 0) return null; + EnsureIndexMap(queue); if (IsShuffle) @@ -176,7 +238,11 @@ public List GenerateUpcoming(int count) if (IsSmartShuffle && currentTrack != null && _rng.NextDouble() < 0.3) { var smart = GetSmartRecommendation(currentTrack, queue); - if (smart != null) { CurrentTrack = smart; return smart; } + if (smart != null) + { + CurrentTrack = smart; + return smart; + } } if (_shuffleDeck.Count == 0 || _shuffleIndex >= _shuffleDeck.Count - 1) @@ -184,20 +250,34 @@ public List GenerateUpcoming(int count) _shuffleIndex++; var nextId = _shuffleDeck[_shuffleIndex]; - Track? nextTrack = _trackIndexMap.TryGetValue(nextId, out var idx) ? queue[idx] : queue.FirstOrDefault(t => t.Id == nextId); + + Track? nextTrack = _trackIndexMap.TryGetValue(nextId, out var idx) + ? queue[idx] + : queue.FirstOrDefault(t => t.Id == nextId); + CurrentTrack = nextTrack; return nextTrack; } - if (currentTrack == null) { CurrentTrack = queue[0]; return queue[0]; } + if (currentTrack == null) + { + CurrentTrack = queue[0]; + return queue[0]; + } - if (_trackIndexMap.TryGetValue(currentTrack.Id, out var currentIdx) && currentIdx >= 0 && currentIdx < queue.Count - 1) + if (_trackIndexMap.TryGetValue(currentTrack.Id, out var currentIdx) && + currentIdx >= 0 && + currentIdx < queue.Count - 1) { CurrentTrack = queue[currentIdx + 1]; return queue[currentIdx + 1]; } - if (RepeatMode == RepeatMode.All) { CurrentTrack = queue[0]; return queue[0]; } + if (RepeatMode == RepeatMode.All) + { + CurrentTrack = queue[0]; + return queue[0]; + } CurrentTrack = null; return null; @@ -209,11 +289,20 @@ public List GenerateUpcoming(int count) var currentTags = current.Tags.ToHashSet(); var scored = queue - .Where(t => t.Id != current.Id && !historySet.Contains(t.Id) && (SkipPenaltyCap <= 0 || EffectiveSkips(t) < SkipPenaltyCap)) + .Where(t => + t.Id != current.Id && + !historySet.Contains(t.Id) && + (SkipPenaltyCap <= 0 || EffectiveSkips(t) < SkipPenaltyCap)) .Select(t => { int matchingTags = t.Tags.Count(tag => currentTags.Contains(tag)); - int score = (t.Artist == current.Artist && t.Artist != "Unknown" ? 5 : 0) + (matchingTags * 2) + (t.IsFavorite ? 1 : 0) - EffectiveSkips(t); + + int score = + (t.Artist == current.Artist && t.Artist != "Unknown" ? 5 : 0) + + (matchingTags * 2) + + (t.IsFavorite ? 1 : 0) - + EffectiveSkips(t); + return (Track: t, Score: score); }) .OrderByDescending(x => x.Score) @@ -228,15 +317,17 @@ public List GenerateUpcoming(int count) { var queue = NavigationPool(currentTrack); if (queue.Count == 0) return null; + EnsureIndexMap(queue); - // FIX: Prevent Previous from returning the track that is already playing + // Prevent Previous from returning the track that is already playing while (_history.Count > 0 && currentTrack != null && _history[0] == currentTrack.Id) _history.RemoveAt(0); if (_history.Count > 0 && currentTrack != null) { var lastHistoryId = _history[0]; + if (_trackIndexMap.TryGetValue(currentTrack.Id, out var currentIdx)) { var sequentialPrevIdx = currentIdx > 0 ? currentIdx - 1 : -1; @@ -267,7 +358,11 @@ public List GenerateUpcoming(int count) return queue[idx - 1]; } - if (RepeatMode == RepeatMode.All) { CurrentTrack = queue[^1]; return queue[^1]; } + if (RepeatMode == RepeatMode.All) + { + CurrentTrack = queue[^1]; + return queue[^1]; + } CurrentTrack = null; return null; @@ -276,7 +371,16 @@ public List GenerateUpcoming(int count) public Track? GetNextChapter(Track current) { if (current == null || string.IsNullOrEmpty(current.Album)) return null; - var chapters = _getAllTracks().Where(t => t.MediaType == MediaType.Audiobook && t.Album == current.Album && t.Artist == current.Artist).OrderBy(t => t.TrackNumber).ThenBy(t => t.Title).ToList(); + + var chapters = _getAllTracks() + .Where(t => + t.MediaType == MediaType.Audiobook && + t.Album == current.Album && + t.Artist == current.Artist) + .OrderBy(t => t.TrackNumber) + .ThenBy(t => t.Title) + .ToList(); + var idx = chapters.FindIndex(t => t.Id == current.Id); return (idx >= 0 && idx < chapters.Count - 1) ? chapters[idx + 1] : null; } @@ -284,9 +388,18 @@ public List GenerateUpcoming(int count) public Track? GetPreviousChapter(Track current) { if (current == null || string.IsNullOrEmpty(current.Album)) return null; - var chapters = _getAllTracks().Where(t => t.MediaType == MediaType.Audiobook && t.Album == current.Album && t.Artist == current.Artist).OrderBy(t => t.TrackNumber).ThenBy(t => t.Title).ToList(); + + var chapters = _getAllTracks() + .Where(t => + t.MediaType == MediaType.Audiobook && + t.Album == current.Album && + t.Artist == current.Artist) + .OrderBy(t => t.TrackNumber) + .ThenBy(t => t.Title) + .ToList(); + var idx = chapters.FindIndex(t => t.Id == current.Id); - return (idx > 0) ? chapters[idx - 1] : null; + return idx > 0 ? chapters[idx - 1] : null; } public bool ShouldRepeatCurrent() => RepeatMode == RepeatMode.One; diff --git a/Services/Audio/PlaybackService.cs b/Services/Audio/PlaybackService.cs index 07aa0ac..4515491 100644 --- a/Services/Audio/PlaybackService.cs +++ b/Services/Audio/PlaybackService.cs @@ -39,6 +39,7 @@ public class PlaybackService : IDisposable private int _crossfadeGeneration; private int _targetVolume = 80; private int _playRequestGeneration; + private readonly object _playLock = new(); public int CrossfadeGeneration => Volatile.Read(ref _crossfadeGeneration); public bool IsCrossfading => _isCrossfading; @@ -288,6 +289,13 @@ private void OnStopped(MediaPlayer player, EventArgs e) private void OnEndReached(MediaPlayer player, EventArgs e) { if (!ReferenceEquals(player, _activePlayer)) return; + + // Mid-crossfade the outgoing player reaching its end is expected: the incoming + // player owns the session. Forwarding Stopped here is what flips the UI button + // to "Play" while audio is still fading over. Only forward ends from the player + // the UI is actually following (PositionSource). + if (_isCrossfading && !ReferenceEquals(player, PositionSource)) return; + SetBuffering(false); _uiPost(() => { @@ -313,8 +321,6 @@ public void Play(string path) { if (!_ready) { - // Engine still starting: never wait on the init lock from the UI thread. - // Remember the latest request and start it once the engine is up. Volatile.Write(ref _deferredPlayPath, path); _ = Task.Run(() => { @@ -322,7 +328,7 @@ public void Play(string path) _uiPost(() => { var pending = Interlocked.Exchange(ref _deferredPlayPath, null); - if (pending == null) return; // a newer request already ran + if (pending == null) return; if (ok) Play(pending); else ReportEngineUnavailable(); }); }); @@ -338,85 +344,100 @@ public void Play(string path) _fadeCts?.Cancel(); t.Step("abort"); - _activePlayer.Stop(); - t.Step("stop"); + _ = Task.Run(() => StartTrack(path, playRequest, t)); + } + catch (Exception ex) + { + Log.Error(ex, "Playback failed for {Path}", path); + } + } - _currentMedia?.Dispose(); - _currentMedia = null; - t.Step("disposeMedia"); + private void StartTrack(string path, int playRequest, StepTimer t) + { + try + { + lock (_playLock) + { + bool Stale() => _disposed || playRequest != Volatile.Read(ref _playRequestGeneration); + if (Stale()) return; - _icyTimer?.Dispose(); - _icyTimer = null; - _lastIcyTitle = string.Empty; - _lastIcyArtist = string.Empty; + _activePlayer.Stop(); + t.Step("stop"); + if (Stale()) return; - var isUrl = path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - path.StartsWith("https://", StringComparison.OrdinalIgnoreCase); - _currentMedia = CreateMedia(path); - if (isUrl) SetBuffering(true); else SetBuffering(false); - t.Step("newMedia"); + _currentMedia?.Dispose(); + _currentMedia = null; + t.Step("disposeMedia"); - _activePlayer.Media = _currentMedia; - _activePlayer.Volume = _targetVolume; - _activePlayer.Play(); - t.Step("play"); - t.Done(); + _icyTimer?.Dispose(); + _icyTimer = null; + _lastIcyTitle = string.Empty; + _lastIcyArtist = string.Empty; - Log.Information("Playback started: {Path}", path); + var isUrl = path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + _currentMedia = CreateMedia(path); + if (isUrl) SetBuffering(true); else SetBuffering(false); + t.Step("newMedia"); - _ = Task.Run(async () => - { - await Task.Delay(2500); - var state = _activePlayer.State; - if (playRequest != Volatile.Read(ref _playRequestGeneration) - || _currentMedia == null - || _activePlayer.IsPlaying - || (state != VLCState.Error && state != VLCState.Ended)) - return; + _activePlayer.Media = _currentMedia; + _activePlayer.Volume = _targetVolume; + _activePlayer.Play(); + t.Step("play"); + t.Done(); - _uiPost(() => + Log.Information("Playback started: {Path}", path); + + _ = Task.Run(async () => { - var currentState = _activePlayer.State; - if (playRequest == Volatile.Read(ref _playRequestGeneration) - && _currentMedia != null - && !_activePlayer.IsPlaying - && (currentState == VLCState.Error || currentState == VLCState.Ended)) - StateChanged?.Invoke(PlaybackState.Stopped); + await Task.Delay(2500); + var state = _activePlayer.State; + if (playRequest != Volatile.Read(ref _playRequestGeneration) + || _currentMedia == null + || _activePlayer.IsPlaying + || (state != VLCState.Error && state != VLCState.Ended)) + return; + + _uiPost(() => + { + var currentState = _activePlayer.State; + if (playRequest == Volatile.Read(ref _playRequestGeneration) + && _currentMedia != null + && !_activePlayer.IsPlaying + && (currentState == VLCState.Error || currentState == VLCState.Ended)) + StateChanged?.Invoke(PlaybackState.Stopped); + }); }); - }); - if (isUrl) - { - _icyTimer = new System.Threading.Timer(_ => + if (isUrl) { - try + _icyTimer = new System.Threading.Timer(_ => { - if (_currentMedia == null) return; - var nowPlaying = _currentMedia.Meta(MetadataType.NowPlaying); - var artist = _currentMedia.Meta(MetadataType.Artist) ?? string.Empty; - var title = !string.IsNullOrWhiteSpace(nowPlaying) ? nowPlaying + try + { + if (_currentMedia == null) return; + var nowPlaying = _currentMedia.Meta(MetadataType.NowPlaying); + var artist = _currentMedia.Meta(MetadataType.Artist) ?? string.Empty; + var title = !string.IsNullOrWhiteSpace(nowPlaying) ? nowPlaying : !string.IsNullOrWhiteSpace(artist) ? (_currentMedia.Meta(MetadataType.Title) ?? string.Empty) : string.Empty; - if (string.IsNullOrWhiteSpace(artist) && title.Contains(" - ")) - { - var parts = title.Split(" - ", StringSplitOptions.None); - if (parts.Length == 2) + if (string.IsNullOrWhiteSpace(artist) && title.Contains(" - ")) { - artist = parts[0].Trim(); - title = parts[1].Trim(); + var parts = title.Split(" - ", StringSplitOptions.None); + if (parts.Length == 2) { artist = parts[0].Trim(); title = parts[1].Trim(); } } - } - if (title != _lastIcyTitle || artist != _lastIcyArtist) - { - _lastIcyTitle = title; - _lastIcyArtist = artist; - _uiPost(() => RadioMetadataChanged?.Invoke(title, artist)); + if (title != _lastIcyTitle || artist != _lastIcyArtist) + { + _lastIcyTitle = title; + _lastIcyArtist = artist; + _uiPost(() => RadioMetadataChanged?.Invoke(title, artist)); + } } - } - catch { /* ignore native teardown exceptions */ } - }, null, 1500, 3000); + catch { /* ignore native teardown exceptions */ } + }, null, 1500, 3000); + } } } catch (Exception ex) @@ -657,6 +678,19 @@ public async Task CrossfadeToAsync(string nextPath, int durationMs, float t if (promoted) { outgoing.Stop(); + + Log.Information("[Crossfade] promoted: vol={Vol} target={Target} vmRequested={Req} mute={Mute} playing={Playing}", + _activePlayer.Volume, _targetVolume, targetVolume, _activePlayer.Mute, _activePlayer.IsPlaying); + // The incoming player's Playing/DurationDiscovered events fired while it + // was still the standby player and were ignored by design, so announce + // the new active session here. Without this the VM keeps the stale + // Stopped state and the play/pause button shows the wrong icon. + _uiPost(() => + { + StateChanged?.Invoke(PlaybackState.Playing); + if (_ready && _activePlayer.Length > 0) + DurationDiscovered?.Invoke(TimeSpan.FromMilliseconds(_activePlayer.Length)); + }); } } catch (OperationCanceledException) diff --git a/Services/Database/DatabaseService.Fts.cs b/Services/Database/DatabaseService.Fts.cs index da1fe94..820ad09 100644 --- a/Services/Database/DatabaseService.Fts.cs +++ b/Services/Database/DatabaseService.Fts.cs @@ -122,6 +122,9 @@ FROM TracksFts private static string BuildFtsQuery(string query) { if (string.IsNullOrWhiteSpace(query)) return string.Empty; + + // FIX: Normalize to FormC so decomposed accents (e + ´) become single characters before we filter + query = query.Normalize(System.Text.NormalizationForm.FormC); var words = new string(query.Select(character => char.IsLetterOrDigit(character) ? character : ' ').ToArray()) .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); diff --git a/Services/Database/DatabaseService.Migrations.cs b/Services/Database/DatabaseService.Migrations.cs index b52e70d..de9ee0b 100644 --- a/Services/Database/DatabaseService.Migrations.cs +++ b/Services/Database/DatabaseService.Migrations.cs @@ -29,7 +29,16 @@ private void MigrateSchema() void Step(int target, string sql) { if (v >= target) return; - _db.Execute(sql); + try + { + _db.Execute(sql); + } + catch (SQLite.SQLiteException ex) when (ex.Message.Contains("duplicate column", StringComparison.OrdinalIgnoreCase)) + { + // sqlite-net's CreateTable already added this column based on the TrackRecord model. + // Safe to ignore and bump the version. + Log.Debug("[DatabaseService] Migration {Target}: column already exists, skipping ALTER TABLE.", target); + } v = target; _db.Execute($"PRAGMA user_version = {target};"); } @@ -50,6 +59,8 @@ void Step(int target, string sql) _db.Execute("PRAGMA user_version = 8;"); } + Step(11, "ALTER TABLE Tracks ADD COLUMN IsOffline INTEGER DEFAULT 0;"); + Log.Information("[DatabaseService] Schema is at version {Version}", v); } catch (Exception ex) { Log.Warning(ex, "[DatabaseService] Schema migration check failed"); } diff --git a/Services/Download/DownloadFailureClassifier.cs b/Services/Download/DownloadFailureClassifier.cs new file mode 100644 index 0000000..ff7b782 --- /dev/null +++ b/Services/Download/DownloadFailureClassifier.cs @@ -0,0 +1,66 @@ +using System; + +namespace NullWave.Services; + +public enum DownloadFailureKind +{ + Unknown, + CookiesUnusable, + BotCheck, + RateLimited, + JsRuntimeMissing, + Unavailable +} + +/// +/// Classifies yt-dlp stderr into failure kinds. +/// Used to decide whether to retry, what message to show the user, and whether to +/// keep or delete a track during playlist downloads. +/// +public static class DownloadFailureClassifier +{ + public static DownloadFailureKind Classify(string stderr, bool hasJsRuntime) + { + if (string.IsNullOrWhiteSpace(stderr)) + return DownloadFailureKind.Unknown; + + static bool Has(string s, string w) => + s.Contains(w, StringComparison.OrdinalIgnoreCase); + + // Cookie failures: Chrome is running, encryption issues, permission denied + if (Has(stderr, "cookie") && + (Has(stderr, "Could not copy") || + Has(stderr, "Failed to decrypt") || + Has(stderr, "could not find") || + Has(stderr, "Permission denied"))) + { + return DownloadFailureKind.CookiesUnusable; + } + + // YouTube bot check (age gate, sign-in required) + if (Has(stderr, "not a bot") || Has(stderr, "Sign in to confirm")) + return DownloadFailureKind.BotCheck; + + // Rate limiting + if (Has(stderr, "HTTP Error 429") || Has(stderr, "Too Many Requests")) + return DownloadFailureKind.RateLimited; + + // Missing JS runtime for signature decryption + if (!hasJsRuntime && + (Has(stderr, "n challenge") || + Has(stderr, "Requested format is not available"))) + { + return DownloadFailureKind.JsRuntimeMissing; + } + + // Video permanently unavailable + if (Has(stderr, "Video unavailable") || + Has(stderr, "Private video") || + Has(stderr, "has been removed")) + { + return DownloadFailureKind.Unavailable; + } + + return DownloadFailureKind.Unknown; + } +} \ No newline at end of file diff --git a/Services/Download/DownloadService.cs b/Services/Download/DownloadService.cs index da4115a..eaedf51 100644 --- a/Services/Download/DownloadService.cs +++ b/Services/Download/DownloadService.cs @@ -6,6 +6,7 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using System.Collections.Concurrent; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -50,7 +51,9 @@ private DownloadJob GetOrCreateJob(string trackId, string url, string title, str Url = url, Title = title, Artist = artist, - RetryAction = () => _ = DownloadAsync(trackId, url) + RetryAction = () => _ = DownloadAsync(trackId, url, + _prefsService.Current.AudioFormat, _prefsService.Current.AudioQuality, + title: title, artist: artist) }; void AddJob() => ActiveJobs.Insert(0, job); @@ -106,6 +109,7 @@ void Prune() public event Action? PlaylistBatchStarted; public event Action? PlaylistBatchProgress; public event Action? PlaylistBatchCompleted; + public event Action? PlaylistTrackStarted; private static readonly Regex ProgressRegex = new( @"\[download\]\s+([\d.]+)%", RegexOptions.Compiled); @@ -115,12 +119,23 @@ 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); + + private static readonly Regex ListParamRegex = new(@"[?&]list=[^&]*", RegexOptions.Compiled); + private static readonly Regex YtIdRegex = new(@"(?:[?&]v=|youtu\.be/)([A-Za-z0-9_-]{11})", RegexOptions.Compiled); + private static readonly string[] AudioExts = { ".mp3", ".m4a", ".ogg", ".opus", ".wav", ".flac" }; private readonly HashSet _activeDownloads = new(); + private readonly HashSet _activePlaylists = new(); + private static readonly Regex ListIdRegex = new(@"[?&]list=([^&]+)", RegexOptions.Compiled); private int _activeDownloadCount; private CancellationTokenSource? _currentDownloadCts; - private SemaphoreSlim _semaphore = new(2, 5); + + // Never replace or dispose the semaphore + private readonly SemaphoreSlim _gate = new(2, 5); private int _currentLimit = 2; + private int _shrinkDebt; + private readonly object _limitLock = new(); + private static bool? _aria2cAvailable; private static readonly object _aria2cLock = new(); private static bool? _ffmpegAvailable; @@ -131,14 +146,74 @@ void Prune() private static string? _cachedJsRuntime; private static bool _jsRuntimeProbed; + // Cookie fallback state + private volatile bool _cookiesDisabledForSession; + private int _cookieWarned; + private readonly ConcurrentDictionary _failureKinds = new(); + + private bool CookiesActive => + !_cookiesDisabledForSession && !string.IsNullOrWhiteSpace(_prefsService.Current.YtDlpBrowserCookies); + + private void DisableCookiesForSession(string reason) + { + _cookiesDisabledForSession = true; + if (Interlocked.Exchange(ref _cookieWarned, 1) != 0) return; + + Log.Warning("[DownloadService] Browser cookies unusable ({Reason}); continuing without them this session", reason); + ToastService.Instance.Show( + "Couldn't read your browser's cookies (close the browser, or clear the cookie setting). Downloading without them.", + ToastType.Warning, scope: "download-cookies"); + } + + internal static string StripListParam(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var u)) return url; + var q = System.Web.HttpUtility.ParseQueryString(u.Query); + if (q["list"] == null) return url; + q.Remove("list"); q.Remove("index"); q.Remove("start_radio"); + return new UriBuilder(u) { Query = q.ToString() }.Uri.AbsoluteUri; + } + public void UpdateConcurrencyLimit(int newLimit) { newLimit = Math.Clamp(newLimit, 1, 5); - if (newLimit == _currentLimit) return; - var old = _semaphore; - _semaphore = new SemaphoreSlim(newLimit, 5); - _currentLimit = newLimit; - old.Dispose(); + int release = 0, absorb = 0; + lock (_limitLock) + { + var delta = newLimit - _currentLimit; + _currentLimit = newLimit; + if (delta > 0) + { + var repay = Math.Min(delta, _shrinkDebt); + _shrinkDebt -= repay; + release = delta - repay; + } + else + { + _shrinkDebt += -delta; + absorb = -delta; + } + } + + if (release > 0) _gate.Release(release); + + for (var i = 0; i < absorb; i++) + { + _ = Task.Run(async () => + { + await _gate.WaitAsync(); + lock (_limitLock) + { + if (_shrinkDebt > 0) + { + _shrinkDebt--; + return; + } + } + _gate.Release(); + }); + } + Log.Information("[DownloadService] Concurrency limit updated to {Limit}", newLimit); } @@ -153,11 +228,18 @@ public DownloadService(LibraryService libraryService, PreferencesService prefsSe public void CancelCurrentDownload() { - _currentDownloadCts?.Cancel(); + var cts = _currentDownloadCts; + if (cts == null || cts.IsCancellationRequested) return; + + cts.Cancel(); Log.Debug("[DownloadService] Current download cancelled by caller"); } - // --- Unified ProcessRunner Detection --- + public bool IsDownloading(string url) + { + lock (_activeDownloads) return _activeDownloads.Contains(url); + } + private static async Task IsAria2cAvailableAsync() { if (_aria2cAvailable == true) return true; @@ -190,55 +272,72 @@ private static async Task IsFfmpegAvailableAsync() return false; } - - - private static string? GetJsRuntimeArg() + private static string? ResolveOnPath(string name) { - if (_jsRuntimeProbed) return _cachedJsRuntime; - - static bool OnPath(string name) + var exe = OperatingSystem.IsWindows() ? name + ".exe" : name; + foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) { - var exe = OperatingSystem.IsWindows() ? name + ".exe" : name; - var path = Environment.GetEnvironmentVariable("PATH") ?? ""; - foreach (var dir in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) - { - try { if (File.Exists(Path.Combine(dir.Trim(), exe))) return true; } catch { } - } - return false; + try { var p = Path.Combine(dir.Trim(), exe); if (File.Exists(p)) return p; } catch { } } + return null; + } + + /// "major.minor.patch" from "v22.1.0" or "deno 2.3.1 (...)"; null if unreadable. + internal static Version? ParseVersion(string? text) + { + var m = System.Text.RegularExpressions.Regex.Match(text ?? "", @"(\d+)\.(\d+)\.(\d+)"); + return m.Success ? new Version(int.Parse(m.Groups[1].Value), int.Parse(m.Groups[2].Value), int.Parse(m.Groups[3].Value)) : null; + } - string? result = null; - bool found = false; - 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; } + private static bool _jsRuntimeFound; + private static long _negativeProbeTicks; - if (found) - { - _cachedJsRuntime = result; - _jsRuntimeProbed = true; - } - return result; + private static async Task RuntimeOkAsync(string name, Version min) + { + var path = ResolveOnPath(name); + if (path == null) return false; + var r = await ProcessRunner.RunAsync(path, new[] { "--version" }, TimeSpan.FromSeconds(3), resolveViaPath: false); + return r.ExitCode == 0 && ParseVersion(r.StandardOutput) is { } v && v >= min; } - private static bool HasAnyJsRuntime() + private static async Task GetJsRuntimeArgAsync() { - static bool OnPath(string name) + // FIX: Only return early if we found a runtime, OR if the 2-minute negative cache hasn't expired + if (_jsRuntimeProbed && (_jsRuntimeFound || Environment.TickCount64 - _negativeProbeTicks <= 120_000)) + return _cachedJsRuntime; + + await ProbeJsAsync(); + return _cachedJsRuntime; + } + + private static async Task ProbeJsAsync() + { + if (_jsRuntimeProbed) { - var exe = OperatingSystem.IsWindows() ? name + ".exe" : name; - var path = Environment.GetEnvironmentVariable("PATH") ?? ""; - foreach (var dir in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) - { - try { if (File.Exists(Path.Combine(dir.Trim(), exe))) return true; } catch { } - } - return false; + if (!_jsRuntimeFound && Environment.TickCount64 - _negativeProbeTicks > 120_000) + _jsRuntimeProbed = false; + else + return _jsRuntimeFound; + } + + if (await RuntimeOkAsync("deno", new Version(2, 3, 0))) + { _cachedJsRuntime = null; _jsRuntimeFound = true; _jsRuntimeProbed = true; } + else if (await RuntimeOkAsync("node", new Version(22, 0, 0))) + { _cachedJsRuntime = "node"; _jsRuntimeFound = true; _jsRuntimeProbed = true; } + else + { + _jsRuntimeFound = false; _jsRuntimeProbed = true; + _negativeProbeTicks = Environment.TickCount64; } - return OnPath("deno") || OnPath("node") || OnPath("bun"); + return _jsRuntimeFound; } - private void AppendSpeedAndAuthArgs(List args, bool useAria2c) + private static bool HasAnyJsRuntime() => _jsRuntimeFound; + + private async Task AppendSpeedAndAuthArgsAsync(List args, bool useAria2c) { - var jsRuntime = GetJsRuntimeArg(); + YtDlpArgs.AppendNetworkArgs(args, _prefsService.Current); + var jsRuntime = await GetJsRuntimeArgAsync(); if (jsRuntime is { } runtime) { args.Add("--js-runtimes"); @@ -252,20 +351,96 @@ private void AppendSpeedAndAuthArgs(List args, bool useAria2c) args.Add("aria2c:-x 16 -k 1M -s 16"); } - var browserCookies = _prefsService.Current.YtDlpBrowserCookies; - if (!string.IsNullOrWhiteSpace(browserCookies)) + // FIXED: Use CookiesActive instead of raw preference check + if (CookiesActive) { args.Add("--cookies-from-browser"); - args.Add(browserCookies); + args.Add(_prefsService.Current.YtDlpBrowserCookies); } } private int GetThrottleDelayMs() { - return string.IsNullOrWhiteSpace(_prefsService.Current.YtDlpBrowserCookies) + // FIXED: Use CookiesActive instead of raw preference check + return !CookiesActive ? Random.Shared.Next(3000, 8000) : Random.Shared.Next(600, 1800); } + + public bool HasConsent() => _prefsService.Current.DownloaderConsent is { IsCurrent: true }; + + private readonly object _consentGate = new(); + private Task? _consentTask; + + /// + /// Single-flight consent prompt. If multiple downloads start simultaneously, + /// only one dialog is shown. + /// + public Task EnsureConsentAsync() + { + if (HasConsent()) return Task.FromResult(true); + lock (_consentGate) return _consentTask ??= PromptConsentAsync(); + } + + private async Task PromptConsentAsync() + { + await Task.Yield(); + try + { + bool accepted = false; + + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(async () => + { + var window = new Views.Dialogs.DownloaderConsentView(); + var vm = new ViewModels.Dialogs.DownloaderConsentViewModel( + _prefsService, + onAccepted: () => window.Close(true), + onDeclined: () => window.Close(false) + ); + window.DataContext = vm; + + var mainWindow = Application.Current?.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop + ? desktop.MainWindow + : null; + + if (mainWindow != null) + { + accepted = await window.ShowDialog(mainWindow); + } + }); + + if (accepted) + { + Log.Information("[DownloadService] User accepted downloader consent"); + return true; + } + + Log.Information("[DownloadService] User declined downloader consent"); + ToastService.Instance.Show("Downloads remain disabled. Enable them in Settings → Downloads.", ToastType.Info, scope: "download"); + return false; + } + finally + { + lock (_consentGate) _consentTask = null; + } + } + + /// + /// Central gate for all download entry points. Prompts if interactive, logs and refuses if background. + /// + private async Task GateConsentAsync(bool interactive) + { + if (HasConsent()) return true; + + if (interactive) return await EnsureConsentAsync(); + + 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; + } public async Task DownloadAsync( string trackId, @@ -278,6 +453,12 @@ public async Task DownloadAsync( string? artist = null, CancellationToken ct = default) { + if (!await GateConsentAsync(isInteractive)) + { + 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,15 +474,26 @@ public async Task DownloadAsync( url = SiParamRegex3.Replace(url, ""); } - if (!allowPlaylist && (url.Contains("list=") || url.Contains("playlist?"))) + if (!allowPlaylist && url.Contains("playlist?", StringComparison.OrdinalIgnoreCase)) { 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); return; } + if (!allowPlaylist && url.Contains("list=", StringComparison.OrdinalIgnoreCase)) + { + url = StripListParam(url); + Log.Information("[DownloadService] Stripped list= parameter from URL: {Url}", url); + } + var safeDownloadDir = Path.GetFullPath(_downloadDir); - var outputTemplate = Path.Combine(safeDownloadDir, "%(title).150B [%(id)s].%(ext)s"); + var trackHash = trackId.Length >= 8 ? trackId[..8] : trackId; + // FIX: Inject a unique NullWave hash so we can ALWAYS find the file, even on Bandcamp/SoundCloud + // Use FileNamer-compatible template for consistency +// Format: {title} [nw-{id8}-{videoId}].{ext} +// The [nw-{id8}] suffix allows TryRelink to find files even if renamed +var outputTemplate = Path.Combine(safeDownloadDir, $"%(title).180s [nw-{trackHash}-%(id)s].%(ext)s"); var qualityValue = audioQuality switch { @@ -309,19 +501,22 @@ 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 { - url, "-f", formatSelector, "--output", outputTemplate, "--print", "after_move:filepath", - "--ignore-errors", - "--parse-metadata", "uploader:%(artist)s", "--parse-metadata", "channel:%(artist)s" + "-f", formatSelector, "--output", outputTemplate, "--print", "after_move:filepath", + "--ignore-errors" }; + + var src = SourceDetector.Detect(url); + var videoId = src == TrackSource.YouTube && YtIdRegex.Match(url) is { Success: true } idm ? idm.Groups[1].Value : null; + + if (src is TrackSource.YouTube or TrackSource.SoundCloud) + { + args.Add("--parse-metadata"); args.Add("uploader:%(artist)s"); + args.Add("--parse-metadata"); args.Add("channel:%(artist)s"); + } if (useFfmpeg) { args.Add("--embed-metadata"); @@ -330,8 +525,10 @@ public async Task DownloadAsync( { args.Add("--embed-thumbnail"); args.Add("--extract-audio"); + // FIX: yt-dlp doesn't accept "ogg", it expects "vorbis" or "opus" + var ytDlpFormat = audioFormat.Equals("ogg", StringComparison.OrdinalIgnoreCase) ? "vorbis" : audioFormat; args.Add("--audio-format"); - args.Add(audioFormat); + args.Add(ytDlpFormat); args.Add("--audio-quality"); args.Add(qualityValue); } @@ -342,17 +539,27 @@ public async Task DownloadAsync( } var useAria2c = _prefsService.Current.UseAria2c && await IsAria2cAvailableAsync(); - AppendSpeedAndAuthArgs(args, useAria2c); + await AppendSpeedAndAuthArgsAsync(args, useAria2c); if (!allowPlaylist) args.Add("--no-playlist"); else args.Add("--yes-playlist"); + if (!allowPlaylist && src != TrackSource.YouTube) + { + args.Add("--playlist-items"); + args.Add("1"); + } + + // SECURITY: Defense in depth against URLs starting with '-' + args.Add("--"); + args.Add(url); + lock (_activeDownloads) { 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); } @@ -360,54 +567,33 @@ public async Task DownloadAsync( CancellationTokenSource cts; lock (this) { - if (isInteractive) - { - _currentDownloadCts?.Cancel(); - cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - _currentDownloadCts = cts; - } - else - { - cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - } + cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + if (isInteractive) _currentDownloadCts = cts; // Stop button still targets the latest } cts.CancelAfter(TimeSpan.FromMinutes(20)); ct = cts.Token; - Log.Debug("Starting download: {Url} (format={Format}, quality={Quality})", url, audioFormat, audioQuality); - - SemaphoreSlim? acquiredSemaphore = null; var activeDownloadCounted = false; DownloadJob? job = null; try { - var sem = _semaphore; - try - { - await sem.WaitAsync(ct); - } - catch (ObjectDisposedException) - { - Log.Debug("[DownloadService] Semaphore rebuilt mid-wait, re-acquiring"); - sem = _semaphore; - await sem.WaitAsync(ct); - } - acquiredSemaphore = sem; - + job = GetOrCreateJob(trackId, url, title ?? "Track", artist ?? "Unknown"); + job.Status = "Queued..."; // FIX: Show queued status while waiting for semaphore + job.IsIndeterminate = true; + // Wait for an open slot in the concurrency gate + await _gate.WaitAsync(ct); Interlocked.Increment(ref _activeDownloadCount); activeDownloadCounted = true; - - job = GetOrCreateJob(trackId, url, title ?? "Track", artist ?? "Unknown"); - job.Status = "Downloading..."; + job.Status = "Downloading..."; // Now it's actually downloading job.IsIndeterminate = false; - + Log.Debug("Starting download: {Url} (format={Format}, quality={Quality})", url, audioFormat, audioQuality); string? outputFilePath = null; - - // --- STEP E: Unified ProcessRunner Execution --- var recentStdErr = new List(); - var result = await ProcessRunner.RunWithStreamingAsync( + + // FIXED: Wrap the run in a lambda so we can retry without cookies + var run = () => ProcessRunner.RunWithStreamingAsync( "yt-dlp", args, onStdOutLine: line => { @@ -423,9 +609,18 @@ public async Task DownloadAsync( job.Progress = pct; return; } + // 1. Raw path output if (line.StartsWith("/") || line.StartsWith("~") || Regex.IsMatch(line, @"^[A-Za-z]:[\\/]")) { - outputFilePath = line; + outputFilePath = line.Trim().Trim('"'); + return; + } + + // 2. Catch "Destination: " or "[Merger] Merging formats into " + var destMatch = Regex.Match(line, @"(?:Destination:\s*|Merging formats into\s*""|Moving to\s*"")([A-Za-z]:\\[^\r\n""]+|/[^\r\n""]+)"); + if (destMatch.Success) + { + outputFilePath = destMatch.Groups[1].Value.Trim().Trim('"'); return; } Log.Debug("yt-dlp: {Line}", line); @@ -438,7 +633,8 @@ 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)) + if (line.Contains("HTTP Error 429") || + line.Contains("Too Many Requests", StringComparison.OrdinalIgnoreCase)) { lock (_aria2cLock) { _rateLimitTriggered = true; } } @@ -448,6 +644,22 @@ public async Task DownloadAsync( ct: ct, resolveViaPath: true); + var result = await run(); + + // FIXED: Cookie retry logic + if (result.ExitCode != 0 && !result.Canceled && !result.TimedOut + && args.Contains("--cookies-from-browser") + && DownloadFailureClassifier.Classify(string.Join("\n", recentStdErr), HasAnyJsRuntime()) + == DownloadFailureKind.CookiesUnusable) + { + DisableCookiesForSession(recentStdErr.LastOrDefault() ?? ""); + var i = args.IndexOf("--cookies-from-browser"); + args.RemoveRange(i, 2); + recentStdErr.Clear(); + outputFilePath = null; + result = await run(); + } + if (result.Canceled || result.TimedOut) { Log.Warning("Download cancelled or timed out: {TrackId}", trackId); @@ -470,10 +682,13 @@ public async Task DownloadAsync( } else if (result.ExitCode == 0) { - var recent = FindMostRecentUnlinkedDownload(title); + if (outputFilePath != null) + Log.Warning("[DownloadService] yt-dlp printed a path that doesn't exist: {Path}", outputFilePath); + + var recent = FindMostRecentUnlinkedDownload(title, videoId, trackHash); if (recent != null) { - Log.Warning("[DownloadService] filepath not captured, using most recent: {Path}", recent); + Log.Information("[DownloadService] output located via fallback: {Path}", recent); job.IsCompleted = true; job.Status = "Completed"; job.Progress = 100; @@ -495,24 +710,24 @@ public async Task DownloadAsync( job.IsFailed = true; job.Status = "Failed"; + // FIXED: Use classifier for better error messages var errText = string.Join("\n", recentStdErr); - string friendlyError = $"yt-dlp exited with code {result.ExitCode}"; - - 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."; - } - else if (errText.Contains("not a bot", StringComparison.OrdinalIgnoreCase)) - { - friendlyError = "YouTube rate limit: Try using browser cookies in Settings."; - } - else if (errText.Contains("HTTP Error 429", StringComparison.OrdinalIgnoreCase)) + await ProbeJsAsync(); // Ensure we have the latest probe result for the classifier + var kind = DownloadFailureClassifier.Classify(errText, HasAnyJsRuntime()); + var detail = recentStdErr.LastOrDefault(l => l.Contains("ERROR", StringComparison.OrdinalIgnoreCase)) + ?? recentStdErr.LastOrDefault() ?? $"exit code {result.ExitCode}"; + + var friendlyError = kind switch { - friendlyError = "YouTube rate limit (429): Wait a few minutes or use browser cookies."; - } - + DownloadFailureKind.CookiesUnusable => "Browser cookies couldn't be read. Close the browser or clear the cookie setting.", + DownloadFailureKind.BotCheck => "YouTube wants a sign-in check. Try browser cookies in Settings.", + DownloadFailureKind.RateLimited => "YouTube rate limit (429). Wait a few minutes.", + DownloadFailureKind.JsRuntimeMissing => "YouTube signatures error. Install Deno 2.3+ or Node 22+.", + DownloadFailureKind.Unavailable => "This video is unavailable.", + _ => $"yt-dlp failed: {detail}" + }; + + _failureKinds[trackId] = kind; job.ErrorMessage = friendlyError; DownloadFailed?.Invoke(trackId, friendlyError, isInteractive); PruneCompletedJobs(); @@ -533,8 +748,11 @@ public async Task DownloadAsync( } finally { - if (activeDownloadCounted) Interlocked.Decrement(ref _activeDownloadCount); - acquiredSemaphore?.Release(); + if (activeDownloadCounted) + { + Interlocked.Decrement(ref _activeDownloadCount); + _gate.Release(); // FIXED: Always release the gate + } lock (_activeDownloads) _activeDownloads.Remove(url); } @@ -548,13 +766,72 @@ public async Task DownloadPlaylistAsync( Action? onTrackFailed = null, CancellationToken ct = default) { + // Extract playlist ID for deduplication + var m = ListIdRegex.Match(playlistUrl); + var key = m.Success ? m.Groups[1].Value : playlistUrl; + + lock (_activePlaylists) + { + if (!_activePlaylists.Add(key)) + { + Log.Information("[DownloadService] Playlist {Key} is already importing; ignoring", key); + ToastService.Instance.Show("This playlist is already being imported.", ToastType.Info, scope: "download"); + return; + } + } + + try + { + await DownloadPlaylistCoreAsync(playlistUrl, onTrackReady, onTrackStarted, + onTrackCompleted, onTrackFailed, ct); + } + finally + { + lock (_activePlaylists) _activePlaylists.Remove(key); + } + } + + private async Task DownloadPlaylistCoreAsync( + string playlistUrl, + Action? onTrackReady = null, + Action? onTrackStarted = null, + Action? onTrackCompleted = null, + Action? onTrackFailed = null, + CancellationToken ct = default) + { + if (!await GateConsentAsync(true)) + { + return; + } + + // Detect YouTube auto-generated mixes (RD...) and cap at 50 tracks + var listId = ListIdRegex.Match(playlistUrl).Groups[1].Value; + var isMix = listId.StartsWith("RD", StringComparison.Ordinal) || + playlistUrl.Contains("start_radio=1", StringComparison.OrdinalIgnoreCase); + + if (isMix) + { + Log.Warning("[DownloadService] Detected YouTube Mix (endless playlist), capping at 50 tracks: {Url}", playlistUrl); + ToastService.Instance.Show( + "YouTube Mix detected (endless playlist). Importing first 50 tracks only.", + ToastType.Info, + durationMs: 4000, + scope: "download"); + } + Log.Information("Starting playlist download: {Url}", playlistUrl); try { - // --- STEP E: Unified ProcessRunner Playlist Metadata --- + var metaArgs = new List { "--flat-playlist", "--dump-json", "--ignore-errors", "--no-download" }; + if (isMix) + { + metaArgs.AddRange(new[] { "--playlist-end", "50" }); + } + metaArgs.Add(playlistUrl); + var metadataResult = await ProcessRunner.RunAsync( "yt-dlp", - new List { "--flat-playlist", "--dump-json", "--ignore-errors", "--no-download", playlistUrl }, + metaArgs, timeout: TimeSpan.FromMinutes(5), ct: ct, resolveViaPath: true); @@ -645,6 +922,7 @@ public async Task DownloadPlaylistAsync( int completedCount = 0; int failedCount = 0; int skippedCount = 0; + int envStreak = 0; for (int i = 0; i < tracks.Count; i++) { @@ -652,6 +930,7 @@ public async Task DownloadPlaylistAsync( var (title, artist, url, videoId) = tracks[i]; onTrackStarted?.Invoke(title, i + 1, tracks.Count); + PlaylistTrackStarted?.Invoke(title, i + 1, tracks.Count); var trackId = Guid.NewGuid(); var cleanUrl = url.Split('&')[0]; @@ -668,9 +947,13 @@ public async Task DownloadPlaylistAsync( DateAdded = DateTime.UtcNow }; + // --- PATCH 1: Clean metadata BEFORE duplicate check --- + if (_prefsService.Current.AutoCleanMetadata) + (newTrack.Artist, newTrack.Title) = TrackNormalizer.ResolveArtistTitle(newTrack.Title, newTrack.Artist); + if (_prefsService.Current.PreventDuplicateDownloads && _libraryService.IsDuplicate(newTrack)) { - Log.Debug("[DownloadService] Skipped duplicate: {Artist} - {Title}", artist, title); + Log.Debug("[DownloadService] Skipped duplicate: {Artist} - {Title}", newTrack.Artist, newTrack.Title); skippedCount++; PlaylistBatchProgress?.Invoke(completedCount, tracks.Count, skippedCount); continue; @@ -730,11 +1013,32 @@ await DownloadAsync(trackId.ToString(), cleanUrl, _prefsService.Current.AudioFor if (!success) { - _libraryService.Remove(trackId); + _failureKinds.TryRemove(trackId.ToString(), out var kind); + var environmental = kind is DownloadFailureKind.BotCheck + or DownloadFailureKind.RateLimited + or DownloadFailureKind.JsRuntimeMissing + or DownloadFailureKind.CookiesUnusable; + + if (!environmental) + { + _libraryService.Remove(trackId); + } + failedCount++; + envStreak = environmental ? envStreak + 1 : 0; + + if (envStreak >= 3) + { + Log.Warning("[DownloadService] Stopping playlist after {N} consecutive {Kind} failures", envStreak, kind); + ToastService.Instance.Show( + $"Playlist paused: {kind}. Fix this and re-import. Remaining tracks were not touched.", + ToastType.Warning, scope: "download"); + break; + } } else if (!string.IsNullOrEmpty(finalFilePath)) { + envStreak = 0; completedCount++; var dbTrack = _libraryService.GetAll().FirstOrDefault(t => t.Id == trackId); if (dbTrack != null) @@ -790,7 +1094,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); } } @@ -826,7 +1130,7 @@ await DownloadAsync(trackId.ToString(), cleanUrl, _prefsService.Current.AudioFor } } - private string? FindMostRecentUnlinkedDownload(string? expectedTitle = null) + private string? FindMostRecentUnlinkedDownload(string? expectedTitle = null, string? videoId = null, string? trackHash = null) { var dir = new DirectoryInfo(_downloadDir); if (!dir.Exists) return null; @@ -836,12 +1140,29 @@ await DownloadAsync(trackId.ToString(), cleanUrl, _prefsService.Current.AudioFor .Select(t => Path.GetFullPath(t.FilePath!)) .ToHashSet(StringComparer.OrdinalIgnoreCase); + // FIX: Try to find by NullWave Track Hash first (Works for ALL sources) + if (!string.IsNullOrEmpty(trackHash)) + { + var tag = $"[nw-{trackHash}"; + var byHash = dir.GetFiles() + .Where(f => AudioExts.Contains(f.Extension.ToLowerInvariant()) + && f.Name.Contains(tag, StringComparison.OrdinalIgnoreCase) + && !linkedPaths.Contains(f.FullName)) + .OrderByDescending(f => f.LastWriteTimeUtc) + .FirstOrDefault(); + if (byHash != null) + { + Log.Information("[DownloadService] Recovered output by track hash: {Path}", byHash.FullName); + return byHash.FullName; + } + } + FileInfo? newest = null; foreach (var file in dir.GetFiles()) { - if (!new[] { ".mp3", ".m4a", ".ogg", ".opus", ".wav", ".flac" }.Contains(file.Extension.ToLowerInvariant())) continue; + if (!AudioExts.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; @@ -851,8 +1172,7 @@ await DownloadAsync(trackId.ToString(), cleanUrl, _prefsService.Current.AudioFor static string Norm(string s) => new string(s.Where(char.IsLetterOrDigit).ToArray()); var want = Norm(expectedTitle); var match = dir.GetFiles() - .Where(f => !linkedPaths.Contains(f.FullName) && - new[] { ".mp3", ".m4a", ".ogg", ".opus", ".wav", ".flac" }.Contains(f.Extension.ToLowerInvariant())) + .Where(f => !linkedPaths.Contains(f.FullName) && AudioExts.Contains(f.Extension.ToLowerInvariant())) .FirstOrDefault(f => Norm(Path.GetFileNameWithoutExtension(f.Name)) == want); if (match != null) return match.FullName; } @@ -883,4 +1203,5 @@ internal static string ChooseFormatSelector(bool useFfmpeg, string audioFormat, } public string DownloadDirectory => _downloadDir; + internal int AvailablePermits => _gate.CurrentCount; } \ No newline at end of file diff --git a/Services/EffectsTierResolver.cs b/Services/EffectsTierResolver.cs index 3877308..1b2e5b8 100644 --- a/Services/EffectsTierResolver.cs +++ b/Services/EffectsTierResolver.cs @@ -9,11 +9,15 @@ namespace NullWave.Services; public class EffectsTierResolver : INotifyPropertyChanged { + private static readonly Lazy _shared = new(() => new EffectsTierResolver(PreferencesService.Shared)); + public static EffectsTierResolver Shared => _shared.Value; + private readonly PreferencesService _prefs; private readonly Func? _hardwareProbe; private readonly Action? _post; private EffectsTier? _devOverride; private EffectsTier? _batteryOverride; + private bool _resolutionLogged; public EffectsTierResolver(PreferencesService prefs) : this(prefs, null, null) { } @@ -43,35 +47,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/FileOperationJournal.cs b/Services/FileOperationJournal.cs new file mode 100644 index 0000000..9bafbff --- /dev/null +++ b/Services/FileOperationJournal.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +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); + +/// +/// 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 +{ + 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; + + // 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"); + Load(); + } + + private void Load() + { + try + { + 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"); } + } + + private void Save() + { + try + { + var recent = _ops.TakeLast(MaxRecords).ToList(); + var json = JsonSerializer.Serialize(recent, new JsonSerializerOptions { WriteIndented = true }); + 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"); } + } + + 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; + } + } + + public FileOpRecord? PopLast() + { + lock (_lock) + { + if (_ops.Count == 0) return null; + var last = _ops[^1]; + _ops.RemoveAt(_ops.Count - 1); + Save(); + _dirty = false; + return last; + } + } +} \ No newline at end of file diff --git a/Services/Integration/SpotifyPageParser.cs b/Services/Integration/SpotifyPageParser.cs index 17523a0..c694981 100644 --- a/Services/Integration/SpotifyPageParser.cs +++ b/Services/Integration/SpotifyPageParser.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Net; using System.Text.RegularExpressions; +using System.Globalization; namespace NullWave.Services; @@ -75,7 +76,7 @@ string First(string key) => .FirstOrDefault() ?? string.Empty; if (kind == SpotifyPageKind.Playlist) artist = string.Empty; - _ = int.TryParse(First("music:duration"), out var seconds); + _ = int.TryParse(First("music:duration"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds); var trackUrls = tags .Where(t => t.Key.Equals("music:song", StringComparison.OrdinalIgnoreCase)) diff --git a/Services/Library/ExportService.cs b/Services/Library/ExportService.cs index bc7804c..88c4759 100644 --- a/Services/Library/ExportService.cs +++ b/Services/Library/ExportService.cs @@ -1,11 +1,19 @@ +using System; using System.Collections.Generic; using System.IO; +using System.Linq; 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 +29,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 +52,114 @@ 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 Task ExportToFolderAsync(IReadOnlyList tracks, string destDir, CancellationToken ct = default) + { + return Task.Run(() => + { + 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 + { + 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; + } + + 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++; + } + } + + // 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 new ExportFolderResult(copied, skipped, failed, m3uPath); + } + finally + { + // ALWAYS restore TagLib's global state, even if cancellation or error. + TagLib.Id3v2.Tag.ForceDefaultVersion = prevForce; + TagLib.Id3v2.Tag.DefaultVersion = prevVer; + } + }, ct); + } + private static string? CsvSafe(string? value) { if (string.IsNullOrEmpty(value)) return value; diff --git a/Services/Library/LibraryService.Maintenance.cs b/Services/Library/LibraryService.Maintenance.cs index c386e13..5f6f108 100644 --- a/Services/Library/LibraryService.Maintenance.cs +++ b/Services/Library/LibraryService.Maintenance.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Avalonia.Threading; using NullWave.Helpers; @@ -16,23 +17,139 @@ public partial class LibraryService private void CleanupBadUrls() { List bad; - lock (_tracksLock) { bad = _tracks.Where(t => !string.IsNullOrEmpty(t.Url) && !SourceDetector.IsPlayableUrl(t.Url) && string.IsNullOrEmpty(t.FilePath)).ToList(); } + lock (_tracksLock) + { + bad = _tracks.Where(t => !string.IsNullOrEmpty(t.Url) + && !SourceDetector.IsPlayableUrl(t.Url) + && string.IsNullOrEmpty(t.FilePath) + && !t.IsOffline).ToList(); + } if (bad.Count == 0) return; foreach (var t in bad) - Log.Warning("[LibraryService] Removing unplayable URL from DB: {Url}", t.Url); + { + t.IsOffline = true; + _db.Update(t); + Log.Warning("[LibraryService] Found unplayable URL, marking as offline: {Url}", t.Url); + } + StateVersion++; + Log.Information("[LibraryService] Marked {Count} bad tracks as offline in DB", bad.Count); + } + + public int RelinkMissingFiles() + { + var missing = GetAll().Where(t => !string.IsNullOrEmpty(t.FilePath) && !File.Exists(t.FilePath)).ToList(); + if (missing.Count == 0) return 0; + + var pref = _prefs?.Current.DownloadDirectory; + var downloadsDir = string.IsNullOrWhiteSpace(pref) ? NullWavePaths.DownloadsDir : pref; + + if (string.IsNullOrWhiteSpace(downloadsDir) || !Directory.Exists(downloadsDir)) return 0; + + var audioExts = new[] { ".mp3", ".m4a", ".flac", ".wav", ".ogg", ".opus" }; + var orphans = Directory.EnumerateFiles(downloadsDir, "*.*", SearchOption.AllDirectories) + .Where(f => audioExts.Contains(Path.GetExtension(f).ToLowerInvariant())) + .ToList(); + + int relinked = 0; + + foreach (var track in missing) + { + var urlId = ExtractYouTubeId(track.Url); + var id8 = track.Id.ToString("N")[..8]; + var wanted = TrackNormalizer.NormalizeForCompare($"{track.Artist} - {track.Title}"); + + var match = orphans.FirstOrDefault(f => MatchesRelinkCandidate(f, id8, urlId, wanted)); + + if (match != null) + { + track.FilePath = match; + _db.Update(track); + orphans.Remove(match); + relinked++; + Log.Information("[LibraryService] Relinked track {Title} to {Path}", track.Title, match); + } + } + + if (relinked > 0) StateVersion++; + Log.Information("[LibraryService] Auto-relinked {Count} missing tracks using filename suffixes.", relinked); + return relinked; + } - lock (_tracksLock) { foreach (var t in bad) { _tracks.Remove(t); _db.Delete(t.Id); } } + public bool TryRelink(Track track) + { + if (track == null || string.IsNullOrEmpty(track.FilePath) || File.Exists(track.FilePath)) + return false; + + var pref = _prefs?.Current.DownloadDirectory; + var downloadsDir = string.IsNullOrWhiteSpace(pref) ? NullWavePaths.DownloadsDir : pref; + + if (string.IsNullOrWhiteSpace(downloadsDir) || !Directory.Exists(downloadsDir)) + return false; + + var audioExts = new[] { ".mp3", ".m4a", ".flac", ".wav", ".ogg", ".opus" }; + var urlId = ExtractYouTubeId(track.Url); + var id8 = track.Id.ToString("N")[..8]; + var wanted = TrackNormalizer.NormalizeForCompare($"{track.Artist} - {track.Title}"); + + string? match = null; + + try + { + match = Directory.EnumerateFiles(downloadsDir, "*.*", SearchOption.AllDirectories) + .Where(f => audioExts.Contains(Path.GetExtension(f).ToLowerInvariant())) + .FirstOrDefault(f => MatchesRelinkCandidate(f, id8, urlId, wanted)); + } + catch (Exception ex) + { + Log.Warning(ex, "[LibraryService] TryRelink scan failed for {Title}", track.Title); + return false; + } + + if (match == null) return false; + + track.FilePath = match; + _db.Update(track); StateVersion++; - Log.Information("[LibraryService] Cleaned {Count} bad tracks from DB", bad.Count); + + Log.Information("[LibraryService] TryRelink repaired {Title} -> {Path}", track.Title, match); + 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; + var match = Regex.Match(url, @"(?:v=|\/)([0-9A-Za-z_-]{11})"); + return match.Success ? match.Groups[1].Value : null; } - // PERF: collects results off-UI, then applies them in ONE UI-thread wave via - // ApplyArtBatch. No per-track PropertyChanged storm, no LibraryChanged rebuild. private async Task BackfillAlbumArt() { if (_metadata == null) return; - List targets; lock (_tracksLock) { @@ -42,7 +159,6 @@ private async Task BackfillAlbumArt() } if (targets.Count == 0) return; - // Let the window finish its first paints before touching the disk await Task.Delay(1500); var results = new List<(Track Track, string Path)>(); @@ -60,7 +176,6 @@ private async Task BackfillAlbumArt() } if (++count % 25 == 0) await Task.Delay(16); } - ApplyArtBatch(results); } @@ -82,7 +197,6 @@ private void BackfillYouTubeThumbnails() } catch (Exception ex) { Log.Warning(ex, "[LibraryService] YT thumb backfill failed for {Title}", t.Title); } } - ApplyArtBatch(results); } @@ -113,7 +227,6 @@ private void BackfillSoundCloudThumbnails() if (results.Count == 0 && metaUpdates.Count == 0) return; - // ONE UI-thread wave for all mutations, then ONE DB transaction. Dispatcher.UIThread.Post(() => { foreach (var (t, p) in results) t.AlbumArtPath = p; @@ -131,17 +244,9 @@ private void BackfillSoundCloudThumbnails() }); } - /// - /// Applies fetched art in a single UI-thread wave and persists in one transaction. - /// Deliberately does NOT raise LibraryChanged: that event triggers - /// LibraryViewModel.Refresh() -> Tracks.ReplaceAll(), which rebuilds every row - /// container and invalidates the whole window (the full-screen flash we captured). - /// Per-row PropertyChanged is sufficient: rows bind AlbumArtPath directly. - /// private void ApplyArtBatch(List<(Track Track, string Path)> results) { if (results.Count == 0) return; - Dispatcher.UIThread.Post(() => { foreach (var (t, p) in results) t.AlbumArtPath = p; @@ -153,13 +258,6 @@ private void ApplyArtBatch(List<(Track Track, string Path)> results) }); } - /// - /// Surgical cache heal: deletes every cached YouTube thumbnail (yt_*.jpg), - /// invalidates its decoded cache entries, nulls AlbumArtPath on the tracks that - /// pointed at them, and persists in one transaction. Caller then runs - /// RebackfillThumbnails() to regenerate via the aspect-preserving ladder. - /// Embedded (hex-named) art is untouched - those are true 1:1 album covers. - /// public int RefetchYouTubeThumbnails() { List affected; @@ -168,7 +266,7 @@ public int RefetchYouTubeThumbnails() affected = _tracks.Where(t => !string.IsNullOrEmpty(t.AlbumArtPath) && Path.GetFileName(t.AlbumArtPath!) .StartsWith("yt_", StringComparison.OrdinalIgnoreCase)) - .ToList(); + .ToList(); } if (affected.Count == 0) return 0; @@ -184,6 +282,7 @@ public int RefetchYouTubeThumbnails() _db.RunInTransaction(() => { foreach (var t in affected) _db.Update(t); }); StateVersion++; Dispatcher.UIThread.Post(() => LibraryChanged?.Invoke(this, EventArgs.Empty)); + Log.Information("[LibraryService] YouTube thumb heal: cleared {Count} path(s), deleted {Deleted} file(s)", affected.Count, deleted); return affected.Count; @@ -206,14 +305,11 @@ public int ClearAllArt() lock (_tracksLock) { targets = _tracks.Where(t => !string.IsNullOrEmpty(t.AlbumArtPath)).ToList(); } if (targets.Count == 0) return 0; - // Persist off-UI in one transaction first... - foreach (var t in targets) t.AlbumArtPath = null; // model-only, off-UI thread + foreach (var t in targets) t.AlbumArtPath = null; _db.RunInTransaction(() => { foreach (var t in targets) _db.Update(t); }); StateVersion++; - // ...then ONE UI wave so rows drop their art in a single invalidation pass. Dispatcher.UIThread.Post(() => LibraryChanged?.Invoke(this, EventArgs.Empty)); - try { if (Directory.Exists(NullWavePaths.ArtCacheDir)) foreach (var f in Directory.EnumerateFiles(NullWavePaths.ArtCacheDir)) try { File.Delete(f); } catch { } } catch { } return targets.Count; } @@ -225,7 +321,7 @@ public int BackfillDurations() var targets = GetAll().Where(t => t.Duration == TimeSpan.Zero && !string.IsNullOrEmpty(t.FilePath) && File.Exists(t.FilePath)).ToList(); foreach (var t in targets) { - var (_, _, dur) = fetcher.Fetch(t.FilePath!); + var (_, _, dur) = fetcher.FetchFromLocalFile(t.FilePath!); if (dur > TimeSpan.Zero) { t.Duration = dur; Update(t); updated++; } } return updated; @@ -251,14 +347,15 @@ public int ReimportAssets(string dir) var exts = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mp3", ".flac", ".m4a", ".ogg", ".wav", ".aac", ".opus" }; var files = Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories).Where(f => exts.Contains(Path.GetExtension(f))).ToList(); var candidates = GetAll().Where(t => string.IsNullOrEmpty(t.FilePath) || !File.Exists(t.FilePath)).ToList(); - var matchedIds = new HashSet(); var matchedFiles = new HashSet(StringComparer.OrdinalIgnoreCase); var toUpdate = new List(); + var matchedIds = new HashSet(); var matchedFiles = new HashSet(StringComparer.OrdinalIgnoreCase); var toUpdate = new List(); foreach (var f in files) { var name = Path.GetFileNameWithoutExtension(f); var exact = candidates.FirstOrDefault(t => !matchedIds.Contains(t.Id) && string.Equals(t.Title, name, StringComparison.OrdinalIgnoreCase)); if (exact != null) { exact.FilePath = f; matchedIds.Add(exact.Id); matchedFiles.Add(f); toUpdate.Add(exact); continue; } } + foreach (var f in files) { if (matchedFiles.Contains(f)) continue; @@ -267,6 +364,7 @@ public int ReimportAssets(string dir) var match = candidates.FirstOrDefault(t => !matchedIds.Contains(t.Id) && Tokenize(t.Title).Count >= 2 && Tokenize(t.Title).IsSubsetOf(fTok)); if (match != null) { match.FilePath = f; matchedIds.Add(match.Id); matchedFiles.Add(f); toUpdate.Add(match); } } + foreach (var t in toUpdate) _db.Update(t); if (toUpdate.Count > 0) StateVersion++; return toUpdate.Count; @@ -324,7 +422,9 @@ public int ForceCleanTitles() { t.TitleForceCleaned = true; toUpdate.Add(t); continue; } t.Title = parsed.Value.Title; t.Artist = parsed.Value.Artist; t.TitleForceCleaned = true; - toUpdate.Add(t); UpdateFileTags(t); cleaned++; + toUpdate.Add(t); + UpdateFileTags(t); + cleaned++; } if (toUpdate.Count > 0) { _db.RunInTransaction(() => { foreach (var t in toUpdate) _db.Update(t); }); StateVersion++; } return cleaned; @@ -338,21 +438,82 @@ 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); - var known = new HashSet(GetAll().Where(t => !string.IsNullOrEmpty(t.FilePath)).Select(t => Path.GetFullPath(t.FilePath!)), StringComparer.OrdinalIgnoreCase); - var files = Directory.GetFiles(downloadsDir, "*.*", SearchOption.TopDirectoryOnly).Where(f => f.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase) || f.EndsWith(".m4a", StringComparison.OrdinalIgnoreCase)).ToList(); - int orphaned = 0, deleted = 0, failed = 0; + + var known = new HashSet( + GetAll().Where(t => !string.IsNullOrEmpty(t.FilePath)).Select(t => Path.GetFullPath(t.FilePath!)), + StringComparer.OrdinalIgnoreCase); + + if (known.Count == 0) + { + Log.Warning("[LibraryService] SweepOrphanedFiles aborted: Database has 0 linked files. Refusing to delete downloads."); + return (0, -1, 0, 0); + } + + var audioExts = new[] { ".mp3", ".m4a", ".flac", ".wav", ".ogg", ".opus" }; + var files = Directory.GetFiles(downloadsDir, "*.*", SearchOption.TopDirectoryOnly) + .Where(f => audioExts.Contains(Path.GetExtension(f).ToLowerInvariant())) + .ToList(); + + // SWEEP GUARD: Abort if DB paths are stale and it looks like everything is orphaned + var orphanCount = files.Count(f => !known.Contains(Path.GetFullPath(f))); + if (files.Count >= 10 && orphanCount * 2 > files.Count) + { + Log.Warning("[LibraryService] SweepOrphanedFiles aborted: {Orphans}/{Files} files look orphaned - DB paths are probably stale.", orphanCount, files.Count); + return (files.Count, -1, 0, 0); + } + + int orphaned = 0, moved = 0, failed = 0; + + var orphansDir = Path.Combine(downloadsDir, "_orphans"); + foreach (var f in files) { var full = Path.GetFullPath(f); if (known.Contains(full)) continue; orphaned++; + if (dryRun) continue; - try { File.Delete(full); deleted++; } catch { failed++; } + + try + { + Directory.CreateDirectory(orphansDir); + 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++; + } } - return (files.Count, orphaned, deleted, failed); + + return (files.Count, orphaned, moved, failed); } public (int Scanned, int DuplicateGroups, int Removed) RemoveDuplicates(bool dryRun = true) @@ -360,7 +521,6 @@ public int ClearTagsForReSync() var allTracks = GetAll(); int scannedCount = allTracks.Count; - // FIX: Use IdentityKey for smarter grouping, ignore placeholders var groups = allTracks .Where(t => !TrackNormalizer.IsPlaceholderTitle(t.Title) && !TrackNormalizer.IsPlaceholderArtist(t.Artist)) .GroupBy(t => TrackNormalizer.IdentityKey(t.Title, t.Artist)) @@ -368,6 +528,7 @@ public int ClearTagsForReSync() .ToList(); int removed = 0; + foreach (var group in groups) { var ordered = group.OrderByDescending(t => HasVerifiedFile(t)) @@ -376,18 +537,19 @@ public int ClearTagsForReSync() .ThenByDescending(t => t.IsFavorite) .ThenBy(t => t.DateAdded) .ToList(); - + var keeper = ordered[0]; + foreach (var dup in ordered.Skip(1)) { - if (!dryRun) - { - Remove(dup.Id); // FIX: Routes through Remove() to clean queue and playlists - removed++; + if (!dryRun) + { + Remove(dup.Id); + removed++; } } } - // StateVersion is already incremented inside Remove() + return (scannedCount, groups.Count, removed); } diff --git a/Services/Library/LibraryService.MetadataHelpers.cs b/Services/Library/LibraryService.MetadataHelpers.cs index 690aa4f..5574798 100644 --- a/Services/Library/LibraryService.MetadataHelpers.cs +++ b/Services/Library/LibraryService.MetadataHelpers.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using NullWave.Helpers; using NullWave.Models; @@ -23,5 +24,8 @@ private bool HasVerifiedFile(Track t) } private static HashSet Tokenize(string s) => - Regex.Matches(s.ToLowerInvariant(), @"[a-z0-9]+").Select(m => m.Value).Where(w => w.Length > 2).ToHashSet(); + Regex.Matches(s.Normalize(NormalizationForm.FormKC).ToLowerInvariant(), @"[\p{L}\p{M}\p{N}]+") + .Select(m => m.Value) + .Where(w => w.Length > 2 || w.Any(c => c > '\u02FF')) + .ToHashSet(); } \ No newline at end of file diff --git a/Services/Library/LibraryService.Sync.cs b/Services/Library/LibraryService.Sync.cs index 0f9d517..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; @@ -41,47 +41,53 @@ public void NormalizeLocalFile(Track t) if (currentlyPlayingPath != null && string.Equals(Path.GetFullPath(track.FilePath!), Path.GetFullPath(currentlyPlayingPath), StringComparison.OrdinalIgnoreCase)) return (false, false); + // S6 SAFETY GUARD: Never touch imported user files. Only sync files NullWave downloaded. + 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!); - var name = SanitizeFileName($"{track.Artist} - {track.Title}") + ext; + + 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; } @@ -91,12 +97,12 @@ public void NormalizeLocalFile(Track t) public void UpdateFileTags(Track track) { if (_metadata == null || string.IsNullOrEmpty(track.FilePath) || !File.Exists(track.FilePath)) return; + // N2: central enforcement. + if (!CanWriteTags(track.FilePath)) + { + Log.Debug("[LibraryService] TagWriteMode denied write for {Path}", track.FilePath); + return; + } _metadata.WriteTagsToFile(track.FilePath, track.Title, track.Artist); } - - private static string SanitizeFileName(string name) - { - var invalid = Path.GetInvalidFileNameChars(); - return new string(name.Select(c => invalid.Contains(c) ? ' ' : c).ToArray()).Trim(); - } } \ No newline at end of file diff --git a/Services/Library/LibraryService.cs b/Services/Library/LibraryService.cs index 2187dac..bbb3b6c 100644 --- a/Services/Library/LibraryService.cs +++ b/Services/Library/LibraryService.cs @@ -18,8 +18,6 @@ public record DuplicateGroup(string Title, string Artist, List Tracks); public record ArtistMergeGroup(string CanonicalName, List Variants, int TotalTracks); public record FileSyncReport(int Scanned, int Retagged, int Renamed, int Skipped, int Failed); -// REMOVED: public enum SortField { ... } (Now lives in Models/Library/SortField.cs) - public partial class LibraryService : IDisposable { private readonly DatabaseService _db; @@ -30,7 +28,6 @@ public partial class LibraryService : IDisposable private readonly List _queue = new(); private readonly List _history = new(); - // --- Step A: O(1) Duplicate Detection Index --- private readonly Dictionary _identityIndex = new(); private int _identityIndexVersion = -1; @@ -50,6 +47,8 @@ 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}", + string.Join(", ", _tracks.GroupBy(t => t.MediaType).Select(g => $"{g.Key}={g.Count()}"))); if (!startBackgroundWork) return; @@ -57,11 +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) { @@ -82,12 +80,68 @@ public LibraryService(DatabaseService db, MetadataService? metadata = null, Pref if (++i % 40 == 0) await Task.Delay(8); } Log.Information("[LibraryService] Decoded {Count}/{Total} track art bitmaps", warmed.Count, targets.Count); - BitmapPrewarm.PrewarmGpuTextures(warmed, 24, 96); }); } - // --- Index Helpers --- + /// + /// 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) + { + if (string.IsNullOrEmpty(filePath)) return false; + try + { + var fullPath = Path.GetFullPath(filePath); + var defaultDir = Path.GetFullPath(NullWavePaths.DownloadsDir); + var defaultCheck = defaultDir.EndsWith(Path.DirectorySeparatorChar.ToString()) + ? defaultDir : defaultDir + Path.DirectorySeparatorChar; + + 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)) + { + try + { + 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; @@ -101,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}"; } @@ -126,10 +180,7 @@ public void Add(Track track) } string fullPath; - try - { - fullPath = Path.GetFullPath(importedPath); - } + try { fullPath = Path.GetFullPath(importedPath); } catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException) { Log.Warning("[LibraryService] Blocked invalid or traversal file path: {Path}", importedPath); @@ -141,25 +192,25 @@ public void Add(Track track) Log.Warning("[LibraryService] Blocked invalid or traversal file path: {Path}", importedPath); return; } - track.FilePath = fullPath; } - // Step A: O(1) Duplicate Check & Index Update - lock (_tracksLock) + lock (_tracksLock) { EnsureIdentityIndex(); - if (GetIdentityKeys(track).Any(k => _identityIndex.TryGetValue(k, out var existingId) && existingId != track.Id)) - return; // Duplicate found, abort add + 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: tag writes go through the central CanWriteTags rule. UpdateFileTags(track); if (!string.IsNullOrEmpty(track.FilePath) && string.IsNullOrEmpty(track.AlbumArtPath) && _metadata != null) @@ -172,7 +223,7 @@ public void Add(Track track) if (!string.IsNullOrEmpty(artPath)) { track.AlbumArtPath = artPath; - Update(track); + Update(track); } } catch (Exception ex) @@ -184,54 +235,77 @@ public void Add(Track track) if (_prefs?.Current.AutoCleanMetadata == true) { - var split = TrackNormalizer.TrySplit(track.Title); - if (split != null && !string.IsNullOrWhiteSpace(split.Value.Artist) && !string.IsNullOrWhiteSpace(split.Value.Title)) + var (cleanArtist, cleanTitle) = TrackNormalizer.ResolveArtistTitle(track.Title, track.Artist); + if (cleanTitle != track.Title || cleanArtist != track.Artist) { - if (TrackNormalizer.IsPlaceholderArtist(track.Artist)) - track.Artist = split.Value.Artist; - if (TrackNormalizer.IsPlaceholderTitle(track.Title)) - track.Title = split.Value.Title; - - Update(track); // Persist cleaned metadata + track.Title = cleanTitle; + track.Artist = cleanArtist; + Update(track); } } } - public void Remove(Guid id) + /// + /// 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. + /// + 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); - // Clean index 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); + + if (recycleFile && track != null && !string.IsNullOrEmpty(track.FilePath) && File.Exists(track.FilePath)) + { + // Unified rule: only managed downloads may be recycled. + if (IsManagedDownload(track.FilePath)) + { + 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 + { + 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); - lock (_tracksLock) { var idx = _tracks.FindIndex(t => t.Id == track.Id); if (idx >= 0) _tracks[idx] = track; - - // Invalidate index so it rebuilds on next check (safest for metadata edits) StateVersion++; - _identityIndexVersion = -1; + _identityIndexVersion = -1; } OnLibraryChanged(); } @@ -251,7 +325,6 @@ public Task UpdateTrackMetadataAsync(Track track) }); } - // --- Step B: Unified Sort Logic --- private static IReadOnlyList SortTracks(IEnumerable source, SortField field, bool ascending) { if (field == SortField.Length) @@ -269,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 // DateAdded, Custom fallback + _ => t => t.DateAdded }; - + var sorted = ascending ? source.OrderBy(key) : source.OrderByDescending(key); return sorted.ThenBy(t => t.Title, StringComparer.OrdinalIgnoreCase).ToList(); } @@ -286,9 +359,7 @@ public IReadOnlyList Search(string query, SortField field = SortField.Dat } public HashSet? SearchFts(string query) => _db.SearchFts(query); - public void RebuildFtsIndex() => _db.RebuildFtsIndex(); - public IReadOnlyList FilterBySource(TrackSource source) => GetAll().Where(t => t.Source == source).ToList(); public IReadOnlyList GetFavorites() => GetAll().Where(t => t.IsFavorite).ToList(); public IReadOnlyList GetRecentlyAdded(int count = 20) => GetAll().OrderByDescending(t => t.DateAdded).Take(count).ToList(); @@ -316,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); @@ -377,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 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..02032a8 --- /dev/null +++ b/Services/Library/M3uParser.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +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, NumberStyles.Integer, CultureInfo.InvariantCulture, 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/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/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/Metadata/LocalMetadataFetcher.cs b/Services/Metadata/LocalMetadataFetcher.cs index c34db37..12b3134 100644 --- a/Services/Metadata/LocalMetadataFetcher.cs +++ b/Services/Metadata/LocalMetadataFetcher.cs @@ -42,7 +42,7 @@ public MediaType DetectMediaType(string filePath) catch { return (null, 0); } } - public (string Title, string Artist, TimeSpan Duration) Fetch(string filePath) + public (string Title, string Artist, TimeSpan Duration) FetchFromLocalFile(string filePath) { try { diff --git a/Services/Metadata/SoundCloudMetadataFetcher.cs b/Services/Metadata/SoundCloudMetadataFetcher.cs index ee946b3..11b674b 100644 --- a/Services/Metadata/SoundCloudMetadataFetcher.cs +++ b/Services/Metadata/SoundCloudMetadataFetcher.cs @@ -1,5 +1,6 @@ using System; using System.Security.Cryptography; +using System.Globalization; using System.Text; using System.Threading.Tasks; using NullWave.Helpers; @@ -38,7 +39,7 @@ public class SoundCloudMetadataFetcher var thumbUrl = lines.Length > 2 ? lines[2] : null; var duration = TimeSpan.Zero; - if (lines.Length > 3 && double.TryParse(lines[3], out var secs)) + if (lines.Length > 3 && double.TryParse(lines[3], NumberStyles.Float, CultureInfo.InvariantCulture, out var secs)) duration = TimeSpan.FromSeconds(secs); string? thumbPath = null; diff --git a/Services/Metadata/YtDlpMetadataFetcher.cs b/Services/Metadata/YtDlpMetadataFetcher.cs new file mode 100644 index 0000000..0411116 --- /dev/null +++ b/Services/Metadata/YtDlpMetadataFetcher.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using NullWave.Helpers; +using NullWave.Services; +using NullWave.Services.Integration; +using NullWave.Models; +using Serilog; + +namespace NullWave.Services.Metadata; + +public class YtDlpMetadataFetcher +{ + private readonly string _sourceName; + private readonly PreferencesService _prefsService; + + // FIX: Accept an optional PreferencesService for testability + public YtDlpMetadataFetcher(string sourceName, PreferencesService? prefs = null) + { + _sourceName = sourceName; + _prefsService = prefs ?? PreferencesService.Shared; + } + + public async Task<(string Title, string Artist, string? ThumbnailPath, TimeSpan Duration)> FetchAsync(string url) + { + // Use the injected service instead of the global singleton + var prefs = _prefsService.Current; + if (!prefs.EnableYtDlp) + { + return (string.Empty, string.Empty, null, TimeSpan.Zero); + } + + try + { + // Mixcloud's 'artist' is the tracklist, so use the DJ (uploader). Archive.org avoids account names. + var artistField = _sourceName switch + { + "Archive.org" => "%(artist,creator)s", + "Mixcloud" => "%(uploader,creator)s", + _ => "%(artist,creator,uploader)s", + }; + + var args = new List { + "--no-download", "--no-warnings", "--no-playlist", "--playlist-items", "1", + "--print", "%(title)s", + "--print", artistField, + "--print", "%(thumbnail)s", + "--print", "%(duration)s" + }; + + YtDlpArgs.AppendNetworkArgs(args, prefs); + args.Add("--"); + args.Add(url); + + var result = await ProcessRunner.RunAsync("yt-dlp", args.ToArray(), TimeSpan.FromSeconds(15), resolveViaPath: true); + + if (result.ExitCode != 0 || result.TimedOut || result.Canceled) + { + Log.Warning("[{Source}] metadata fetch failed or timed out for {Url}", _sourceName, url); + return (string.Empty, string.Empty, null, TimeSpan.Zero); + } + + var lines = result.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var title = lines.Length > 0 && lines[0] != "NA" && lines[0] != "None" ? lines[0] : string.Empty; + var artist = lines.Length > 1 && lines[1] != "NA" && lines[1] != "None" ? lines[1] : string.Empty; + var thumbUrl = lines.Length > 2 && lines[2] != "NA" && lines[2] != "None" ? lines[2] : null; + + var duration = TimeSpan.Zero; + if (lines.Length > 3 && double.TryParse(lines[3], NumberStyles.Float, CultureInfo.InvariantCulture, out var secs)) + duration = TimeSpan.FromSeconds(secs); + + string? thumbPath = null; + if (!string.IsNullOrEmpty(thumbUrl)) + { + var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(url)); + var hash = Convert.ToHexString(hashBytes)[..12]; + var prefix = _sourceName.ToLowerInvariant().Replace(" ", "").Replace(".", ""); + thumbPath = await ThumbnailDownloader.FetchAsync(thumbUrl, $"{prefix}_{hash}"); + } + + Log.Information("[{Source}] metadata fetched: {Title} by {Artist}", _sourceName, title, artist); + return (title, artist, thumbPath, duration); + } + catch (Exception ex) + { + Log.Error(ex, "[{Source}] metadata fetch failed for {Url}", _sourceName, url); + return (string.Empty, string.Empty, null, TimeSpan.Zero); + } + } +} \ No newline at end of file diff --git a/Services/MetadataService.cs b/Services/MetadataService.cs index e73a1c9..9c0bde0 100644 --- a/Services/MetadataService.cs +++ b/Services/MetadataService.cs @@ -19,6 +19,13 @@ public class MetadataService private readonly LocalMetadataFetcher _local; private readonly LastFmService _lastFm; private readonly UrlParserService _urlParser = new(); + private readonly YtDlpMetadataFetcher _ytDlpFallback = new("YouTube"); + + // NEW: Generic fetchers for yt-dlp-supported sources + private readonly YtDlpMetadataFetcher _bandcamp; + private readonly YtDlpMetadataFetcher _audiomack; + private readonly YtDlpMetadataFetcher _archiveOrg; + private readonly YtDlpMetadataFetcher _mixcloud; public MetadataService(ConfigService config, LastFmService lastFm) { @@ -26,6 +33,12 @@ public MetadataService(ConfigService config, LastFmService lastFm) _soundCloud = new SoundCloudMetadataFetcher(); _local = new LocalMetadataFetcher(); _lastFm = lastFm; + + // NEW: Initialize generic fetchers + _bandcamp = new YtDlpMetadataFetcher("Bandcamp"); + _audiomack = new YtDlpMetadataFetcher("Audiomack"); + _archiveOrg = new YtDlpMetadataFetcher("Archive.org"); + _mixcloud = new YtDlpMetadataFetcher("Mixcloud"); } public async Task<(string Title, string Artist, string? ThumbnailPath, TimeSpan Duration)> FetchFromUrlAsync(string url) @@ -33,14 +46,42 @@ public MetadataService(ConfigService config, LastFmService lastFm) var source = SourceDetector.Detect(url); return source switch { - TrackSource.YouTube => await _youTube.FetchAsync(url), + TrackSource.YouTube => await FetchYouTubeWithFallbackAsync(url), TrackSource.SoundCloud => await _soundCloud.FetchAsync(url), TrackSource.Spotify => await FetchSpotifyMetadataAsync(url), TrackSource.LastFm => await FetchLastFmUrlAsync(url), + // NEW: Route to generic yt-dlp fetcher + TrackSource.Bandcamp => await _bandcamp.FetchAsync(url), + TrackSource.Audiomack => await _audiomack.FetchAsync(url), + TrackSource.ArchiveOrg => await _archiveOrg.FetchAsync(url), + TrackSource.Mixcloud => await _mixcloud.FetchAsync(url), _ => ("Unknown Title", "Unknown Artist", null, TimeSpan.Zero) }; } + private async Task<(string Title, string Artist, string? ThumbnailPath, TimeSpan Duration)> FetchYouTubeWithFallbackAsync(string url) + { + try + { + var result = await _youTube.FetchAsync(url); + // If the API returns placeholders or fails, use our yt-dlp fallback + if (result.Title == "Unknown Title" || string.IsNullOrWhiteSpace(result.Title)) + { + Log.Information("[MetadataService] YouTube API returned no data, falling back to yt-dlp for {Url}", url); + var fb = await _ytDlpFallback.FetchAsync(url); + // Keep the API's thumbnail if yt-dlp didn't find one + return (fb.Title, fb.Artist, fb.ThumbnailPath ?? result.ThumbnailPath, fb.Duration); + } + return result; + } + catch (Exception ex) + { + // FIX: Removed reference to out-of-scope 'result' variable + Log.Warning(ex, "[MetadataService] YouTube API threw, falling back to yt-dlp for {Url}", url); + return await _ytDlpFallback.FetchAsync(url); + } + } + public (string? Album, int TrackNumber) FetchAlbumAndTrackNumber(string filePath) => _local.FetchAlbumAndTrackNumber(filePath); @@ -48,7 +89,61 @@ public MediaType DetectLocalMediaType(string filePath) => _local.DetectMediaType(filePath); public (string Title, string Artist, TimeSpan Duration) FetchFromLocalFile(string filePath) - => _local.Fetch(filePath); + { + TagLib.File? file = null; + + // Retry loop to handle file locks from yt-dlp or LibVLC + for (int attempt = 0; attempt < 3; attempt++) + { + try + { + file = TagLib.File.Create(filePath); + break; // Success, exit loop + } + catch (IOException) when (attempt < 2) + { + // File is locked by another process, wait a bit and try again + System.Threading.Thread.Sleep(200 * (attempt + 1)); + } + catch (Exception ex) + { + Log.Error(ex, "TagLib failed for {Path}, falling back to filename", filePath); + return (Path.GetFileNameWithoutExtension(filePath), "Unknown", TimeSpan.Zero); + } + } + + if (file == null) + { + Log.Debug("TagLib file locked or unreadable after retries, falling back to filename: {Path}", filePath); + return (Path.GetFileNameWithoutExtension(filePath), "Unknown", TimeSpan.Zero); + } + + try + { + using (file) // Ensure proper disposal + { + var title = file.Tag.Title; + var artist = file.Tag.FirstPerformer + ?? (file.Tag.Performers.Length > 0 + ? string.Join(", ", file.Tag.Performers) + : null); + var duration = file.Properties.Duration; + + if (string.IsNullOrWhiteSpace(title)) + title = Path.GetFileNameWithoutExtension(filePath); + if (string.IsNullOrWhiteSpace(artist)) + artist = "Unknown"; + + Log.Information("Local file tags read: {Title} by {Artist}", title, artist); + return (title, artist, duration); + } + } + catch (Exception ex) + { + Log.Error(ex, "TagLib read failed for {Path}, falling back to filename", filePath); + return (Path.GetFileNameWithoutExtension(filePath), "Unknown", TimeSpan.Zero); + } + } private static readonly HttpClient _sharedHttp = new() { Timeout = TimeSpan.FromSeconds(10) }; @@ -90,7 +185,6 @@ public MediaType DetectLocalMediaType(string filePath) return (title, artist, null, TimeSpan.Zero); } - // FIX: Made async and wrapped in Task.Run to prevent blocking the UI thread during startup public async Task ExtractAlbumArtAsync(string filePath) { try diff --git a/Services/Plugins/BuiltInPluginManifest.cs b/Services/Plugins/BuiltInPluginManifest.cs new file mode 100644 index 0000000..824e90d --- /dev/null +++ b/Services/Plugins/BuiltInPluginManifest.cs @@ -0,0 +1,21 @@ +using System; +using NullWave.Plugins.Contracts; + +namespace NullWave.Services.Plugins; + +/// +/// Factory for creating PluginManifest instances from built-in IPlugin/IProvider objects. +/// Lives in the main app (not Contracts) because it references IProvider. +/// +public static class BuiltInPluginManifest +{ + public static PluginManifest FromBuiltIn(IPlugin p) => new( + p is IProvider ip ? ip.Descriptor.Id : p.Name, + p.Name, + "NullWave", + "built-in", + p.Description, + p is IProvider q ? new[] { q.Descriptor.Kind } : Array.Empty(), + true, + PluginAvailability.Installed); +} \ No newline at end of file diff --git a/Services/Plugins/IProvider.cs b/Services/Plugins/IProvider.cs new file mode 100644 index 0000000..78c6169 --- /dev/null +++ b/Services/Plugins/IProvider.cs @@ -0,0 +1,12 @@ +using NullWave.Plugins.Contracts; + +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..0da04de 100644 --- a/Services/Plugins/LastFmMetadataProvider.cs +++ b/Services/Plugins/LastFmMetadataProvider.cs @@ -6,6 +6,7 @@ using NullWave.Models; using NullWave.Services; using Serilog; +using NullWave.Plugins.Contracts; namespace NullWave.Services.Plugins; @@ -18,13 +19,18 @@ public class LastFmMetadataProvider : IMetadataProvider private readonly LastFmService _inner; private readonly PreferencesService _prefs; + public string Id => Descriptor.Id; public string Name => "Last.fm"; public string Description => "Metadata enrichment, artist bios, and scrobbling"; public PluginState State { get; set; } = PluginState.Unavailable; public bool IsEnabled { get; set; } = true; - // I expose the inner service so existing consumers (TrackDetailViewModel, - // LastFmEnrichmentService) can still access it directly during migration. + public ProviderDescriptor Descriptor { get; } = new( + Id: "lastfm", + Kind: ProviderKind.Metadata, + DisplayName: "Last.fm" + ); + public LastFmService Inner => _inner; public LastFmMetadataProvider(LastFmService inner, PreferencesService prefs) @@ -34,7 +40,7 @@ public LastFmMetadataProvider(LastFmService inner, PreferencesService prefs) IsEnabled = prefs.Current.EnableLastFm; } - public Task InitializeAsync(CancellationToken ct = default) + public Task InitializeAsync(IPluginHost host, CancellationToken ct = default) { if (!IsEnabled) { @@ -66,7 +72,6 @@ public Task ShutdownAsync(CancellationToken ct = default) if (!IsEnabled || State != PluginState.Available) return null; - // identifier is expected to be "title|artist" for Last.fm var parts = identifier.Split('|', 2); if (parts.Length != 2) return null; @@ -82,16 +87,12 @@ public Task ShutdownAsync(CancellationToken ct = default) }; } - // Proxy for artist info to support TrackDetailViewModel's Artist Info panel public Task GetArtistInfoAsync(string artist) => _inner.GetArtistInfoAsync(artist); - // Proxy for scrobbling to unblock MainViewModel public Task ScrobbleAsync(string title, string artist, DateTime playedAt) => _inner.ScrobbleAsync(title, artist, playedAt); - // Expose these so the rest of the app can still check configuration state - // via the provider if needed, without needing direct access to _inner. public bool IsConfiguredForRead => _inner.IsConfiguredForRead; public bool IsConfiguredForScrobbling => _inner.IsConfiguredForScrobbling; } \ No newline at end of file diff --git a/Services/Plugins/OllamaAIProvider.cs b/Services/Plugins/OllamaAIProvider.cs index be514e2..8e88846 100644 --- a/Services/Plugins/OllamaAIProvider.cs +++ b/Services/Plugins/OllamaAIProvider.cs @@ -6,6 +6,7 @@ using NullWave.Models; using NullWave.Services.SmartSorting; using Serilog; +using NullWave.Plugins.Contracts; namespace NullWave.Services.Plugins; @@ -19,11 +20,18 @@ public class OllamaAIProvider : IAIProvider private readonly LocalAIService _inner; private readonly PreferencesService _prefs; + public string Id => Descriptor.Id; public string Name => "Ollama Local AI"; public string Description => "On-device AI for smart shuffle and mood playlist ranking"; 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) @@ -37,7 +45,7 @@ public OllamaAIProvider(LocalAIService inner, PreferencesService prefs) public event Action? FallbackNotice; - public async Task InitializeAsync(CancellationToken ct = default) + public async Task InitializeAsync(IPluginHost host, CancellationToken ct = default) { if (!IsEnabled) { diff --git a/Services/Plugins/OpenWeatherProvider.cs b/Services/Plugins/OpenWeatherProvider.cs index 3a7e0d5..6d4f52c 100644 --- a/Services/Plugins/OpenWeatherProvider.cs +++ b/Services/Plugins/OpenWeatherProvider.cs @@ -9,6 +9,7 @@ using NullWave.Models; using NullWave.Services.SmartSorting; using Serilog; +using NullWave.Plugins.Contracts; namespace NullWave.Services.Plugins; @@ -22,11 +23,18 @@ public class OpenWeatherProvider : IWeatherProvider private readonly WeatherService _inner; private readonly PreferencesService _prefs; + public string Id => Descriptor.Id; public string Name => "OpenWeather"; public string Description => "Fetches local weather for AI mood playlists"; 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; @@ -34,7 +42,7 @@ public OpenWeatherProvider(WeatherService inner, PreferencesService prefs) IsEnabled = prefs.Current.EnableOpenWeather; } - public Task InitializeAsync(CancellationToken ct = default) + public Task InitializeAsync(IPluginHost host, CancellationToken ct = default) { if (!IsEnabled) { diff --git a/Services/Plugins/PluginDiscovery.cs b/Services/Plugins/PluginDiscovery.cs new file mode 100644 index 0000000..6679b86 --- /dev/null +++ b/Services/Plugins/PluginDiscovery.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using NullWave.Plugins.Contracts; +using Serilog; + +namespace NullWave.Services.Plugins; + +public class PluginDiscovery +{ + private readonly string _pluginsDirectory; + private readonly Dictionary _contexts = new(); + + public PluginDiscovery(string pluginsDirectory) + { + _pluginsDirectory = pluginsDirectory; + Directory.CreateDirectory(_pluginsDirectory); + } + + public async Task> DiscoverAndLoadAsync(Func hostFactory) + { + var loaded = new List<(PluginManifest, IPlugin)>(); + + if (!Directory.Exists(_pluginsDirectory)) + return loaded; + + var pluginDirs = Directory.GetDirectories(_pluginsDirectory); + + foreach (var pluginDir in pluginDirs) + { + try + { + var manifestPath = Path.Combine(pluginDir, "plugin.json"); + if (!File.Exists(manifestPath)) + continue; + + var json = await File.ReadAllTextAsync(manifestPath); + var manifest = JsonSerializer.Deserialize(json); + if (manifest == null) continue; + + var dllName = manifest.EntryPoint.Split(',')[1].Trim(); + var dllPath = Path.Combine(pluginDir, $"{dllName}.dll"); + if (!File.Exists(dllPath)) + { + Log.Warning("[PluginDiscovery] DLL not found for {PluginId}: {Path}", manifest.Id, dllPath); + continue; + } + + var context = new PluginLoadContext(dllPath); + _contexts[manifest.Id] = context; + + var assembly = context.LoadFromAssemblyPath(dllPath); + var pluginTypeName = manifest.EntryPoint.Split(',')[0]; + var pluginType = assembly.GetType(pluginTypeName); + + if (pluginType == null) + { + Log.Warning("[PluginDiscovery] Plugin type {Type} not found in {PluginId}", + pluginTypeName, manifest.Id); + continue; + } + + var plugin = (IPlugin?)Activator.CreateInstance(pluginType); + if (plugin == null) continue; + + // Create a per-plugin host instance + var pluginHost = hostFactory(manifest); + + var success = await plugin.InitializeAsync(pluginHost, default); + if (success) + { + loaded.Add((manifest, plugin)); + Log.Information("[PluginDiscovery] Loaded plugin: {Name} v{Version}", + manifest.Name, manifest.Version); + } + else + { + Log.Information("[PluginDiscovery] Plugin {Name} initialized but returned inactive (state={State})", + manifest.Name, plugin.State); + } + } + catch (Exception ex) + { + Log.Error(ex, "[PluginDiscovery] Failed to load plugin from {Dir}", pluginDir); + } + } + + return loaded; + } + + public void UnloadAll() + { + foreach (var context in _contexts.Values) + { + context.Unload(); + } + _contexts.Clear(); + } +} + +internal class PluginLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver _resolver; + + public PluginLoadContext(string pluginPath) : base(isCollectible: true) + { + _resolver = new AssemblyDependencyResolver(pluginPath); + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + var path = _resolver.ResolveAssemblyToPath(assemblyName); + if (path != null) + return LoadFromAssemblyPath(path); + + return null; + } +} \ No newline at end of file diff --git a/Services/Plugins/PluginHost.cs b/Services/Plugins/PluginHost.cs new file mode 100644 index 0000000..375f128 --- /dev/null +++ b/Services/Plugins/PluginHost.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using NullWave.Plugins.Contracts; +using NullWave.Services.Security; + +namespace NullWave.Services.Plugins; + +public class PluginHost : IPluginHost +{ + private readonly PreferencesService _prefs; + private readonly KeyStoreService _keyStore; + private readonly PluginManifest _manifest; + + public string HostVersion => "0.7.0"; + public PluginManifest Manifest => _manifest; + + public PluginHost(PreferencesService prefs, KeyStoreService keyStore, PluginManifest manifest) + { + _prefs = prefs; + _keyStore = keyStore; + _manifest = manifest; + } + + public T? GetSetting(string key) + { + return default; + } + + public void SetSetting(string key, T value) + { + } + + public string? GetApiKey(string keyName) + { + if (!_manifest.Permissions.AccessApiKeys) + { + Serilog.Log.Warning("[PluginHost] Plugin {PluginId} tried to access API key {Key} without permission", + _manifest.Id, keyName); + return null; + } + + var key = _keyStore.GetKey(keyName); + Serilog.Log.Information("[PluginHost] Plugin {PluginId} accessed API key {Key}", _manifest.Id, keyName); + return key; + } + + public void Log(LogLevel level, string message, Exception? exception = null) + { + var prefix = $"[{_manifest.Id}]"; + switch (level) + { + case LogLevel.Debug: + Serilog.Log.Debug(exception, "{Prefix} {Message}", prefix, message); + break; + case LogLevel.Info: + Serilog.Log.Information(exception, "{Prefix} {Message}", prefix, message); + break; + case LogLevel.Warning: + Serilog.Log.Warning(exception, "{Prefix} {Message}", prefix, message); + break; + case LogLevel.Error: + Serilog.Log.Error(exception, "{Prefix} {Message}", prefix, message); + break; + } + } + + public void ShowToast(string message, string type = "Info", int durationMs = 3000) + { + var toastType = type.ToLowerInvariant() switch + { + "success" => Models.ToastType.Success, + "warning" => Models.ToastType.Warning, + "error" => Models.ToastType.Error, + _ => Models.ToastType.Info + }; + + ToastService.Instance.Show(message, toastType, durationMs, scope: _manifest.Id); + } + + public Task ConfirmAsync(string title, string message) + { + return Task.FromResult(true); + } + + public IReadOnlyList GetLibraryTracks() + { + return Array.Empty(); + } + + public Task DownloadToTempAsync(string url, CancellationToken ct = default) + { + throw new NotImplementedException("DownloadToTempAsync not yet implemented"); + } + + public Task RunProcessAsync(string executable, string[] args, TimeSpan timeout, CancellationToken ct = default) + { + if (!_manifest.Permissions.ExecuteProcesses) + { + Serilog.Log.Warning("[PluginHost] Plugin {PluginId} tried to execute process without permission", _manifest.Id); + return Task.FromResult(new ProcessResult(-1, "", "Permission denied", false, false)); + } + + throw new NotImplementedException("RunProcessAsync not yet implemented"); + } +} \ No newline at end of file diff --git a/Services/Plugins/PluginManager.cs b/Services/Plugins/PluginManager.cs index 0298189..c0ba3e6 100644 --- a/Services/Plugins/PluginManager.cs +++ b/Services/Plugins/PluginManager.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Serilog; +using NullWave.Plugins.Contracts; namespace NullWave.Services.Plugins; @@ -90,7 +91,7 @@ private async Task InitializeOneAsync(IPlugin plugin, TimeSpan timeout, Cancella using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(timeout); - var init = Task.Run(() => plugin.InitializeAsync(cts.Token)); + var init = Task.Run(() => plugin.InitializeAsync(null!, cts.Token)); var finished = await Task.WhenAny(init, Task.Delay(timeout, cts.Token)); if (!ReferenceEquals(finished, init)) @@ -114,7 +115,11 @@ private async Task InitializeOneAsync(IPlugin plugin, TimeSpan timeout, Cancella var success = await init; cts.Cancel(); // releases the timeout timer - plugin.State = success ? PluginState.Available : PluginState.Error; + plugin.State = success + ? PluginState.Available + : plugin.State is PluginState.Disabled or PluginState.Unavailable + ? plugin.State + : PluginState.Error; if (!success) _logger.Information("Plugin {Name} initialized without optional configuration - its features stay off until configured", plugin.Name); diff --git a/Services/Plugins/PluginState.cs b/Services/Plugins/PluginState.cs deleted file mode 100644 index 199549e..0000000 --- a/Services/Plugins/PluginState.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace NullWave.Services.Plugins; - -public enum PluginState -{ - /// - /// Plugin binary/API is present and healthy. - /// - Available, - - /// - /// Plugin binary or remote endpoint not found (e.g. yt-dlp not on PATH). - /// - Unavailable, - - /// - /// User explicitly disabled this plugin. - /// - Disabled, - - /// - /// Initialization failed or health check is failing. - /// - Error, - - /// - /// Plugin is currently starting up. - /// - Loading -} \ No newline at end of file diff --git a/Services/Plugins/ProviderCaller.cs b/Services/Plugins/ProviderCaller.cs new file mode 100644 index 0000000..6cfa3a0 --- /dev/null +++ b/Services/Plugins/ProviderCaller.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Serilog; +using NullWave.Plugins.Contracts; + +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..d2c0f5d --- /dev/null +++ b/Services/Plugins/ProviderDescriptor.cs @@ -0,0 +1,13 @@ +using NullWave.Plugins.Contracts; + +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/IPlugin.cs b/Services/Plugins/ProviderInterfaces.cs similarity index 67% rename from Services/Plugins/IPlugin.cs rename to Services/Plugins/ProviderInterfaces.cs index 0860a0e..4a61db1 100644 --- a/Services/Plugins/IPlugin.cs +++ b/Services/Plugins/ProviderInterfaces.cs @@ -3,36 +3,27 @@ using System.Threading; using System.Threading.Tasks; using NullWave.Models; +using NullWave.Plugins.Contracts; namespace NullWave.Services.Plugins; -public interface IPlugin -{ - string Name { get; } - string Description { get; } - PluginState State { get; set; } - bool IsEnabled { get; set; } - Task InitializeAsync(CancellationToken ct = default); - 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); } @@ -70,4 +61,21 @@ public class WeatherData public string Condition { get; set; } = string.Empty; public double TemperatureC { get; set; } public List MoodTags { get; set; } = new(); +} + +/// +/// Extension to build a PluginManifest from a built-in IPlugin/IProvider. +/// Lives here (not in Contracts) because it references IProvider which is app-internal. +/// +public static class PluginManifestExtensions +{ + public static PluginManifest FromBuiltIn(IPlugin p) => new( + p is IProvider ip ? ip.Descriptor.Id : p.Name, + p.Name, + "NullWave", + "built-in", + p.Description, + p is IProvider q ? new[] { q.Descriptor.Kind } : Array.Empty(), + true, + PluginAvailability.Installed); } \ No newline at end of file diff --git a/Services/Plugins/ProviderRegistry.cs b/Services/Plugins/ProviderRegistry.cs new file mode 100644 index 0000000..579f28c --- /dev/null +++ b/Services/Plugins/ProviderRegistry.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Serilog; +using NullWave.Plugins.Contracts; + +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; + } + var kind = provider.Descriptor.Kind; + // Check persisted choice NOW that provider is registered + var persisted = GetPersistedChoice(kind); + if (persisted == id || (persisted != null && _providers.ContainsKey(persisted))) + { + _activeByKind[kind] = persisted ?? id; + } + 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..70317d3 100644 --- a/Services/Plugins/YtDlpDownloadProvider.cs +++ b/Services/Plugins/YtDlpDownloadProvider.cs @@ -1,35 +1,29 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using NullWave.Models; +using NullWave.Helpers; using Serilog; +using NullWave.Plugins.Contracts; namespace NullWave.Services.Plugins; -/// -/// Wraps DownloadService as an optional plugin. When yt-dlp is missing or disabled, -/// all download methods return graceful failures and the UI can hide download controls. -/// public class YtDlpDownloadProvider : IDownloadProvider { private readonly DownloadService _inner; private readonly PreferencesService _prefs; private readonly Func> _versionProbe; + public string Id => Descriptor.Id; public string Name => "yt-dlp Downloader"; public string Description => "Downloads audio from YouTube, SoundCloud, and other supported sites"; public PluginState State { get; set; } = PluginState.Unavailable; public bool IsEnabled { get; set; } = true; - // I expose the inner service so MainViewModel can still wire events - // (PlaylistBatchStarted, etc.) during the migration period. public DownloadService Inner => _inner; - /// - /// Asks which yt-dlp version is installed (null = not found). Defaults to the shared - /// , which the startup diagnostics also use, so yt-dlp is looked - /// up once per run instead of being launched again for every check. - /// public YtDlpDownloadProvider(DownloadService inner, PreferencesService prefs, Func>? versionProbe = null) { _inner = inner; @@ -38,7 +32,7 @@ public YtDlpDownloadProvider(DownloadService inner, PreferencesService prefs, Fu IsEnabled = prefs.Current.EnableYtDlp; } - public async Task InitializeAsync(CancellationToken ct = default) + public async Task InitializeAsync(IPluginHost host, CancellationToken ct = default) { if (!IsEnabled) { @@ -74,10 +68,7 @@ public bool SupportsUrl(string url) if (!IsEnabled || State != PluginState.Available) return false; - return url.Contains("youtube", StringComparison.OrdinalIgnoreCase) - || url.Contains("youtu.be", StringComparison.OrdinalIgnoreCase) - || url.Contains("soundcloud", StringComparison.OrdinalIgnoreCase) - || url.Contains("bandcamp", StringComparison.OrdinalIgnoreCase); + return SourceDetector.IsYtDlpSource(SourceDetector.Detect(url)); } public Task DownloadAsync(string url, DownloadOptions options, CancellationToken ct = default) @@ -85,7 +76,6 @@ public Task DownloadAsync(string url, DownloadOptions options, C if (!IsEnabled || State != PluginState.Available) return Task.FromResult(DownloadResult.Failed("yt-dlp provider not available")); - // I generate a fresh track ID since the inner service expects one var trackId = Guid.NewGuid().ToString(); _ = _inner.DownloadAsync( @@ -97,9 +87,12 @@ public Task DownloadAsync(string url, DownloadOptions options, C isInteractive: true, ct: ct); - // Note: DownloadService is fire-and-forget with events. I return success - // here because the real result arrives via the ProgressChanged/DownloadCompleted - // 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 ({string.Join(", ", SupportedSites.All.Where(s => s.IsYtDlpNative).Select(s => s.DisplayName))})" + ); } \ 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..a2b1267 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,51 +14,92 @@ 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 SecretServiceProtector? _secretServiceProtector; 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; } + public bool UsedFallback { get; private set; } // K2: Track if we fell back to legacy + public bool UsesWeakerFallback => UsedFallback || _storedWithFallbackProtection || _activeProtector?.Id == 1; + public bool SecretServiceClearFailed { get; private set; } private bool _quarantineFailed; + private string? _readOnlyReason; + private bool _storedWithFallbackProtection; - public KeyStoreService() : this(NullWavePaths.KeyStorePath, DeriveKey()) + public KeyStoreService(string storePath) { + _storePath = storePath; + Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!); MigrateLegacyIfNeeded(); - lock (_lock) { LoadLocked(); } // surfaces WasRecovered at startup + + _encryptionKey = DeriveLegacyKey(); + + if (OperatingSystem.IsWindows()) + { + var dpapi = new DpapiProtector(); + _activeProtector = dpapi.IsAvailable ? dpapi : new LegacyDerivedProtector(storePath); + } + else if (OperatingSystem.IsLinux()) + { + var secretService = new SecretServiceProtector(); + _secretServiceProtector = secretService; + _activeProtector = secretService.IsAvailable ? secretService : new LegacyDerivedProtector(storePath); + } + else + { + _activeProtector = new LegacyDerivedProtector(storePath); + } + _legacyProtector = new LegacyDerivedProtector(storePath); + Initialize(); } internal KeyStoreService(string storePath, byte[] encryptionKey) { _storePath = storePath; _encryptionKey = encryptionKey; + _activeProtector = new AesGcmProtector(1, "test", encryptionKey); + _legacyProtector = _activeProtector; Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!); + Initialize(); } - private void MigrateLegacyIfNeeded() + internal KeyStoreService( + string storePath, + byte[] legacyKey, + ISecretProtector active, + SecretServiceProtector? secretServiceProtector = null) { - try - { - var legacy = Path.Combine(NullWavePaths.LegacyDataDir, "keys.enc"); - if (File.Exists(_storePath) || !File.Exists(legacy)) return; - if (string.Equals(Path.GetFullPath(legacy), Path.GetFullPath(_storePath), - StringComparison.OrdinalIgnoreCase)) return; - File.Move(legacy, _storePath); - Log.Information("Migrated keys.enc to {Path}", _storePath); - } - catch (Exception ex) + _storePath = storePath; + _encryptionKey = legacyKey; + _activeProtector = active; + _secretServiceProtector = secretServiceProtector; + _legacyProtector = new AesGcmProtector(1, "legacy", legacyKey); + Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!); + Initialize(); + } + + private void Initialize() + { + var migratedNow = MigrateV1ToV2IfNeeded(); + lock (_lock) { LoadLocked(); } + + if (!migratedNow && !WasRecovered && _readOnlyReason == null) { - Log.Warning(ex, "Legacy keys.enc migration failed; continuing without it"); + var bak = _storePath + ".v1.bak"; + try + { + if (File.Exists(bak)) File.Delete(bak); + } + catch (Exception ex) + { + Log.Warning(ex, "[KeyStoreService] Could not remove v1 backup"); + } } } - /* - * 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() + private static byte[] DeriveLegacyKey() { var machineId = GetMachineId(); var username = Environment.UserName; @@ -74,6 +116,98 @@ private static string GetMachineId() return Environment.MachineName; } + private bool MigrateV1ToV2IfNeeded() + { + if (!File.Exists(_storePath)) return false; + try + { + var blob = File.ReadAllBytes(_storePath); + + 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; + } + + Log.Information("[KeyStoreService] Detected v1 keystore, attempting migration to v2"); + var json = Encoding.UTF8.GetString(DecryptLegacy(blob)); + var keys = JsonSerializer.Deserialize>(json); + if (keys == null || keys.Count == 0) + { + Log.Warning("[KeyStoreService] v1 keystore was empty, skipping migration"); + return false; + } + + if (_activeProtector != null) + { + var newBlob = EncryptWithActiveProtector(json); + + try + { + var payload = new byte[newBlob.Length - 6]; + Array.Copy(newBlob, 6, payload, 0, payload.Length); + var check = _activeProtector.Unprotect(payload); + if (Encoding.UTF8.GetString(check) != json) + throw new CryptographicException("Round-trip mismatch"); + } + catch (Exception ex) + { + Log.Warning(ex, "[KeyStoreService] v1->v2 round-trip failed; keeping v1 file untouched"); + return false; + } + + var tmp = _storePath + ".tmp"; + WriteSecure(tmp, newBlob); // K4 FIX + + var backup = _storePath + ".v1.bak"; + File.Copy(_storePath, backup, overwrite: true); + File.Move(tmp, _storePath, overwrite: true); + + Log.Information("[KeyStoreService] Migrated {Count} keys from v1 to v2 format. Backup at {Backup}", keys.Count, backup); + return true; + } + return false; + } + catch (Exception ex) + { + Log.Warning(ex, "[KeyStoreService] v1→v2 migration failed, will use v1 format"); + return false; + } + } + + private byte[] EncryptWithActiveProtector(string plaintext) + { + if (_activeProtector == null) + throw new InvalidOperationException("No active protector available"); + + var plaintextBytes = Encoding.UTF8.GetBytes(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; + blob[5] = _activeProtector.Id; + Array.Copy(payload, 0, blob, 6, payload.Length); + return blob; + } + + private void MigrateLegacyIfNeeded() + { + try + { + var legacy = Path.Combine(NullWavePaths.LegacyDataDir, "keys.enc"); + if (File.Exists(_storePath) || !File.Exists(legacy)) return; + if (string.Equals(Path.GetFullPath(legacy), Path.GetFullPath(_storePath), + StringComparison.OrdinalIgnoreCase)) return; + + File.Move(legacy, _storePath); + Log.Information("Migrated keys.enc to {Path}", _storePath); + } + catch (Exception ex) + { + Log.Warning(ex, "Legacy keys.enc migration failed; continuing without it"); + } + } + public Dictionary LoadKeys() { lock (_lock) return new Dictionary(LoadLocked()); @@ -86,14 +220,56 @@ private Dictionary LoadLocked() try { - var json = Decrypt(File.ReadAllBytes(_storePath)); + var blob = File.ReadAllBytes(_storePath); + string json; + + 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) + { + _readOnlyReason = $"Keystore was written by protector {protectorId}, which isn't available here. Left untouched."; + Log.Error("[KeyStoreService] {Reason}", _readOnlyReason); + return _cache = new Dictionary(); + } + + _storedWithFallbackProtection = + protectorId == _legacyProtector?.Id && protectorId != _activeProtector?.Id; + 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 + { + _storedWithFallbackProtection = true; + json = Encoding.UTF8.GetString(DecryptLegacy(blob)); + } + return _cache = JsonSerializer.Deserialize>(json) ?? new(); } - catch (Exception ex) + catch (Exception ex) when (ex is CryptographicException or JsonException or FormatException or ArgumentException) { Quarantine(ex); return _cache = new Dictionary(); } + catch (Exception ex) + { + _readOnlyReason = $"Keystore could not be read right now ({ex.GetType().Name}). Left untouched."; + Log.Error(ex, "[KeyStoreService] {Reason}", _readOnlyReason); + return _cache = new Dictionary(); + } } private void Quarantine(Exception ex) @@ -142,16 +318,42 @@ 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"); } + + var dir = Path.GetDirectoryName(_storePath); + if (dir != null && Directory.Exists(dir)) + { + var prefix = Path.GetFileName(_storePath); + foreach (var file in Directory.GetFiles(dir, $"{prefix}.*")) + { + if (file.EndsWith(".v1.bak", StringComparison.OrdinalIgnoreCase) || + file.Contains(".bad-", StringComparison.OrdinalIgnoreCase) || + file.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) + { + try { File.Delete(file); } catch { } + } + } + } _cache = new Dictionary(); + _readOnlyReason = null; + UsedFallback = false; + _storedWithFallbackProtection = false; + SecretServiceClearFailed = false; + + if (_secretServiceProtector != null) + { + try + { + SecretServiceClearFailed = !_secretServiceProtector.ClearMasterKey(); + } + catch (Exception ex) + { + SecretServiceClearFailed = true; + Log.Error(ex, "[KeyStoreService] Local keys were deleted but Secret Service cleanup failed"); + } + } } } @@ -159,16 +361,62 @@ private void Persist(Dictionary keys) { if (_quarantineFailed) throw new IOException("Keystore is unreadable and could not be moved aside; refusing to overwrite it."); + if (_readOnlyReason != null) + throw new IOException(_readOnlyReason); + + var json = JsonSerializer.Serialize(keys); + byte[] blob; + + if (_activeProtector != null) + { + 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; + UsedFallback = false; + _storedWithFallbackProtection = false; + } + 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; + _storedWithFallbackProtection = 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; + blob[5] = id; + Array.Copy(payload, 0, blob, 6, payload.Length); + } + else + { + blob = EncryptLegacy(json); + } - var blob = Encrypt(JsonSerializer.Serialize(keys)); 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)) @@ -198,14 +446,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 +470,14 @@ 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 +490,49 @@ 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..0679760 --- /dev/null +++ b/Services/Security/SecretServiceProtector.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using NullWave.Helpers; +using Serilog; + +namespace NullWave.Services.Security; + +internal delegate ProcessResult SecretServiceCommandRunner( + IReadOnlyList arguments, + string? standardInput, + TimeSpan timeout); + +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"; + + private readonly SecretServiceCommandRunner _commandRunner; + private bool? _isAvailable; + + public byte Id => 3; + public string Name => "Linux Secret Service (GNOME Keyring / KWallet)"; + + internal SecretServiceProtector(SecretServiceCommandRunner? commandRunner = null) + => _commandRunner = commandRunner ?? RunSecretTool; + + public bool IsAvailable + { + get + { + if (_isAvailable.HasValue) return _isAvailable.Value; + if (!OperatingSystem.IsLinux()) return (_isAvailable = false).Value; + + try + { + var result = _commandRunner(new[] { "--version" }, null, TimeSpan.FromSeconds(2)); + _isAvailable = result.ExitCode == 0 && !result.TimedOut && !result.Canceled; + if (!_isAvailable.Value) + Log.Warning("[SecretService] secret-tool availability check failed (exit {ExitCode}, timed out: {TimedOut})", + result.ExitCode, result.TimedOut); + } + catch (Exception ex) + { + Log.Warning(ex, "[SecretService] secret-tool availability check failed"); + _isAvailable = false; + } + + return _isAvailable.Value; + } + } + + 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 = LookupMasterKey() ?? throw new InvalidOperationException("Master key missing from Secret Service"); + var aesProtector = new AesGcmProtector(Id, Name, masterKey); + return aesProtector.Unprotect(payload); + } + + internal bool ClearMasterKey() + { + var args = new[] { "clear", AttrApp, SchemaName, AttrPurpose, "keystore-master" }; + var result = _commandRunner(args, null, TimeSpan.FromSeconds(5)); + if (result.ExitCode == 0 && !result.TimedOut && !result.Canceled) + return true; + + Log.Warning( + "[SecretService] Failed to clear stored master key (exit {ExitCode}, timed out: {TimedOut}, canceled: {Canceled}): {Error}", + result.ExitCode, result.TimedOut, result.Canceled, result.StandardError); + return false; + } + + private byte[] GetOrCreateMasterKey() + { + var existing = LookupMasterKey(); + if (existing != null) return existing; + + var newKey = RandomNumberGenerator.GetBytes(32); + StoreMasterKey(newKey); + return newKey; + } + + private byte[]? LookupMasterKey() + { + var args = new[] { "lookup", AttrApp, SchemaName, AttrPurpose, "keystore-master" }; + var result = _commandRunner(args, null, TimeSpan.FromSeconds(5)); + + if (result.TimedOut || result.Canceled) + throw new InvalidOperationException("Secret Service timed out (keyring may be locked or unavailable)"); + + if (result.ExitCode == 0 && !string.IsNullOrWhiteSpace(result.StandardOutput)) + return Convert.FromBase64String(result.StandardOutput.Trim()); + + if (result.ExitCode == 1 && string.IsNullOrWhiteSpace(result.StandardError)) + return null; + + throw new InvalidOperationException($"Secret Service lookup failed: {result.StandardError}"); + } + + private void StoreMasterKey(byte[] key) + { + var base64 = Convert.ToBase64String(key); + var args = new[] { "store", "--label", Label, AttrApp, SchemaName, AttrPurpose, "keystore-master" }; + var result = _commandRunner(args, base64, TimeSpan.FromSeconds(10)); + + if (result.ExitCode == 0 && !result.TimedOut && !result.Canceled) + return; + + Log.Warning( + "[SecretService] store failed (exit {ExitCode}, timed out: {TimedOut}, canceled: {Canceled}): {Error}", + result.ExitCode, result.TimedOut, result.Canceled, result.StandardError); + throw new InvalidOperationException($"secret-tool store failed: {result.StandardError}"); + } + + private static ProcessResult RunSecretTool( + IReadOnlyList arguments, + string? standardInput, + TimeSpan timeout) + { + var startInfo = new ProcessStartInfo("secret-tool") + { + RedirectStandardInput = standardInput != null, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + UseShellExecute = false, + CreateNoWindow = true + }; + foreach (var argument in arguments) + startInfo.ArgumentList.Add(argument); + + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + using var process = new Process { StartInfo = startInfo }; + process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); }; + + try + { + process.Start(); + } + catch (Win32Exception ex) when (ex.NativeErrorCode is 2 or 3) + { + return new ProcessResult(-1, string.Empty, ex.Message, false, false); + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + if (standardInput != null) + { + process.StandardInput.Write(standardInput); + process.StandardInput.Close(); + } + + if (!process.WaitForExit((int)timeout.TotalMilliseconds)) + { + try { process.Kill(entireProcessTree: true); } + catch (InvalidOperationException) { } + catch (Win32Exception ex) { Log.Warning(ex, "[SecretService] Could not stop timed-out secret-tool"); } + process.WaitForExit(); + return new ProcessResult(-1, stdout.ToString(), stderr.ToString(), true, false); + } + + process.WaitForExit(); + return new ProcessResult(process.ExitCode, stdout.ToString(), stderr.ToString(), false, false); + } +} diff --git a/Services/SmartSorting/HardwareDetector.cs b/Services/SmartSorting/HardwareDetector.cs index 3703782..87dae65 100644 --- a/Services/SmartSorting/HardwareDetector.cs +++ b/Services/SmartSorting/HardwareDetector.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Runtime.InteropServices; using System.Runtime.Intrinsics.X86; +using System.Globalization; using System.Threading; using System.Threading.Tasks; using NullWave.Helpers; @@ -85,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 @@ -102,7 +126,7 @@ private async Task GetGpuInfoAsync(HardwareInfo info, CancellationToken ct) { info.GpuType = parts[0].Trim(); info.HasNvidia = true; - if (long.TryParse(parts[1].Trim(), out long mb)) + if (long.TryParse(parts[1].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long mb)) info.GpuVramGB = (long)Math.Round((double)mb / 1024.0); return; } @@ -151,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); @@ -167,7 +198,7 @@ private async Task GetGpuInfoAsync(HardwareInfo info, CancellationToken ct) if (isDiscreteAmd) info.HasAmd = true; if (isDiscreteNvidia) info.HasNvidia = true; - if (long.TryParse(parts[1], out long vramBytes) && vramBytes >= 1073741824) + if (long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out long vramBytes) && vramBytes >= 1073741824) { info.GpuVramGB = Math.Max(4, (long)Math.Round((double)vramBytes / 1024.0 / 1024.0 / 1024.0)); } @@ -183,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/Services/SmartSorting/LocalAIService.Ollama.cs b/Services/SmartSorting/LocalAIService.Ollama.cs index da56754..7676cd3 100644 --- a/Services/SmartSorting/LocalAIService.Ollama.cs +++ b/Services/SmartSorting/LocalAIService.Ollama.cs @@ -143,12 +143,16 @@ public async Task DownloadModelAsync(string model, IProgress? progress = response.EnsureSuccessStatusCode(); using var stream = await response.Content.ReadAsStreamAsync(ct); using var reader = new System.IO.StreamReader(stream); - while (!reader.EndOfStream && !ct.IsCancellationRequested) + while (!ct.IsCancellationRequested) { var line = await reader.ReadLineAsync(ct); + if (line == null) break; if (string.IsNullOrWhiteSpace(line)) continue; + using var doc = JsonDocument.Parse(line); - if (doc.RootElement.TryGetProperty("total", out var t) && doc.RootElement.TryGetProperty("completed", out var c) && t.GetInt64() > 0) + if (doc.RootElement.TryGetProperty("total", out var t) && + doc.RootElement.TryGetProperty("completed", out var c) && + t.GetInt64() > 0) progress?.Report((double)c.GetInt64() / t.GetInt64()); } progress?.Report(1.0); diff --git a/Services/System/DependencyUpdateService.cs b/Services/System/DependencyUpdateService.cs index c6bded1..9171eaa 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,24 +21,34 @@ public class DependencyInfo public bool IsInstalled { get; init; } } +public sealed record PackageInstallExecution(bool Approved, ProcessResult? ProcessResult); + public class DependencyUpdateService { - // Asking a tool for its version should take a moment. Installing or updating one can take minutes. private static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(8); private static readonly TimeSpan ActionTimeout = TimeSpan.FromMinutes(2); + // 0x8A15002B (APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE) as a signed int. + // This is what winget returns when it manages a package but there is no newer version. + private const int WingetNoUpdateExitCode = unchecked((int)0x8A15002B); + private readonly HttpClient _http; + private readonly Func, TimeSpan, Task> _packageProcessRunner; - public DependencyUpdateService() + public const string InstallCancelledStatus = "Install cancelled by user"; + + 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 ===== public async Task GetYtDlpInfoAsync() { - // Shared with the startup diagnostics and the yt-dlp plugin: one lookup per run. var installed = await ToolVersionProbe.GetVersionAsync("yt-dlp"); if (string.IsNullOrWhiteSpace(installed)) return new DependencyInfo { Name = "yt-dlp", IsInstalled = false }; @@ -68,7 +80,6 @@ public async Task UpdateYtDlpAsync() } finally { - // Whatever happened, the installed version may have changed: forget the cached answer. ToolVersionProbe.Invalidate("yt-dlp"); } } @@ -76,34 +87,20 @@ public async Task UpdateYtDlpAsync() private static async Task UpdateYtDlpCoreAsync() { // 1. ALWAYS try yt-dlp's native self-updater first. - // This works for standalone .exe, pip, and most package managers. var selfUpdate = await RunCommandAsync("yt-dlp", "-U"); if (selfUpdate != null) { - Log.Information("[DependencyUpdate] yt-dlp updated successfully via native self-updater (-U)"); - return "Update completed via yt-dlp"; - } - - // 2. Fallback for Windows: try winget (only works if originally installed via winget) - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Log.Information("[DependencyUpdate] Native update failed, attempting winget fallback..."); - var wingetUpdate = await RunCommandAsync("winget", "upgrade", "yt-dlp.yt-dlp", "--accept-source-agreements", "--accept-package-agreements"); - if (wingetUpdate != null) + if (selfUpdate.Contains("up to date", StringComparison.OrdinalIgnoreCase)) { - Log.Information("[DependencyUpdate] yt-dlp updated successfully via winget"); - return "Update completed via winget"; + Log.Information("[DependencyUpdate] yt-dlp is already up to date"); + return "Already up to date"; } + Log.Information("[DependencyUpdate] yt-dlp updated successfully via native self-updater (-U)"); + return "Update completed via yt-dlp"; } - // 3. Fallback for Linux: try pip - var pipUpdate = await RunCommandAsync("pip", "install", "--upgrade", "yt-dlp"); - if (pipUpdate != null) - { - Log.Information("[DependencyUpdate] yt-dlp updated successfully via pip"); - return "Update completed via pip"; - } - + // 2. Fallbacks disabled: silent winget/pip updates bypass the S7 consent gate. + // Users must update yt-dlp manually or via their OS package manager. return "Update failed: Ensure yt-dlp is installed correctly."; } @@ -112,8 +109,6 @@ public async Task GetVlcInfoAsync() { if (OperatingSystem.IsWindows()) { - // NEVER run "vlc --version" on Windows: VLC opens its own console window, shows - // "Press RETURN to continue..." and waits. Read the version from vlc.exe instead. var found = FindWindowsVlcVersion(); if (found != null) { @@ -127,7 +122,6 @@ public async Task GetVlcInfoAsync() }; } - // Bundled LibVLC ships no vlc.exe; VlcLocator already validated its DLLs, plugins and CPU arch. var resolution = VlcLocator.Current; if (resolution.Chosen != null) { @@ -144,7 +138,6 @@ public async Task GetVlcInfoAsync() return new DependencyInfo { Name = "VLC", IsInstalled = false }; } - // Linux / macOS: the command line is safe and is how VLC is normally found. var installed = await ToolVersionProbe.GetVersionAsync("vlc"); if (!string.IsNullOrWhiteSpace(installed)) { @@ -188,22 +181,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(); // next ResolveVlcDirectory() sees the fresh install + 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() { @@ -224,8 +250,6 @@ public async Task GetFfmpegInfoAsync() public Task GetDotNetInfoAsync() { - // NullWave ships with its own .NET runtime, so there is nothing to install. Report the one - // that is running instead of looking for a "dotnet" command that most users do not have. return Task.FromResult(new DependencyInfo { Name = ".NET", @@ -238,20 +262,29 @@ public Task GetDotNetInfoAsync() // ===== HELPERS ===== - /// For "what version is this?" questions: short timeout. private static Task RunQueryAsync(string cmd, params string[] args) => RunWithTimeoutAsync(QueryTimeout, cmd, args); - /// For installs and updates: long timeout. 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 { - // FIX (P9): Route through ProcessRunner to prevent stderr deadlocks - // and enforce a timeout for hung tools and package managers. var result = await ProcessRunner.RunAsync(cmd, args, timeout: timeout); if (result.TimedOut) @@ -270,6 +303,12 @@ public Task GetDotNetInfoAsync() if (combined.Contains("up to date", StringComparison.OrdinalIgnoreCase)) return combined; } + + // winget exits with UPDATE_NOT_APPLICABLE when nothing newer exists: that's success. + // Any other non-zero code (like "no installed package found") is a real failure. + if (cmd == "winget" && args.Length > 0 && args[0] == "upgrade" + && result.ExitCode == WingetNoUpdateExitCode) + return result.StandardOutput; if (!string.IsNullOrWhiteSpace(result.StandardError)) Log.Warning("[DependencyUpdate] {Cmd} exited {Code}: {Err}", cmd, result.ExitCode, result.StandardError.Trim()); diff --git a/Services/System/PreferencesService.cs b/Services/System/PreferencesService.cs index 10daa2d..cdd62c7 100644 --- a/Services/System/PreferencesService.cs +++ b/Services/System/PreferencesService.cs @@ -30,9 +30,9 @@ public class PreferencesService : IDisposable public Preferences Current => _prefs; - public PreferencesService() + public PreferencesService(string? prefsPath = null) { - _prefsPath = NullWavePaths.PrefsPath; + _prefsPath = prefsPath ?? NullWavePaths.PrefsPath; _prefs = Load(); } diff --git a/Services/System/ToastService.cs b/Services/System/ToastService.cs index 316110e..2052bf9 100644 --- a/Services/System/ToastService.cs +++ b/Services/System/ToastService.cs @@ -280,7 +280,11 @@ private void Enqueue(LiveNotification n) private void DisplayToast(LiveNotification n) { ActiveToasts.Add(n); - ScheduleDismiss(n, n.DurationMs); + // FIX: Do not auto-dismiss live activities while they are still in progress + if (!(n.IsLiveActivity && !n.IsCompleted)) + { + ScheduleDismiss(n, n.DurationMs); + } } /// Fills freed slots from the overflow queue. Must run on the UI thread. diff --git a/Services/System/UpdateService.cs b/Services/System/UpdateService.cs index 7d7ee7a..200f178 100644 --- a/Services/System/UpdateService.cs +++ b/Services/System/UpdateService.cs @@ -1,4 +1,5 @@ using System; +using System.Reflection; using System.Threading.Tasks; using Serilog; using Velopack; @@ -9,6 +10,8 @@ namespace NullWave.Services; public class UpdateCheckResult { public bool IsUpdateAvailable { get; init; } + public bool Failed { get; init; } + public bool NotInstalled { get; init; } public string CurrentVersion { get; init; } = string.Empty; public string LatestVersion { get; init; } = string.Empty; public string ReleaseUrl { get; init; } = string.Empty; @@ -17,21 +20,29 @@ public class UpdateCheckResult public class UpdateService { + private const string RepoUrl = "https://github.com/ZenQuantDev/NullWave"; + private readonly UpdateManager _updateManager; private UpdateInfo? _pendingUpdate; + private readonly string _displayVersion; public UpdateService() { - var source = new GithubSource("https://github.com/ZenQuantDev/NullWave", accessToken: null, prerelease: false); - _updateManager = new UpdateManager(source); + var infoVer = typeof(UpdateService).Assembly + .GetCustomAttribute()?.InformationalVersion ?? "0.0.0"; + + _displayVersion = UpdateVersioning.Display(infoVer); + var isPreRelease = UpdateVersioning.IsPreRelease(infoVer); - // Log initialization state - Log.Information("[UpdateService] Initialized. IsInstalled={IsInstalled}, Version={Version}", - _updateManager.IsInstalled, CurrentVersion); + var source = new GithubSource(RepoUrl, accessToken: null, prerelease: isPreRelease); + _updateManager = new UpdateManager(source); + + Log.Information("[UpdateService] Initialized. IsInstalled={IsInstalled}, Version={Version}, PreRelease={PreRelease}", + _updateManager.IsInstalled, _displayVersion, isPreRelease); } public bool IsInstalled => _updateManager.IsInstalled; - public string CurrentVersion => _updateManager.CurrentVersion?.ToString() ?? "0.0.0"; + public string CurrentVersion => _updateManager.CurrentVersion?.ToString() ?? _displayVersion; public async Task CheckForUpdateAsync() { @@ -40,10 +51,11 @@ public async Task CheckForUpdateAsync() Log.Information("[UpdateService] Skipping check: App is not installed via Velopack (dev/portable build)."); return new UpdateCheckResult { + NotInstalled = true, IsUpdateAvailable = false, - CurrentVersion = "dev build", - LatestVersion = "dev build", - ReleaseUrl = "https://github.com/ZenQuantDev/NullWave/releases", + CurrentVersion = _displayVersion, + LatestVersion = _displayVersion, + ReleaseUrl = $"{RepoUrl}/releases", ReleaseNotes = "Updates are only available for installed builds." }; } @@ -61,7 +73,7 @@ public async Task CheckForUpdateAsync() IsUpdateAvailable = false, CurrentVersion = CurrentVersion, LatestVersion = CurrentVersion, - ReleaseUrl = "https://github.com/ZenQuantDev/NullWave/releases" + ReleaseUrl = $"{RepoUrl}/releases" }; } @@ -73,8 +85,8 @@ public async Task CheckForUpdateAsync() IsUpdateAvailable = true, CurrentVersion = CurrentVersion, LatestVersion = versionString, - ReleaseUrl = $"https://github.com/ZenQuantDev/NullWave/releases/tag/v{versionString}", - ReleaseNotes = "See GitHub for release notes." + ReleaseUrl = $"{RepoUrl}/releases/tag/v{versionString}", + ReleaseNotes = _pendingUpdate.TargetFullRelease.NotesMarkdown ?? "See GitHub for release notes." }; } catch (Exception ex) @@ -82,6 +94,7 @@ public async Task CheckForUpdateAsync() Log.Error(ex, "[UpdateService] Update check failed"); return new UpdateCheckResult { + Failed = true, IsUpdateAvailable = false, CurrentVersion = CurrentVersion, LatestVersion = "unknown" @@ -89,7 +102,7 @@ public async Task CheckForUpdateAsync() } } - public async Task StageUpdateAsync() + public async Task StageUpdateAsync(Action? progress = null) { if (!_updateManager.IsInstalled) return false; @@ -101,7 +114,7 @@ public async Task StageUpdateAsync() if (_pendingUpdate == null) return false; Log.Information("[UpdateService] Downloading update v{Version}...", _pendingUpdate.TargetFullRelease.Version); - await _updateManager.DownloadUpdatesAsync(_pendingUpdate); + await _updateManager.DownloadUpdatesAsync(_pendingUpdate, progress); Log.Information("[UpdateService] Update staged successfully."); return true; } @@ -120,18 +133,19 @@ public void ApplyUpdatesAndRestart() return; } + // FIX: No sync-over-async. If we don't have a pending update, bail out. + // The UI should only show this button when UpdateStaged is true, which means + // StageUpdateAsync already succeeded and _pendingUpdate is set. + if (_pendingUpdate == null) + { + Log.Warning("[UpdateService] ApplyUpdatesAndRestart called but no update is staged."); + return; + } + try { - if (_pendingUpdate == null) - { - _pendingUpdate = _updateManager.CheckForUpdatesAsync().GetAwaiter().GetResult(); - } - - if (_pendingUpdate != null) - { - Log.Information("[UpdateService] Applying update v{Version} and restarting...", _pendingUpdate.TargetFullRelease.Version); - _updateManager.ApplyUpdatesAndRestart(_pendingUpdate.TargetFullRelease); - } + Log.Information("[UpdateService] Applying update v{Version} and restarting...", _pendingUpdate.TargetFullRelease.Version); + _updateManager.ApplyUpdatesAndRestart(_pendingUpdate.TargetFullRelease); } catch (Exception ex) { diff --git a/Services/System/UpdateVersioning.cs b/Services/System/UpdateVersioning.cs new file mode 100644 index 0000000..4b3b2bf --- /dev/null +++ b/Services/System/UpdateVersioning.cs @@ -0,0 +1,17 @@ +namespace NullWave.Services; + +public static class UpdateVersioning +{ + public static string Display(string? info) => + string.IsNullOrWhiteSpace(info) ? "0.0.0" : info.Split('+')[0]; + + public static bool IsPreRelease(string? info) => Display(info).Contains('-'); + + public static string StatusText(UpdateCheckResult r) => r switch + { + { NotInstalled: true } => "Dev build: updates need an installed copy", + { Failed: true } => "Couldn't check for updates (offline or rate-limited)", + { IsUpdateAvailable: true } => $"Update available: v{r.LatestVersion}", + _ => $"You are up to date (v{r.CurrentVersion})" + }; +} \ No newline at end of file diff --git a/Themes/ControlStyles.axaml b/Themes/ControlStyles.axaml index b7a75fe..9cdb2fc 100644 --- a/Themes/ControlStyles.axaml +++ b/Themes/ControlStyles.axaml @@ -456,6 +456,11 @@ + + + + + @@ -473,20 +478,37 @@ - + - + + + @@ -728,7 +750,7 @@ - + @@ -746,7 +768,7 @@ - + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Themes/Shapes.axaml b/Themes/Shapes.axaml index 461165c..eb4afea 100644 --- a/Themes/Shapes.axaml +++ b/Themes/Shapes.axaml @@ -18,6 +18,18 @@ 20 24 + + 0 + 0,2 + 0,4 + 0,8 + 0,12 + 0,16 + 0,24 + 0,0,0,12 + 0,0,0,16 + 0,0,16,0 + 210 90 diff --git a/Themes/Typography.axaml b/Themes/Typography.axaml index 83a37b0..1215b8f 100644 --- a/Themes/Typography.axaml +++ b/Themes/Typography.axaml @@ -1,35 +1,28 @@ - - 2 - 4 - 6 - 10 - 14 - 9999 - 6 + - - 4 - 8 - 12 - 16 - 20 - 24 + + 11 + 12 + 14 + 15 + 17 + 20 + 24 + 30 - - 210 - 90 - 320 - - - 44 - 40 - - - 32 - 44 - 64 + + 11 + 15 + 17 + 20 + 24 \ No newline at end of file diff --git a/ViewModels/Dialogs/DownloaderConsentViewModel.cs b/ViewModels/Dialogs/DownloaderConsentViewModel.cs new file mode 100644 index 0000000..f826e7c --- /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, NotifyPropertyChangedFor(nameof(CanAccept))] private bool _userUnderstands; + [ObservableProperty, NotifyPropertyChangedFor(nameof(CanAccept))] 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..dec1efb --- /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 M3uImportResult? DialogResult { 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() + { + DialogResult = null; + CloseRequested?.Invoke(); + } + + [RelayCommand] + private void Import() + { + // Pass the preview and the user-chosen name back to the caller + DialogResult = new M3uImportResult(PlaylistName, _preview); + CloseRequested?.Invoke(); + } + + public event Action? CloseRequested; + public sealed record M3uImportResult(string PlaylistName, M3uImportPreview Preview); +} \ No newline at end of file diff --git a/ViewModels/Downloads/DownloadManagerViewModel.cs b/ViewModels/Downloads/DownloadManagerViewModel.cs index 92d05b7..cbb321d 100644 --- a/ViewModels/Downloads/DownloadManagerViewModel.cs +++ b/ViewModels/Downloads/DownloadManagerViewModel.cs @@ -22,7 +22,9 @@ public partial class DownloadManagerViewModel : ObservableObject public DownloadManagerViewModel() { - RetryCommand = new RelayCommand(job => job?.RetryAction?.Invoke()); + RetryCommand = new RelayCommand( + job => job?.RetryAction?.Invoke(), + job => job is { IsFailed: true }); ClearCompletedCommand = new RelayCommand(() => { for (int i = Jobs.Count - 1; i >= 0; i--) diff --git a/ViewModels/Library/LibraryViewModel.Commands.cs b/ViewModels/Library/LibraryViewModel.Commands.cs index e1d0e42..03d44eb 100644 --- a/ViewModels/Library/LibraryViewModel.Commands.cs +++ b/ViewModels/Library/LibraryViewModel.Commands.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -36,10 +37,17 @@ private async Task RemoveTrackAsync(Track? t) if (SelectedTrack?.Id == targetId) SelectedTrack = null; var undoTrack = target; - // 2.5 FIX: Capture playlist memberships BEFORE removing the track + bool recycleFile = false; + if (!string.IsNullOrEmpty(target.FilePath) && File.Exists(target.FilePath)) + { + var msg = $"Remove '{target.Title}' from your library?\n\nDo you also want to send the audio file to the Recycle Bin?"; + recycleFile = await DialogService.Instance.ShowDialogAsync(new Views.ConfirmDialog("Remove Track", msg)); + } + var memberships = GetPlaylistMemberships?.Invoke(targetId) ?? new List(); - await Task.Run(() => _library.Remove(targetId)); + bool shouldRecycle = recycleFile; + await Task.Run(() => _library.Remove(targetId, shouldRecycle)); NullActionLogger.TrackRemoved(targetId.ToString(), "LibraryViewModel"); TriggerRefresh(debounce: false); @@ -49,7 +57,7 @@ private async Task RemoveTrackAsync(Track? t) _library.Add(undoTrack); RestorePlaylistMemberships?.Invoke(undoTrack, memberships); TriggerRefresh(debounce: false); - ToastService.Instance.Show("Track restored.", ToastType.Success, durationMs: 2000, scope: "library-delete"); + ToastService.Instance.Show("Track restored to library.", ToastType.Success, durationMs: 2000, scope: "library-delete"); }, scope: "library-delete"); } diff --git a/ViewModels/Library/LibraryViewModel.Search.cs b/ViewModels/Library/LibraryViewModel.Search.cs index 8ea5a7e..5227c80 100644 --- a/ViewModels/Library/LibraryViewModel.Search.cs +++ b/ViewModels/Library/LibraryViewModel.Search.cs @@ -138,18 +138,18 @@ void FlushPending() FlushPending(); HashSet? ftsMatches = null; + var needles = globalTerms.Select(NullWave.Helpers.TrackNormalizer.SearchKey).Where(n => n.Length > 0).ToList(); if (globalTerms.Count > 0) - { ftsMatches = _library.SearchFts(string.Join(' ', globalTerms)); - if (ftsMatches is { Count: 0 }) return Enumerable.Empty(); - } return tracks.Where(t => { if (!filters.All(f => f(t))) return false; - if (ftsMatches != null) return ftsMatches.Contains(t.Id); - if (globalTerms.Count > 0) return globalTerms.All(term => t.Title.ToLowerInvariant().Contains(term) || t.Artist.ToLowerInvariant().Contains(term)); - return true; + if (globalTerms.Count == 0) return true; + if (ftsMatches != null && ftsMatches.Contains(t.Id)) return true; + if (needles.Count == 0) return false; + var hay = NullWave.Helpers.TrackNormalizer.SearchKey(t.Title) + " " + NullWave.Helpers.TrackNormalizer.SearchKey(t.Artist); + return needles.All(n => hay.Contains(n, StringComparison.Ordinal)); }); } @@ -216,7 +216,7 @@ await Dispatcher.UIThread.InvokeAsync(() => // ai: playlists are music playlists: never pull radio streams or audiobooks // into them (keyword matches like "pop" -> "Indie Pop Rocks" station). var allTracks = _library.GetAll().Where(t => t.MediaType == MediaType.Music).ToArray(); - var keywords = query.ToLowerInvariant().Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries).Where(k => k.Length > 2).ToArray(); + var keywords = query.ToLowerInvariant().Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries).Where(k => k.Length > 2 || k.Any(c => c > '\u02FF')).ToArray(); var candidateTracks = allTracks.Where(t => { var searchText = $"{t.Title} {t.Artist} {string.Join(" ", t.Tags)}".ToLowerInvariant(); 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/Library/TrackDetailViewModel.cs b/ViewModels/Library/TrackDetailViewModel.cs index 59b8998..33b5e1a 100644 --- a/ViewModels/Library/TrackDetailViewModel.cs +++ b/ViewModels/Library/TrackDetailViewModel.cs @@ -26,7 +26,7 @@ public class TrackDetailViewModel : ViewModelBase { private const string LastFmPlaceholderImageHash = "2a96cbd8b46e442fc41c2b86b821562f"; - // Per-artist memoization. Kills the 7-12? repeat log storms when you click + // Per-artist memoization. Kills the 7-12s repeat log storms when you click // multiple tracks by the same artist in sequence, and prevents re-downloading // the Last.fm default placeholder avatar (the "no artist image" image). private static readonly ConcurrentDictionary ArtistInfoCache = new(); @@ -36,7 +36,7 @@ public class TrackDetailViewModel : ViewModelBase private readonly PluginManager _plugins; private readonly IdentityService _identity; private readonly PreferencesService _prefs; - + private Track? _currentTrack; private bool _isOpen; private string _editTitle = string.Empty; @@ -102,7 +102,6 @@ public bool IsOpen OnPropertyChanged(); OnPropertyChanged(nameof(PanelWidth)); OnPropertyChanged(nameof(PanelOpacity)); - if (!_isOpen && _currentTrack != null) { _currentTrack.PropertyChanged -= OnTrackPropertyChanged; @@ -114,35 +113,11 @@ public bool IsOpen public double PanelWidth => _isOpen ? 320 : 0; public double PanelOpacity => _isOpen ? 1.0 : 0.0; - public string EditTitle - { - get => _editTitle; - set { _editTitle = value; OnPropertyChanged(); } - } - - public string EditArtist - { - get => _editArtist; - set { _editArtist = value; OnPropertyChanged(); } - } - - public string EditNotes - { - get => _editNotes; - set { _editNotes = value; OnPropertyChanged(); } - } - - public string NewTag - { - get => _newTag; - set { _newTag = value; OnPropertyChanged(); } - } - - public string CopyStatus - { - get => _copyStatus; - set { _copyStatus = value; OnPropertyChanged(); } - } + public string EditTitle { get => _editTitle; set { _editTitle = value; OnPropertyChanged(); } } + public string EditArtist { get => _editArtist; set { _editArtist = value; OnPropertyChanged(); } } + public string EditNotes { get => _editNotes; set { _editNotes = value; OnPropertyChanged(); } } + public string NewTag { get => _newTag; set { _newTag = value; OnPropertyChanged(); } } + public string CopyStatus { get => _copyStatus; set { _copyStatus = value; OnPropertyChanged(); } } public ObservableCollection Tags { get; } = new(); @@ -159,14 +134,13 @@ public string CopyStatus public string DisplayLastPlayed => _currentTrack?.LastPlayed?.ToString("dd MMM yyyy HH:mm") ?? "Never"; public string DisplayPlayCount => _currentTrack?.PlayCount.ToString() ?? "0"; public string DisplayDuration => _currentTrack?.DisplayDuration ?? "-"; - + public string DisplaySkipWeight { get { if (_currentTrack == null) return "0"; var skipCount = _currentTrack.SkipCount; - if (_currentTrack.LastSkipped.HasValue && _currentTrack.LastSkipped.Value != DateTime.MinValue) { var daysSinceLastSkip = (DateTime.UtcNow - _currentTrack.LastSkipped.Value).TotalDays; @@ -179,7 +153,6 @@ public string DisplaySkipWeight public string DisplayLastSkipped => _currentTrack?.LastSkipped?.ToString("dd MMM yyyy HH:mm") ?? "Never"; public bool IsFavorite => _currentTrack?.IsFavorite ?? false; - public bool IsTrackSharingEnabled => _prefs.Current.EnableTrackSharing; public ICommand SaveCommand { get; } @@ -197,7 +170,7 @@ public TrackDetailViewModel(LibraryService library, PluginManager plugins, Ident _plugins = plugins; _identity = identity; _prefs = prefs; - + SaveCommand = new RelayCommand(Save); CloseCommand = new RelayCommand(() => IsOpen = false); AddTagCommand = new RelayCommand(AddTag); @@ -232,6 +205,7 @@ public void OpenFor(Track track) SimilarArtists.Clear(); HasSimilarArtists = false; LibraryArtistTrackCount = 0; + _ = LoadArtistInfoAsync(track); } @@ -258,7 +232,6 @@ private void RefreshDisplayProperties() private void Save() { if (_currentTrack == null) return; - _currentTrack.Title = EditTitle.Trim(); _currentTrack.Artist = EditArtist.Trim(); _currentTrack.Notes = EditNotes; @@ -280,7 +253,6 @@ private void AddTag() { var tag = NewTag.Trim(); if (string.IsNullOrWhiteSpace(tag) || Tags.Contains(tag)) return; - Tags.Add(tag); NewTag = string.Empty; } @@ -294,10 +266,8 @@ private void RemoveTag(string? tag) private void ToggleFavorite() { if (_currentTrack == null) return; - bool expectedNewState = !_currentTrack.IsFavorite; _library.ToggleFavorite(_currentTrack.Id); - NullActionLogger.FavoriteToggled(_currentTrack.Id.ToString(), expectedNewState, "TrackDetailViewModel"); OnPropertyChanged(nameof(IsFavorite)); } @@ -305,11 +275,9 @@ private void ToggleFavorite() private async Task CopyUrlAsync() { if (_isCopying) return; - try { _isCopying = true; - if (await Helpers.ClipboardHelper.CopyTrackLinkAsync(_currentTrack, _prefs, _identity)) { CopyStatus = "Copied!"; @@ -331,10 +299,8 @@ private async Task CopyUrlAsync() private async Task CopyShareLinkAsync() { if (_currentTrack == null) return; - var window = Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop ? desktop.MainWindow : null; if (window == null) return; - var clipboard = TopLevel.GetTopLevel(window)?.Clipboard; if (clipboard == null) return; @@ -342,7 +308,6 @@ private async Task CopyShareLinkAsync() { var installId = _identity.Fingerprint ?? "unknown-peer"; var shareUrl = ShareLink.Track(installId, _currentTrack.Id); - await clipboard.SetTextAsync(shareUrl); ToastService.Instance.Show("Track share link copied! Send it to a friend.", ToastType.Success, durationMs: 4000); Log.Information("Track share link copied: {Url}", shareUrl); @@ -357,7 +322,6 @@ private async Task CopyShareLinkAsync() private async Task RelinkFileAsync() { if (_currentTrack == null) return; - var window = Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop ? desktop.MainWindow : null; if (window == null) return; @@ -370,7 +334,7 @@ private async Task RelinkFileAsync() if (result.Path != null) { _currentTrack.FilePath = result.Path; - await _library.RefreshAlbumArtAsync(_currentTrack); + await _library.RefreshAlbumArtAsync(_currentTrack); changes += $"FilePath relinked to \"{result.Path}\""; Log.Information("Track relinked to local file: {Title} > {Path}", _currentTrack.Title, result.Path); } @@ -384,10 +348,8 @@ private async Task RelinkFileAsync() } _library.Update(_currentTrack); - NullActionLogger.TrackEdited(_currentTrack.Id.ToString(), changes, "TrackDetailViewModel"); ToastService.Instance.Show($"'{_currentTrack.Title}' re-linked.", ToastType.Success); - RefreshDisplayProperties(); } @@ -424,7 +386,6 @@ private async Task LoadArtistInfoAsync(Track track) { ArtistBio = info.Bio; ArtistListeners = info.Listeners; - SimilarArtists.Clear(); if (info.SimilarArtists.Count > 0) { @@ -436,13 +397,17 @@ private async Task LoadArtistInfoAsync(Track track) else { var normalizedTarget = NullWave.Helpers.TrackNormalizer.NormalizeArtistKey(primaryArtist); - LibraryArtistTrackCount = _library.GetAll() + + // FIX: Run full library scan off the UI thread + var count = await Task.Run(() => _library.GetAll() .Count(t => NullWave.Helpers.TrackNormalizer.SplitArtistCredits(t.Artist) - .Any(name => NullWave.Helpers.TrackNormalizer.NormalizeArtistKey(name) == normalizedTarget)); + .Any(name => NullWave.Helpers.TrackNormalizer.NormalizeArtistKey(name) == normalizedTarget))); + + if (_currentTrack == null || _currentTrack.Id != trackId) return; + LibraryArtistTrackCount = count; } ResolveArtistImageAsync(info, primaryArtist, track, trackId); - IsLoadingArtistInfo = false; } @@ -467,7 +432,7 @@ private void ResolveArtistImageAsync(LastFmArtistInfo? info, string primaryArtis if (isPlaceholder) { // Last.fm returns this hash for artists with no photo. Log at Debug only - // and fall back to album art � do NOT re-download the placeholder. + // and fall back to album art — do NOT re-download the placeholder. Log.Debug("[TrackDetailViewModel] Artist image for {Artist}: placeholder/none - using album art", primaryArtist); ArtistImagePathCache[primaryArtist] = null; ArtistImagePath = track.AlbumArtPath; @@ -475,6 +440,7 @@ private void ResolveArtistImageAsync(LastFmArtistInfo? info, string primaryArtis } Log.Information("[TrackDetailViewModel] Artist image for {Artist}: {Url}", primaryArtist, url); + _ = Task.Run(async () => { try @@ -494,6 +460,7 @@ private void ResolveArtistImageAsync(LastFmArtistInfo? info, string primaryArtis } ArtistImagePathCache[primaryArtist] = localPath; + await Dispatcher.UIThread.InvokeAsync(() => { if (_currentTrack != null && _currentTrack.Id == trackId) @@ -505,6 +472,7 @@ await Dispatcher.UIThread.InvokeAsync(() => Log.Warning(ex, "[TrackDetailViewModel] Failed to download artist image for {Artist}", primaryArtist); // Cache the failure so we don't retry the same broken URL. ArtistImagePathCache[primaryArtist] = null; + await Dispatcher.UIThread.InvokeAsync(() => { if (_currentTrack != null && _currentTrack.Id == trackId) @@ -513,4 +481,4 @@ await Dispatcher.UIThread.InvokeAsync(() => } }); } -} +} \ No newline at end of file diff --git a/ViewModels/Library/TrackViewModel.cs b/ViewModels/Library/TrackViewModel.cs index ee47d80..6580c7f 100644 --- a/ViewModels/Library/TrackViewModel.cs +++ b/ViewModels/Library/TrackViewModel.cs @@ -28,6 +28,10 @@ public TrackViewModel(Track track) TrackSource.Spotify => "♪ Spotify", TrackSource.SoundCloud => "☁ SoundCloud", TrackSource.LastFm => "◎ Last.fm", + TrackSource.Bandcamp => "♫ Bandcamp", + TrackSource.Audiomack => "▶ Audiomack", + TrackSource.ArchiveOrg => "⌂ Archive.org", + TrackSource.Mixcloud => "☁ Mixcloud", TrackSource.Local => "💾 Local", _ => "? Unknown" }; diff --git a/ViewModels/Playback/PlayerViewModel.Commands.cs b/ViewModels/Playback/PlayerViewModel.Commands.cs index c798c59..189a8a0 100644 --- a/ViewModels/Playback/PlayerViewModel.Commands.cs +++ b/ViewModels/Playback/PlayerViewModel.Commands.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using System.Globalization; using CommunityToolkit.Mvvm.Input; using NullWave.Models; using NullWave.Services; @@ -30,7 +31,7 @@ private void InitializeCommands() PreviousTrackCommand = new RelayCommand(PlayPrevious); NextTrackCommand = new RelayCommand(PlayNext); - SetSleepTimerCommand = new RelayCommand(s => { if (int.TryParse(s, out var m)) StartSleepTimer(m); }); + SetSleepTimerCommand = new RelayCommand(s => { if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var m)) StartSleepTimer(m); }); CancelSleepTimerCommand = new RelayCommand(CancelSleepTimer); SetEndOfChapterTimerCommand = new RelayCommand(() => { CancelSleepTimer(); SleepTimerEndOfChapter = true; ToastService.Instance.Show(L("MiniPlayer_SleepTimer_ChapterSet"), ToastType.Success, scope: "sleep"); }); SetPlaybackRateCommand = new RelayCommand(s => { if (float.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var r)) PlaybackRate = r; }); diff --git a/ViewModels/Playback/PlayerViewModel.Playback.cs b/ViewModels/Playback/PlayerViewModel.Playback.cs index eee5c82..b8a279d 100644 --- a/ViewModels/Playback/PlayerViewModel.Playback.cs +++ b/ViewModels/Playback/PlayerViewModel.Playback.cs @@ -30,6 +30,23 @@ public void PlayPlaylist(Playlist playlist) public void PlayTrack(Track? track) { + if (track == null) return; + // Debounce: prevent rapid re-clicks on the same track + if (_currentTrack?.Id == track.Id && + (DateTime.UtcNow - _trackStartTime).TotalMilliseconds < 1500) + { + return; + } + + // --- PATCH: Prevent UI state corruption if track is still downloading --- + if (track.MediaType != MediaType.Radio && !string.IsNullOrEmpty(track.Url) + && (string.IsNullOrEmpty(track.FilePath) || !File.Exists(track.FilePath)) + && _download.IsDownloading(track.Url)) + { + ToastService.Instance.Show("Still downloading, try again in a moment.", ToastType.Info, scope: "download"); + return; + } + var t = new StepTimer("PlayTrack"); try { @@ -70,17 +87,14 @@ public void PlayTrack(Track? track) var contextChanged = _currentTrack == null || _currentTrack.MediaType != track.MediaType || (track.MediaType == MediaType.Audiobook && (_currentTrack.Album != track.Album || _currentTrack.Artist != track.Artist)); _playSessionId = Guid.NewGuid(); - CurrentTrack = track; - t.Step("setCurrentTrack"); - + t.Step("setCurrentTrack"); _navigator.CurrentTrack = track; if (contextChanged) _navigator.InvalidateDeck(); AlbumArtPath = track.AlbumArtPath; t.Step("navigator"); _playRecorded = false; - if (track.MediaType == MediaType.Radio && !string.IsNullOrEmpty(track.Url)) { _pendingResumeTicks = 0; @@ -93,14 +107,24 @@ public void PlayTrack(Track? track) return; } + // Patch D: Try to relink missing files before giving up and redownloading + if (!string.IsNullOrEmpty(track.FilePath) && !File.Exists(track.FilePath)) + { + _library.TryRelink(track); + t.Step("relink"); + } + if (!string.IsNullOrEmpty(track.FilePath) && File.Exists(track.FilePath)) { _pendingResumeTicks = track.MediaType == MediaType.Audiobook && _settings.AutoResumeAudiobooks ? track.PlaybackPositionTicks : 0; _playback.Play(track.FilePath); t.Step("playFile"); if (track.MediaType == MediaType.Audiobook) _playback.SetRate(_playbackRate); else _playback.SetRate(1.0f); - if (track.MediaType == MediaType.Audiobook) { _audiobookSaveTimer?.Dispose(); _audiobookSaveTimer = new System.Threading.Timer(_ => SaveAudiobookProgress(), null, 5000, 5000); } + + // Patch D: marshal audiobook progress saves to the UI thread. + if (track.MediaType == MediaType.Audiobook) { _audiobookSaveTimer?.Dispose(); _audiobookSaveTimer = new System.Threading.Timer(_ => Dispatcher.UIThread.Post(SaveAudiobookProgress), null, 5000, 5000); } else { _audiobookSaveTimer?.Dispose(); _audiobookSaveTimer = null; } + _trackStartTime = DateTime.UtcNow; StatusText = CurrentTrackDisplay; NullActionLogger.TrackPlayed(track.Id.ToString(), track.Title, track.Artist, nameof(PlayerViewModel)); @@ -109,13 +133,28 @@ public void PlayTrack(Track? track) if (!string.IsNullOrEmpty(track.Url)) { + // --- CONSENT GATE --- + if (!_download.HasConsent()) + { + _ = Task.Run(async () => + { + if (await _download.EnsureConsentAsync()) + Dispatcher.UIThread.Post(() => PlayTrack(track)); + }); + return; + } + _playback.Stop(); + t.Step("stop"); Position = 0; IsDownloading = true; StatusText = L("Player_Status_DownloadingBeforePlayback"); + t.Step("state"); NullActionLogger.ImportStarted(track.Url, nameof(PlayerViewModel)); + t.Step("log"); _ = _download.DownloadAsync(track.Id.ToString(), track.Url, _settings.AudioFormat, _settings.AudioQuality, title: track.Title, artist: track.Artist); + t.Step("startDownload"); return; } @@ -155,7 +194,13 @@ private void PlayPause() if (_settings.FadeOnPauseEnabled) _ = _playback.FadeAndResumeAsync(_settings.FadeOnPauseDurationMs); else _playback.Resume(); if (_currentTrack != null) NullActionLogger.TrackPlayed(_currentTrack.Id.ToString(), _currentTrack.Title, _currentTrack.Artist, nameof(PlayerViewModel)); } - else if (_currentTrack != null) PlayTrack(_currentTrack); + else if (_currentTrack != null) + { + // Engine and VM disagree (e.g. a state event was swallowed mid-crossfade): + // resync the button instead of restarting the track from zero. + if (_playback.IsPlaying) { _playback.ResyncState(); return; } + PlayTrack(_currentTrack); + } else PlaySelectedTrackRequested?.Invoke(); } @@ -187,7 +232,9 @@ private void OnTrackFinished() if (_position >= _settings.ScrobbleThreshold && _currentTrack.MediaType != MediaType.Audiobook) { var freshTrack = _library.GetAll().FirstOrDefault(t => t.Id == _currentTrack.Id); +#if !DEBUG TrackScrobbleRequested?.Invoke(freshTrack?.Title ?? _currentTrack.Title, freshTrack?.Artist ?? _currentTrack.Artist, DateTime.UtcNow); +#endif } } if (_navigator.ShouldRepeatCurrent() && _currentTrack != null) PlayTrack(_currentTrack); else PlayNext(); @@ -275,7 +322,6 @@ private void PlayNextCore() StatusText = "End of Audiobook"; return; } - var head = _library.GetQueue(); if (head.Count > 0) { @@ -289,7 +335,6 @@ private void PlayNextCore() return; } } - if (_activePlaylist != null && _activePlaylist.Tracks.Count > 0) { Track? pick = null; @@ -311,7 +356,6 @@ private void PlayNextCore() StatusText = $"End of '{_activePlaylist.Name}'."; return; } - var queuedAuto = _library.DequeueNext(); if (queuedAuto != null) { PlayTrack(queuedAuto); RefillAutoQueue(); return; } var next = _navigator.GetNextTrack(_currentTrack); @@ -342,7 +386,6 @@ private void RefillAutoQueue() var autoCount = _library.GetQueue().Count(e => !e.IsManual); var needed = target - autoCount; if (needed <= 0) return; - if (_activePlaylist != null && _activePlaylist.Tracks.Count > 0) { var adds = new System.Collections.Generic.List(); @@ -372,7 +415,9 @@ private void CheckCrossfade(float pos) { if (_currentTrack?.MediaType is MediaType.Radio or MediaType.Audiobook) return; if (!_settings.CrossfadeEnabled || _isCrossfading || _hasTriggeredCrossfade || _currentTrack == null) return; - var duration = _playback.Duration.TotalSeconds; + + // Patch A: use cached duration. + var duration = TrackDuration.TotalSeconds; if (duration <= 0) return; var remaining = duration - (pos * duration); if (remaining <= _settings.CrossfadeDurationSeconds) @@ -410,6 +455,10 @@ private void CheckCrossfade(float pos) RebuildAutoQueue(); } + // Patch D: don't silently lose a queued track whose file moved. + if (next != null && !string.IsNullOrEmpty(next.FilePath) && !File.Exists(next.FilePath)) + _library.TryRelink(next); + if (next != null && !string.IsNullOrEmpty(next.FilePath) && File.Exists(next.FilePath)) { _isCrossfading = true; @@ -418,7 +467,6 @@ private void CheckCrossfade(float pos) _navigator.CurrentTrack = next; AlbumArtPath = next.AlbumArtPath; StatusText = CurrentTrackDisplay; - _trackStartTime = DateTime.UtcNow; Log.Information("Starting crossfade transition to {NextTitle}", next.Title); var sessionId = _playSessionId; var crossfadeTask = _playback.CrossfadeToAsync(next.FilePath, _settings.CrossfadeDurationSeconds * 1000, _volume); @@ -429,9 +477,16 @@ private void CheckCrossfade(float pos) if (sessionId != _playSessionId) return; _isCrossfading = false; if (!t.IsCompletedSuccessfully || t.Result != _playback.CrossfadeGeneration) return; + _trackStartTime = DateTime.UtcNow; + // Mitigation for silent track B after crossfade + _playback.Volume = _volume; if (left != null) _navigator.RecordPlay(left); if (left != null && _playRecorded && left.MediaType != MediaType.Audiobook) + { + #if !DEBUG TrackScrobbleRequested?.Invoke(left.Title, left.Artist, DateTime.UtcNow); + #endif + } _playRecorded = false; _hasTriggeredCrossfade = false; NullActionLogger.TrackPlayed(next.Id.ToString(), next.Title, next.Artist, nameof(PlayerViewModel)); @@ -448,7 +503,9 @@ private void CheckCrossfade(float pos) private void CheckPlayRecorded() { if (_currentTrack == null || _playRecorded) return; - var duration = _playback.Duration.TotalSeconds; + + // Patch A: use cached duration. + var duration = TrackDuration.TotalSeconds; if (duration <= 0) return; var elapsed = Position * duration; if (elapsed >= Math.Min(30, duration * _settings.ScrobbleThreshold)) { _playRecorded = true; _library.RecordPlay(_currentTrack.Id); } @@ -480,4 +537,4 @@ private void CancelSleepTimer() _sleepTimer = null; SleepTimerRemaining = TimeSpan.Zero; } -} +} \ No newline at end of file diff --git a/ViewModels/Playback/PlayerViewModel.cs b/ViewModels/Playback/PlayerViewModel.cs index 8d25370..76e7f1c 100644 --- a/ViewModels/Playback/PlayerViewModel.cs +++ b/ViewModels/Playback/PlayerViewModel.cs @@ -62,16 +62,39 @@ public partial class PlayerViewModel : ViewModelBase, IDisposable private DateTime _lastNavigationTime = DateTime.MinValue; private static readonly TimeSpan NavigationDebounce = TimeSpan.FromMilliseconds(300); private DateTime _lastPlayPauseTime = DateTime.MinValue; + + // Patch A: cached duration + private TimeSpan _durationCache; + private long _durationCacheAt; + private static string L(string key) => LocalizationService.Instance[key]; + private TimeSpan TrackDuration + { + get + { + var now = Environment.TickCount64; + if (_durationCache <= TimeSpan.Zero || now - _durationCacheAt > 5000) + { + _durationCache = _playback.Duration; + _durationCacheAt = now; + } + return _durationCache; + } + } + public bool SleepTimerEndOfChapter { get => _sleepTimerEndOfChapter; set { _sleepTimerEndOfChapter = value; OnPropertyChanged(); } } + public float PlaybackRate { get => _playbackRate; set { _playbackRate = value; _playback.SetRate(value); OnPropertyChanged(); OnPropertyChanged(nameof(PlaybackRateDisplay)); OnPropertyChanged(nameof(PlaybackRateForeground)); OnPropertyChanged(nameof(IsRateAdjusted)); _settings.AudiobookPlaybackRate = value; } } public string PlaybackRateDisplay => $"{_playbackRate:0.0#}x"; + + #pragma warning disable CS0067 public event Action? TrackScrobbleRequested; + #pragma warning restore CS0067 public event Action? PlaySelectedTrackRequested; public PlayerViewModel(PlaybackService playback, DownloadService download, LibraryService library, SettingsViewModel settings, MetadataService metadata) @@ -81,7 +104,6 @@ public PlayerViewModel(PlaybackService playback, DownloadService download, Libra _playbackRate = _settings.AudiobookPlaybackRate; _playback.Volume = _volume; - // Subscribe using named methods for clean disposal _playback.PositionChanged += OnPlaybackPositionChanged; _playback.StateChanged += OnPlaybackStateChanged; _playback.TrackFinished += OnPlaybackTrackFinished; @@ -99,7 +121,6 @@ public PlayerViewModel(PlaybackService playback, DownloadService download, Libra public void Dispose() { - // Unsubscribe from Singleton services to prevent memory leaks during hot-reloads or testing _playback.PositionChanged -= OnPlaybackPositionChanged; _playback.StateChanged -= OnPlaybackStateChanged; _playback.TrackFinished -= OnPlaybackTrackFinished; @@ -116,10 +137,12 @@ public void Dispose() _sleepTimer?.Dispose(); } - // --- Extracted Event Handlers --- - private void OnPlaybackPositionChanged(float pos) - => Dispatcher.UIThread.Post(() => { Position = pos; CheckPlayRecorded(); CheckCrossfade(pos); }); + { + void Apply() { Position = pos; CheckPlayRecorded(); CheckCrossfade(pos); } + if (Dispatcher.UIThread.CheckAccess()) Apply(); + else Dispatcher.UIThread.Post(Apply); + } private void OnPlaybackStateChanged(PlaybackState state) => Dispatcher.UIThread.Post(() => { @@ -171,57 +194,108 @@ private void OnDownloadCompleted(string trackId, string filePath, bool isInterac IsDownloading = false; StatusText = L("Player_Status_DownloadComplete"); NullActionLogger.ImportCompleted(filePath, trackId, 0, nameof(PlayerViewModel)); + }); - if (Guid.TryParse(trackId, out var id)) - { - var track = _library.GetAll().FirstOrDefault(t => t.Id == id); - if (track != null) - { - track.FilePath = filePath; - var (tagTitle, tagArtist, duration) = _metadata.FetchFromLocalFile(filePath); - tagArtist = System.Text.RegularExpressions.Regex.Replace(tagArtist ?? "", @"\s*-\s*Topic\s*$", "", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Trim(); - - if (!string.IsNullOrWhiteSpace(tagTitle)) - { - var parsedTags = NullWave.Helpers.TrackNormalizer.TrySplit(tagTitle); - var cleanTitle = tagTitle; - var cleanArtist = tagArtist; - if (parsedTags != null && !string.IsNullOrWhiteSpace(parsedTags.Value.Title)) - { - cleanTitle = parsedTags.Value.Title; - if (!string.IsNullOrWhiteSpace(parsedTags.Value.Artist)) - cleanArtist = parsedTags.Value.Artist; - } - - if (NullWave.Helpers.TrackNormalizer.IsPlaceholderTitle(track.Title) || System.Text.RegularExpressions.Regex.IsMatch(track.Title ?? "", @"\([A-Za-z0-9_-]{11}\)$")) track.Title = cleanTitle; - if (NullWave.Helpers.TrackNormalizer.IsPlaceholderArtist(track.Artist) && !string.IsNullOrWhiteSpace(cleanArtist)) track.Artist = cleanArtist; + if (Guid.TryParse(trackId, out var id)) + { + _ = CompleteDownloadAsync(id, trackId, filePath); + } + } - _download.UpdateJobMetadata(trackId, track.Title, track.Artist); - } + private async Task CompleteDownloadAsync(Guid id, string trackId, string filePath) + { + try + { + // TagLib read off the UI thread. + var tags = await Task.Run(() => _metadata.FetchFromLocalFile(filePath)); + + // Model mutation on the UI thread. + var track = await Dispatcher.UIThread.InvokeAsync(() => ApplyDownloadedTags(id, trackId, filePath, tags)); + if (track == null) return; + + // TagLib write + rename + DB update off the UI thread. + await Task.Run(() => _library.NormalizeLocalFile(track)); + + Dispatcher.UIThread.Post(() => + { + var fresh = _library.GetAll().FirstOrDefault(t => t.Id == id) ?? track; + + if (_currentTrack?.Id == id || _state != PlaybackState.Playing) + PlayTrack(fresh); + else + ToastService.Instance.Show($"'{fresh.Title}' downloaded.", ToastType.Success, scope: "download"); + }); + } + catch (Exception ex) + { + Log.Error(ex, "[PlayerViewModel] Download completion failed for {Path}", filePath); + Dispatcher.UIThread.Post(() => IsDownloading = false); + } + } - track.Duration = duration; - _library.Update(track); - _library.NormalizeLocalFile(track); + private Track? ApplyDownloadedTags( + Guid id, + string trackId, + string filePath, + (string? Title, string? Artist, TimeSpan Duration) tags) + { + var track = _library.GetAll().FirstOrDefault(t => t.Id == id); + if (track == null) return null; - var fresh = _library.GetAll().FirstOrDefault(t => t.Id == id); - if (_currentTrack?.Id == id || _state != PlaybackState.Playing) - PlayTrack(fresh ?? track); - else - ToastService.Instance.Show($"'{(fresh ?? track).Title}' downloaded.", ToastType.Success, scope: "download"); - } + track.FilePath = filePath; + var (tagTitle, tagArtist, duration) = tags; + + tagArtist = System.Text.RegularExpressions.Regex.Replace( + tagArtist ?? "", + @"\s*-\s*Topic\s*$", + "", + System.Text.RegularExpressions.RegexOptions.IgnoreCase).Trim(); + + if (!string.IsNullOrWhiteSpace(tagTitle)) + { + var parsedTags = NullWave.Helpers.TrackNormalizer.TrySplit(tagTitle); + var cleanTitle = tagTitle; + var cleanArtist = tagArtist; + + if (parsedTags != null && !string.IsNullOrWhiteSpace(parsedTags.Value.Title)) + { + cleanTitle = parsedTags.Value.Title; + if (!string.IsNullOrWhiteSpace(parsedTags.Value.Artist)) + cleanArtist = parsedTags.Value.Artist; } - }); + + if (NullWave.Helpers.TrackNormalizer.IsPlaceholderTitle(track.Title) || + System.Text.RegularExpressions.Regex.IsMatch(track.Title ?? "", @"\([A-Za-z0-9_-]{11}\)$")) + { + track.Title = cleanTitle; + } + + if (NullWave.Helpers.TrackNormalizer.IsPlaceholderArtist(track.Artist) && + !string.IsNullOrWhiteSpace(cleanArtist)) + { + track.Artist = cleanArtist; + } + + _download.UpdateJobMetadata(trackId, track.Title, track.Artist); + } + + track.Duration = duration; + _library.Update(track); + + return track; } private void OnDownloadFailed(string trackId, string? error, bool isInteractive) { if (!isInteractive) return; if (string.Equals(error, "Cancelled", StringComparison.OrdinalIgnoreCase)) - return; // superseded by a newer interactive download; leave IsDownloading alone + return; + Dispatcher.UIThread.Post(() => { IsDownloading = false; StatusText = string.Format(L("Player_Status_DownloadFailed"), error ?? L("Player_Status_UnknownError")); NullActionLogger.Error(nameof(PlayerViewModel), $"Download failed: {error ?? "Unknown error"}", $"trackId={trackId}"); + if (Guid.TryParse(trackId, out var id)) { var failedTrack = _library.GetAll().FirstOrDefault(t => t.Id == id); if (failedTrack != null) { @@ -244,7 +318,10 @@ public Track? CurrentTrack get => _currentTrack; private set { - _currentTrack = value; OnPropertyChanged(); OnPropertyChanged(nameof(CurrentTrackDisplay)); OnPropertyChanged(nameof(CurrentArtistDisplay)); + _currentTrack = value; + _durationCache = TimeSpan.Zero; // Patch A: force duration re-read + + OnPropertyChanged(); OnPropertyChanged(nameof(CurrentTrackDisplay)); OnPropertyChanged(nameof(CurrentArtistDisplay)); OnPropertyChanged(nameof(CurrentArtistOrNowPlayingDisplay)); OnPropertyChanged(nameof(HasAlbumArt)); OnPropertyChanged(nameof(AlbumArtPath)); OnPropertyChanged(nameof(IsCurrentFavorite)); OnPropertyChanged(nameof(IsAudiobook)); OnPropertyChanged(nameof(IsRadio)); OnPropertyChanged(nameof(Show10sSeek)); OnPropertyChanged(nameof(ArtPlaceholderKind)); @@ -278,8 +355,15 @@ private set public MaterialIconKind PlayPauseIconKind => IsPlaying ? MaterialIconKind.Pause : MaterialIconKind.Play; public float Position { get => _position; set { _position = value; OnPropertyChanged(); OnPropertyChanged(nameof(PositionDisplay)); OnPropertyChanged(nameof(DurationDisplay)); } } - public string PositionDisplay { get { var total = _playback.Duration; var current = TimeSpan.FromSeconds(Position * total.TotalSeconds); return $"{(int)current.TotalMinutes:D2}:{current.Seconds:D2}"; } } - public string DurationDisplay { get { var total = _playback.Duration; return $"{(int)total.TotalMinutes:D2}:{total.Seconds:D2}"; } } + + public string PositionDisplay + { + get { var total = TrackDuration; var current = TimeSpan.FromSeconds(Position * total.TotalSeconds); return $"{(int)current.TotalMinutes:D2}:{current.Seconds:D2}"; } + } + public string DurationDisplay + { + get { var total = TrackDuration; return $"{(int)total.TotalMinutes:D2}:{total.Seconds:D2}"; } + } public float Volume { get => _volume; set { _volume = value; _playback.Volume = value; OnPropertyChanged(); OnPropertyChanged(nameof(VolumeIconKind)); } } public bool IsDownloading { get => _isDownloading; private set { _isDownloading = value; OnPropertyChanged(); } } @@ -334,13 +418,13 @@ public TimeSpan SleepTimerRemaining public string SleepTimerDisplay => IsSleepTimerActive ? $"{(int)_sleepTimerRemaining.TotalMinutes:D2}:{_sleepTimerRemaining.Seconds:D2}" : ""; private void OnBufferingChanged(bool isBuffering) - => Dispatcher.UIThread.Post(() => IsBuffering = isBuffering); + => Dispatcher.UIThread.Post(() => IsBuffering = isBuffering); private void OnRadioMetadataChanged(string title, string artist) - => Dispatcher.UIThread.Post(() => { - if (_currentTrack?.MediaType != MediaType.Radio) return; - NowPlayingTitle = title ?? string.Empty; - NowPlayingArtist = artist ?? string.Empty; - if (HasNowPlaying) StatusText = NowPlayingDisplay; - }); -} + => Dispatcher.UIThread.Post(() => { + if (_currentTrack?.MediaType != MediaType.Radio) return; + NowPlayingTitle = title ?? string.Empty; + NowPlayingArtist = artist ?? string.Empty; + if (HasNowPlaying) StatusText = NowPlayingDisplay; + }); +} \ No newline at end of file diff --git a/ViewModels/Playback/QueueViewModel.cs b/ViewModels/Playback/QueueViewModel.cs index ea78d95..a0eb03c 100644 --- a/ViewModels/Playback/QueueViewModel.cs +++ b/ViewModels/Playback/QueueViewModel.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Windows.Input; +using Avalonia.Threading; using NullWave.Helpers; using NullWave.Models; using NullWave.Services; @@ -12,6 +15,7 @@ public class QueueViewModel : ViewModelBase { private readonly LibraryService _library; private bool _isOpen; + private int _refreshPending; public bool IsOpen { @@ -46,18 +50,15 @@ public bool IsOpen public QueueViewModel(LibraryService library) { _library = library; - _library.QueueChanged += (_, _) => Refresh(); + _library.QueueChanged += (_, _) => ScheduleRefresh(); Refresh(); CloseCommand = new RelayCommand(() => IsOpen = false); - ClearQueueCommand = new RelayCommand(() => { var snapshot = _library.GetQueue().ToList(); if (snapshot.Count == 0) return; - _library.ClearQueue(); - ToastService.Instance.Show( message: $"Cleared {snapshot.Count} item(s) from queue.", type: ToastType.Warning, @@ -71,6 +72,7 @@ public QueueViewModel(LibraryService library) { if (t != null) _library.RemoveFromQueue(t.Id); }); + MoveUpCommand = new RelayCommand(t => Move(t, -1)); MoveDownCommand = new RelayCommand(t => Move(t, 1)); PlayNowCommand = new RelayCommand(t => @@ -79,6 +81,12 @@ public QueueViewModel(LibraryService library) }); } + private void ScheduleRefresh() + { + if (Interlocked.Exchange(ref _refreshPending, 1) == 1) return; + Dispatcher.UIThread.Post(() => { Volatile.Write(ref _refreshPending, 0); Refresh(); }, DispatcherPriority.Background); + } + public void MoveTrackTo(Track track, int newIndex) { var current = _library.GetQueue().ToList(); 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/Profile/UserProfileViewModel.cs b/ViewModels/Profile/UserProfileViewModel.cs index d6d2582..f7313ba 100644 --- a/ViewModels/Profile/UserProfileViewModel.cs +++ b/ViewModels/Profile/UserProfileViewModel.cs @@ -38,6 +38,7 @@ public sealed record ArtistStat(string Name, int PlayCount); private readonly PreferencesService _prefs; private readonly IdentityService _identity; private readonly PropertyChangedEventHandler _langHandler; + private readonly Action> _prefsHandler; private readonly string _badgesPath; private List _grantedBadges = new(); @@ -108,6 +109,16 @@ public UserProfileViewModel(LibraryService? library, PreferencesService prefs, I _library = library; _prefs = prefs; _identity = identity; + _prefsHandler = changed => + { + if (!changed.Contains(nameof(Preferences.ForceAllFeatures)) && + !changed.Contains(nameof(Preferences.EnableExperimentalFeatures))) + return; + + OnPropertyChanged(nameof(IsExperimentalModeActive)); + OnPropertyChanged(nameof(ShowAdvancedFeatures)); + }; + _prefs.Changed += _prefsHandler; _badgesPath = NullWavePaths.BadgesPath; Load(); _savedUsername = _username; _savedBio = _bio; _savedHasAvatar = HasAvatar; _savedAvatarPath = _avatarPath; _savedBannerImagePath = _bannerImagePath; @@ -143,6 +154,7 @@ public UserProfileViewModel(LibraryService? library, PreferencesService prefs, I public bool IsDirty => _username != _savedUsername || _bio != _savedBio || HasAvatar != _savedHasAvatar || _avatarPath != _savedAvatarPath || _bannerImagePath != _savedBannerImagePath; public bool ShowSaveToast { get => _showSaveToast; private set { _showSaveToast = value; OnPropertyChanged(); } } public bool IsExperimentalModeActive => _prefs.Current.EnableExperimentalFeatures || CheckDevAccess(); + public bool ShowAdvancedFeatures => _prefs.Current.ForceAllFeatures || IsExperimentalModeActive; public int TotalTracks => _totalTracks; @@ -270,6 +282,7 @@ public void Dispose() { _saveTimer?.Dispose(); LocalizationService.Instance.PropertyChanged -= _langHandler; + _prefs.Changed -= _prefsHandler; if (_library != null) _library.LibraryChanged -= OnLibraryChanged; _statsTimer?.Stop(); } diff --git a/ViewModels/Settings/IntegrationsHubViewModel.cs b/ViewModels/Settings/IntegrationsHubViewModel.cs new file mode 100644 index 0000000..03d49d4 --- /dev/null +++ b/ViewModels/Settings/IntegrationsHubViewModel.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Material.Icons; +using NullWave.Helpers; +using NullWave.Services; +using NullWave.Plugins.Contracts; +using IPluginCatalog = NullWave.Plugins.Contracts.IPluginCatalog; + +namespace NullWave.ViewModels.Settings; + +internal static class PluginUi +{ + public static string L(string key) => LocalizationService.Instance[key]; + + public static IReadOnlyList Tags(ProviderKind[] kinds) => + kinds.Select(k => L($"Settings_Integrations_Group_{k}")).ToList(); + + public static MaterialIconKind IconFor(ProviderKind[] kinds) => + (kinds.Length > 0 ? kinds[0] : (ProviderKind?)null) switch + { + ProviderKind.Download => MaterialIconKind.Download, + ProviderKind.Metadata => MaterialIconKind.TagMultiple, + ProviderKind.Weather => MaterialIconKind.WeatherPartlyCloudy, + ProviderKind.Mood => MaterialIconKind.Brain, + ProviderKind.Search => MaterialIconKind.Magnify, + ProviderKind.Radio => MaterialIconKind.Radio, + ProviderKind.PlaylistImport => MaterialIconKind.PlaylistPlus, + _ => MaterialIconKind.Puzzle + }; + + public static bool Matches(PluginManifest m, string name, string desc, string q, string category) + { + if (!string.Equals(category, "All", StringComparison.Ordinal) && + !m.Kinds.Any(k => string.Equals(k.ToString(), category, StringComparison.Ordinal))) + return false; + + if (string.IsNullOrWhiteSpace(q)) return true; + + bool Has(string s) => s != null && s.Contains(q.Trim(), StringComparison.OrdinalIgnoreCase); + return Has(name) || Has(desc) || Has(m.Author) || Tags(m.Kinds).Any(Has); + } +} + +public partial class CategoryChipViewModel : ObservableObject +{ + public string Key { get; } + public string Label => Key == "All" + ? PluginUi.L("Settings_Plugins_All") + : PluginUi.L($"Settings_Integrations_Group_{Key}"); + + [ObservableProperty] private bool _isSelected; + + public CategoryChipViewModel(string key, bool selected) + { + Key = key; + _isSelected = selected; + } +} + +public sealed class StoreEntryViewModel +{ + public PluginManifest Manifest { get; } + + public StoreEntryViewModel(PluginManifest m) => Manifest = m; + + public string Name => Manifest.Name; + public string Author => Manifest.Author; + public string Description => Manifest.Description; + public string VersionLabel => Manifest.Version; + public IReadOnlyList KindTags => PluginUi.Tags(Manifest.Kinds); + public MaterialIconKind PrimaryIcon => PluginUi.IconFor(Manifest.Kinds); + + public bool CanInstall => Manifest.Availability == PluginAvailability.Installable; + + public string ActionLabel => Manifest.Availability switch + { + PluginAvailability.Installed => PluginUi.L("Settings_Plugins_InstalledLabel"), + PluginAvailability.Installable => PluginUi.L("Settings_Plugins_Install"), + _ => PluginUi.L("Settings_Plugins_ComingSoon") + }; + + public bool Matches(string q, string cat) => + PluginUi.Matches(Manifest, Name, Description, q, cat); +} + +public partial class IntegrationsHubViewModel : ObservableObject +{ + private readonly IReadOnlyList _installed; + private readonly IReadOnlyList _store; + + public IReadOnlyList Categories { get; } + public ObservableCollection VisibleInstalled { get; } = new(); + public ObservableCollection VisibleStore { get; } = new(); + + [ObservableProperty] private string _tab = "Installed"; + [ObservableProperty] private string _search = string.Empty; + [ObservableProperty] private string _category = "All"; + + public bool IsInstalledTab => Tab == "Installed"; + public bool IsStoreTab => Tab == "Store"; + public bool HasNoResults => IsInstalledTab + ? VisibleInstalled.Count == 0 + : VisibleStore.Count == 0; + + public IntegrationsHubViewModel( + IEnumerable installed, + IPluginCatalog catalog) + { + _installed = installed.ToList(); + + var installedIds = _installed.Select(r => r.Manifest.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + _store = catalog.StoreEntries + .Where(m => !installedIds.Contains(m.Id)) + .Select(m => new StoreEntryViewModel(m)) + .ToList(); + + var kinds = _installed.SelectMany(r => r.Manifest.Kinds) + .Concat(_store.SelectMany(s => s.Manifest.Kinds)) + .Distinct() + .OrderBy(k => k) + .Select(k => k.ToString()) + .ToList(); + + var categoryList = new List { new("All", true) }; + categoryList.AddRange(kinds.Select(k => new CategoryChipViewModel(k, false))); + Categories = categoryList; + + Refilter(); + } + + partial void OnTabChanged(string value) + { + OnPropertyChanged(nameof(IsInstalledTab)); + OnPropertyChanged(nameof(IsStoreTab)); + OnPropertyChanged(nameof(HasNoResults)); + Refilter(); + } + + partial void OnSearchChanged(string value) => Refilter(); + + partial void OnCategoryChanged(string value) + { + foreach (var c in Categories) + c.IsSelected = string.Equals(c.Key, value, StringComparison.Ordinal); + Refilter(); + } + + [RelayCommand] + private void SetTab(string tab) => Tab = tab; + + [RelayCommand] + private void SetCategory(string key) => Category = key; + + private void Refilter() + { + VisibleInstalled.Clear(); + foreach (var r in _installed.Where(r => r.Matches(Search, Category))) + VisibleInstalled.Add(r); + + VisibleStore.Clear(); + foreach (var s in _store.Where(s => s.Matches(Search, Category))) + VisibleStore.Add(s); + + OnPropertyChanged(nameof(HasNoResults)); + } + + public void RefreshAll() + { + foreach (var r in _installed) + r.RefreshFromPlugin(); + } +} \ No newline at end of file diff --git a/ViewModels/Settings/PluginRowViewModel.cs b/ViewModels/Settings/PluginRowViewModel.cs index 65c71dd..c784de6 100644 --- a/ViewModels/Settings/PluginRowViewModel.cs +++ b/ViewModels/Settings/PluginRowViewModel.cs @@ -1,26 +1,77 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Avalonia; using Avalonia.Media; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; +using Material.Icons; using NullWave.Helpers; using NullWave.Models; using NullWave.Services; using NullWave.Services.Plugins; using Serilog; +using NullWave.Plugins.Contracts; namespace NullWave.ViewModels.Settings; -public partial class PluginRowViewModel : ObservableObject +public partial class PluginRowViewModel : ObservableObject, IDisposable { private readonly IPlugin _plugin; private readonly Action _persistToggle; private CancellationTokenSource? _initCts; - public string Name => _plugin.Name; - public string Description => _plugin.Description; + public PluginManifest Manifest { get; } + + public string Name => _plugin.Name switch + { + "yt-dlp Downloader" => LocalizationService.Instance["Settings_Integrations_Plugin_YtDlp_Name"], + "Last.fm" => LocalizationService.Instance["Settings_Integrations_Plugin_LastFm_Name"], + "OpenWeather" => LocalizationService.Instance["Settings_Integrations_Plugin_OpenWeather_Name"], + "Ollama Local AI" => LocalizationService.Instance["Settings_Integrations_Plugin_Ollama_Name"], + _ => _plugin.Name + }; + + public string Description => _plugin.Name switch + { + "yt-dlp Downloader" => LocalizationService.Instance["Settings_Integrations_Plugin_YtDlp_Desc"], + "Last.fm" => LocalizationService.Instance["Settings_Integrations_Plugin_LastFm_Desc"], + "OpenWeather" => LocalizationService.Instance["Settings_Integrations_Plugin_OpenWeather_Desc"], + "Ollama Local AI" => LocalizationService.Instance["Settings_Integrations_Plugin_Ollama_Desc"], + _ => _plugin.Description + }; + + public string Author => Manifest.Author; + public string VersionLabel => Manifest.BuiltIn + ? LocalizationService.Instance["Settings_Plugins_BuiltIn"] + : Manifest.Version; + public IReadOnlyList KindTags => PluginUi.Tags(Manifest.Kinds); + public MaterialIconKind PrimaryIcon => PluginUi.IconFor(Manifest.Kinds); + + private PluginState EffectiveState => !IsEnabled ? PluginState.Disabled : State; + + public string StatusText => EffectiveState switch + { + PluginState.Available => LocalizationService.Instance["Settings_Integrations_Status_Available"], + PluginState.Loading => LocalizationService.Instance["Settings_Integrations_Status_Loading"], + PluginState.Error => LocalizationService.Instance["Settings_Integrations_Status_Error"], + PluginState.Disabled => LocalizationService.Instance["Settings_Integrations_Status_Disabled"], + PluginState.Unavailable => LocalizationService.Instance["Settings_Plugins_NeedsSetup"], + _ => LocalizationService.Instance["Settings_Integrations_Status_Unknown"] + }; + + public string? SetupPage => EffectiveState != PluginState.Unavailable + ? null + : Manifest.Id switch + { + "lastfm" or "openweather" => "ApiKeys", + "yt-dlp" => "Updates", + "ollama" => "SmartFeatures", + _ => null + }; + + public bool NeedsAttention => IsEnabled && State == PluginState.Unavailable; [ObservableProperty] private PluginState _state; @@ -31,12 +82,13 @@ public partial class PluginRowViewModel : ObservableObject [ObservableProperty] private bool _isBusy; - public IBrush StatusDotBrush => State switch + public IBrush StatusDotBrush => EffectiveState switch { PluginState.Available => (IBrush)Application.Current!.Resources["BrushGreen"]!, - PluginState.Loading => (IBrush)Application.Current!.Resources["BrushAmber"]!, - PluginState.Error => (IBrush)Application.Current!.Resources["BrushRed"]!, - _ => (IBrush)Application.Current!.Resources["BrushTextMuted"]! + PluginState.Loading => (IBrush)Application.Current!.Resources["BrushAmber"]!, + PluginState.Error => (IBrush)Application.Current!.Resources["BrushRed"]!, + PluginState.Disabled => (IBrush)Application.Current!.Resources["BrushTextMuted"]!, + _ => (IBrush)Application.Current!.Resources["BrushAmber"]! }; public PluginRowViewModel(IPlugin plugin, Action persistToggle) @@ -45,23 +97,67 @@ public PluginRowViewModel(IPlugin plugin, Action persistToggle) _persistToggle = persistToggle; _state = plugin.State; _isEnabled = plugin.IsEnabled; + Manifest = BuiltInPluginManifest.FromBuiltIn(plugin); + LocalizationService.Instance.PropertyChanged += OnLocalizationChanged; + } + + partial void OnStateChanged(PluginState value) + { + NotifyStatus(); + } + + partial void OnIsEnabledChanged(bool value) + { + NotifyStatus(); } - // FIX: Replaced partial void OnIsEnabledChanged with an explicit method called - // from the View's Click event. This completely eliminates the Avalonia binding - // coercion storm that caused background threads to mutate the UI collection. + private void NotifyStatus() + { + OnPropertyChanged(nameof(StatusDotBrush)); + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(SetupPage)); + OnPropertyChanged(nameof(NeedsAttention)); + } + + public void RefreshFromPlugin() + { + State = _plugin.State; + IsEnabled = _plugin.IsEnabled; + NotifyStatus(); + } + + private void OnLocalizationChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + OnPropertyChanged(nameof(Name)); + OnPropertyChanged(nameof(Description)); + OnPropertyChanged(nameof(VersionLabel)); + OnPropertyChanged(nameof(KindTags)); + OnPropertyChanged(nameof(StatusText)); + } + + public void Dispose() + { + LocalizationService.Instance.PropertyChanged -= OnLocalizationChanged; + _initCts?.Cancel(); + _initCts?.Dispose(); + } + + public bool Matches(string query, string category) => + PluginUi.Matches(Manifest, Name, Description, query, category); + public void UserToggled(bool newValue) { if (_plugin.IsEnabled == newValue) return; _plugin.IsEnabled = newValue; _persistToggle(newValue); - IsEnabled = newValue; // Update UI property - + IsEnabled = newValue; + NotifyStatus(); + _initCts?.Cancel(); _initCts?.Dispose(); _initCts = new CancellationTokenSource(); - + _ = ReinitializeAsync(_initCts.Token); } @@ -72,27 +168,24 @@ private async Task ReinitializeAsync(CancellationToken ct) try { var wasEnabled = _plugin.IsEnabled; - - // Run the actual plugin initialization - await _plugin.InitializeAsync(ct); - + + await _plugin.InitializeAsync(null!, ct); + if (ct.IsCancellationRequested) return; - // Marshal all property changes back to the UI thread await Dispatcher.UIThread.InvokeAsync(() => { State = _plugin.State; - OnPropertyChanged(nameof(StatusDotBrush)); + NotifyStatus(); }); - // Only notify if the user just enabled it if (!wasEnabled) return; var message = State switch { - PluginState.Available => $"{Name} connected successfully.", - PluginState.Error => $"{Name} failed to connect - check configuration.", - PluginState.Unavailable => $"{Name} is unavailable right now.", + PluginState.Available => string.Format(LocalizationService.Instance["Settings_Integrations_Plugin_Connected"], Name), + PluginState.Error => string.Format(LocalizationService.Instance["Settings_Integrations_Plugin_Failed"], Name), + PluginState.Unavailable => string.Format(LocalizationService.Instance["Settings_Integrations_Plugin_Unavailable"], Name), _ => null }; @@ -104,7 +197,6 @@ await Dispatcher.UIThread.InvokeAsync(() => } catch (OperationCanceledException) { - // Expected when the user toggles rapidly } catch (Exception ex) { diff --git a/ViewModels/Settings/SettingsViewModel.AI.cs b/ViewModels/Settings/SettingsViewModel.AI.cs index 42f9161..2e581a3 100644 --- a/ViewModels/Settings/SettingsViewModel.AI.cs +++ b/ViewModels/Settings/SettingsViewModel.AI.cs @@ -8,6 +8,7 @@ using NullWave.Services; using NullWave.Services.SmartSorting; using NullWave.Services.Plugins; +using NullWave.Plugins.Contracts; using Serilog; namespace NullWave.ViewModels; diff --git a/ViewModels/Settings/SettingsViewModel.ApiKeys.cs b/ViewModels/Settings/SettingsViewModel.ApiKeys.cs index d54bdc2..fee24af 100644 --- a/ViewModels/Settings/SettingsViewModel.ApiKeys.cs +++ b/ViewModels/Settings/SettingsViewModel.ApiKeys.cs @@ -66,6 +66,12 @@ public string OpenWeatherApiKey public bool IsYouTubeKeyValid => !string.IsNullOrWhiteSpace(YouTubeApiKey) && YouTubeApiKey.StartsWith("AIza", System.StringComparison.OrdinalIgnoreCase) && YouTubeApiKey.Length >= 30; public bool IsLastFmKeyValid => !string.IsNullOrWhiteSpace(LastFmApiKey) && LastFmApiKey.Length == 32; public bool IsOpenWeatherKeyValid => !string.IsNullOrWhiteSpace(OpenWeatherApiKey) && OpenWeatherApiKey.Length == 32; + public bool IsWeakerKeyStorageActive => _keyStore.UsesWeakerFallback; + public string KeyStorageFallbackNotice => OperatingSystem.IsLinux() + ? L("Settings_ApiKeys_WeakerStorageLinux") + : OperatingSystem.IsWindows() + ? L("Settings_ApiKeys_WeakerStorageWindows") + : L("Settings_ApiKeys_WeakerStorageOther"); #endregion #region LastFm State @@ -100,12 +106,14 @@ [RelayCommand] private void SaveKeys() if (!string.IsNullOrWhiteSpace(LastFmApiKey)) _keyStore.SaveKey("LastFm", LastFmApiKey); if (!string.IsNullOrWhiteSpace(LastFmApiSecret)) _keyStore.SaveKey("LastFm:Secret", LastFmApiSecret); if (!string.IsNullOrWhiteSpace(OpenWeatherApiKey)) _keyStore.SaveKey("OpenWeather", OpenWeatherApiKey); + RefreshKeyStorageNotice(); } [RelayCommand] private void DeleteApiKeys() { _secureDelete.DeleteApiKeys(); YouTubeApiKey = SpotifyClientId = SpotifyClientSecret = SoundCloudClientId = LastFmApiKey = LastFmApiSecret = OpenWeatherApiKey = string.Empty; + RefreshKeyStorageNotice(); } [RelayCommand] private void DeleteLogs() => _secureDelete.DeleteLogs(); @@ -114,13 +122,22 @@ [RelayCommand] private void DeleteEverything() { _secureDelete.DeleteEverything(); YouTubeApiKey = SpotifyClientId = SpotifyClientSecret = SoundCloudClientId = LastFmApiKey = LastFmApiSecret = OpenWeatherApiKey = string.Empty; + RefreshKeyStorageNotice(); + } + + private void RefreshKeyStorageNotice() + { + OnPropertyChanged(nameof(IsWeakerKeyStorageActive)); + OnPropertyChanged(nameof(KeyStorageFallbackNotice)); + if (_keyStore.SecretServiceClearFailed) + ToastService.Instance.Show(L("Settings_ApiKeys_SecretServiceClearFailed"), ToastType.Warning); } [RelayCommand] private void ResetPreferences() { var prefsPath = System.IO.Path.Combine(NullWave.Helpers.NullWavePaths.DataDir, "prefs.json"); if (System.IO.File.Exists(prefsPath)) { try { System.IO.File.Delete(prefsPath); } catch { } } - ToastService.Instance.Show("Preferences reset to defaults. Please restart NullWave to apply.", ToastType.Success); + ToastService.Instance.Show(L("Settings_Maintenance_Status_PreferencesReset"), ToastType.Success); } [RelayCommand] private void LastFmConnect() diff --git a/ViewModels/Settings/SettingsViewModel.Maintenance.cs b/ViewModels/Settings/SettingsViewModel.Maintenance.cs index 68b4aac..1999dad 100644 --- a/ViewModels/Settings/SettingsViewModel.Maintenance.cs +++ b/ViewModels/Settings/SettingsViewModel.Maintenance.cs @@ -38,21 +38,21 @@ public partial class SettingsViewModel [ObservableProperty] private string _dedupeStatus = string.Empty; [ObservableProperty] private string _syncFilesStatus = string.Empty; - [RelayCommand] private void ClearThumbnails() { ThumbnailStatus = "Clearing thumbnails..."; ClearThumbnailsRequested?.Invoke(); } - [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 VacuumDatabase() { IsRepairing = true; VacuumStatus = "Optimizing database..."; VacuumDatabaseRequested?.Invoke(); } + [RelayCommand] private void ClearThumbnails() { ThumbnailStatus = L("Settings_Maintenance_Status_ClearingThumbnails"); ClearThumbnailsRequested?.Invoke(); } + [RelayCommand] private void RepairPaths() { IsRepairing = true; RepairStatus = L("Settings_Maintenance_Status_ScanningPaths"); RepairPathsRequested?.Invoke(); } + [RelayCommand] private void ReimportAssets() { IsRepairing = true; RepairStatus = L("Settings_Maintenance_Status_ScanningDownloads"); ReimportAssetsRequested?.Invoke(); } + [RelayCommand] private void ForceMetaResync() { IsRepairing = true; RepairStatus = L("Settings_Maintenance_Status_ResyncStarting"); ForceMetaResyncRequested?.Invoke(); } + [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 = L("Settings_Maintenance_Status_OptimizingDatabase"); 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(); } - [RelayCommand] private void ForceCleanTitles() { IsRepairing = true; ForceCleanStatus = "Re-parsing track titles for embedded artist names..."; ForceCleanTitlesRequested?.Invoke(); } + [RelayCommand] private void VerifyLinks() { IsRepairing = true; VerifyLinksStatus = L("Settings_Maintenance_Status_CheckingLinks"); VerifyLinksRequested?.Invoke(); } + [RelayCommand] private void ForceCleanTitles() { IsRepairing = true; ForceCleanStatus = L("Settings_Maintenance_Status_CleaningTitles"); ForceCleanTitlesRequested?.Invoke(); } [RelayCommand] private void MergeSimilarArtists() => MergeSimilarArtistsRequested?.Invoke(); - [RelayCommand] private void PreviewDuplicates() { IsRepairing = true; DedupeStatus = "Scanning for duplicate tracks..."; RemoveDuplicatesRequested?.Invoke(true); } - [RelayCommand] private void RemoveDuplicates() { IsRepairing = true; DedupeStatus = "Removing duplicate tracks..."; RemoveDuplicatesRequested?.Invoke(false); } - [RelayCommand] private void PreviewSyncFiles() { IsRepairing = true; SyncFilesStatus = "Scanning files..."; SyncFilesRequested?.Invoke(true); } - [RelayCommand] private void SyncFiles() { IsRepairing = true; SyncFilesStatus = "Syncing files..."; SyncFilesRequested?.Invoke(false); } + [RelayCommand] private void PreviewDuplicates() { IsRepairing = true; DedupeStatus = L("Settings_Maintenance_Status_ScanningDuplicates"); RemoveDuplicatesRequested?.Invoke(true); } + [RelayCommand] private void RemoveDuplicates() { IsRepairing = true; DedupeStatus = L("Settings_Maintenance_Status_RemovingDuplicates"); RemoveDuplicatesRequested?.Invoke(false); } + [RelayCommand] private void PreviewSyncFiles() { IsRepairing = true; SyncFilesStatus = L("Settings_Maintenance_Status_ScanningFiles"); SyncFilesRequested?.Invoke(true); } + [RelayCommand] private void SyncFiles() { IsRepairing = true; SyncFilesStatus = L("Settings_Maintenance_Status_SyncingFiles"); SyncFilesRequested?.Invoke(false); } [RelayCommand] private void RequestBackfillDurations() => BackfillDurationsRequested?.Invoke(); public event Action? BackupDatabaseRequested; [RelayCommand] private void RequestBackup(string path) => BackupDatabaseRequested?.Invoke(path); @@ -63,99 +63,140 @@ public partial class SettingsViewModel public void ReportDedupeComplete(int scanned, int groups, int removed, bool wasDryRun) { IsRepairing = false; - 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)."; + DedupeStatus = wasDryRun + ? string.Format(L("Settings_Maintenance_Status_DedupePreview"), groups, removed, scanned) + : string.Format(L("Settings_Maintenance_Status_DedupeComplete"), removed, groups); 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; - SweepStatus = wasDryRun ? $"Found {orphaned} orphaned file(s) out of {scanned} scanned. Click Sweep to delete." : failed == 0 ? $"✓ Deleted {deleted} orphaned file(s)." : $"Deleted {deleted} file(s), {failed} failed (check logs)."; - ToastService.Instance.Show(SweepStatus, failed > 0 ? ToastType.Warning : ToastType.Success, scope: "maintenance"); + var strings = LocalizationService.Instance; + if (guardRefused) + { + SweepStatus = strings["Settings_Maintenance_Sweep_Guard"]; + ToastService.Instance.Show(SweepStatus, ToastType.Warning, scope: "maintenance"); + return; + } + 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) { IsRepairing = false; var saved = beforeKB - afterKB; - VacuumStatus = saved > 0 ? $"✓ Optimized: {beforeKB}KB → {afterKB}KB ({saved}KB reclaimed)" : $"✓ Database already optimal ({afterKB}KB)"; + VacuumStatus = saved > 0 + ? string.Format(L("Settings_Maintenance_Status_VacuumComplete"), beforeKB, afterKB, saved) + : string.Format(L("Settings_Maintenance_Status_VacuumAlreadyOptimal"), afterKB); ToastService.Instance.Show(VacuumStatus, ToastType.Success, scope: "maintenance"); } public void ReportSearchIndexRebuildComplete() { IsRepairing = false; - ToastService.Instance.Show("Search index rebuilt successfully.", ToastType.Success, scope: "maintenance"); + ToastService.Instance.Show(L("Settings_Maintenance_Status_SearchIndexComplete"), ToastType.Success, scope: "maintenance"); } public void ReportSearchIndexRebuildFailed(string reason) { IsRepairing = false; Log.Error("[Settings] Search index rebuild failed: {Reason}", reason); - ToastService.Instance.Show("Search index rebuild failed.", ToastType.Error, scope: "maintenance"); + ToastService.Instance.Show(L("Settings_Maintenance_Status_SearchIndexFailed"), ToastType.Error, scope: "maintenance"); } public void ReportVerifyLinksComplete(int checkedCount, int mismatchCount) { IsRepairing = false; - VerifyLinksStatus = mismatchCount == 0 ? $"✓ Checked {checkedCount} linked track(s) — no mismatches found." : $"⚠ Checked {checkedCount} track(s) — found {mismatchCount} possible mis-link(s). See logs for details."; + VerifyLinksStatus = mismatchCount == 0 + ? string.Format(L("Settings_Maintenance_Status_VerifyLinksClean"), checkedCount) + : string.Format(L("Settings_Maintenance_Status_VerifyLinksMismatch"), checkedCount, mismatchCount); ToastService.Instance.Show(VerifyLinksStatus, mismatchCount > 0 ? ToastType.Warning : ToastType.Success, scope: "maintenance"); } public void ReportForceCleanComplete(int cleaned) { IsRepairing = false; - ForceCleanStatus = cleaned == 0 ? "No titles needed cleaning." : $"✓ Cleaned {cleaned} track title(s)/artist(s). Spot-check multi-dash titles for accuracy."; + ForceCleanStatus = cleaned == 0 + ? L("Settings_Maintenance_Status_NoTitlesToClean") + : string.Format(L("Settings_Maintenance_Status_TitlesCleaned"), cleaned); ToastService.Instance.Show(ForceCleanStatus, ToastType.Success, scope: "maintenance"); } public void ReportArtistMergeComplete(int groupsFound, int tracksUpdated) { - var message = groupsFound == 0 ? "No duplicate artist names found." : $"Merged {groupsFound} artist group(s), updated {tracksUpdated} track(s)."; + var message = groupsFound == 0 + ? L("Settings_Maintenance_Status_NoDuplicateArtists") + : string.Format(L("Settings_Maintenance_Status_ArtistsMerged"), groupsFound, tracksUpdated); ToastService.Instance.Show(message, ToastType.Info, scope: "maintenance"); } public void ReportSyncFilesComplete(FileSyncReport r, bool wasDryRun) { IsRepairing = false; - SyncFilesStatus = wasDryRun ? $"Preview: {r.Retagged} file(s) would be retagged, {r.Renamed} renamed (of {r.Scanned} scanned)." : $"✓ Synced {r.Scanned} file(s): {r.Retagged} retagged, {r.Renamed} renamed, {r.Failed} failed."; + SyncFilesStatus = wasDryRun + ? string.Format(L("Settings_Maintenance_Status_SyncPreview"), r.Retagged, r.Renamed, r.Scanned) + : string.Format(L("Settings_Maintenance_Status_SyncComplete"), r.Scanned, r.Retagged, r.Renamed, r.Failed); ToastService.Instance.Show(SyncFilesStatus, r.Failed > 0 ? ToastType.Warning : ToastType.Success, scope: "maintenance"); } public void ReportThumbnailsCleared(int count) { - ThumbnailStatus = $"Cleared {count} thumbnails - re-fetching in background..."; + ThumbnailStatus = string.Format(L("Settings_Maintenance_Status_ThumbnailsCleared"), count); ToastService.Instance.Show(ThumbnailStatus, ToastType.Success, scope: "maintenance"); } public void ReportBackfillDurationsComplete(int updated) { - ToastService.Instance.Show($"Updated duration for {updated} track(s).", ToastType.Success, scope: "maintenance"); + ToastService.Instance.Show(string.Format(L("Settings_Maintenance_Status_DurationsUpdated"), updated), ToastType.Success, scope: "maintenance"); } - public void ReportRepairPathsComplete(int total, int missing, int cleared) + public void ReportRepairPathsComplete(int total, int missing, int cleared, int relinked) { IsRepairing = false; - RepairStatus = missing == 0 ? $"✓ All {total} file paths are valid." : $"Found {missing} missing file(s) - {cleared} path(s) cleared."; + if (missing == 0) + { + RepairStatus = string.Format(L("Settings_Maintenance_Status_PathsValid"), total); + } + else + { + RepairStatus = string.Format(L("Settings_Maintenance_Status_PathsMissing"), missing, cleared, relinked); + } ToastService.Instance.Show(RepairStatus, missing == 0 ? ToastType.Success : ToastType.Warning, scope: "maintenance"); } public void ReportReimportComplete(int relinked) { IsRepairing = false; - RepairStatus = relinked == 0 ? "No new file matches found." : $"✓ Re-linked {relinked} track(s)."; + RepairStatus = relinked == 0 + ? L("Settings_Maintenance_Status_NoReimportMatches") + : string.Format(L("Settings_Maintenance_Status_ReimportComplete"), relinked); ToastService.Instance.Show(RepairStatus, relinked > 0 ? ToastType.Success : ToastType.Info, scope: "maintenance"); } public void ReportMetaResyncComplete(int cleared) { IsRepairing = false; - RepairStatus = $"✓ Cleared tags for {cleared} track(s) - re-sync running."; + RepairStatus = string.Format(L("Settings_Maintenance_Status_ResyncComplete"), cleared); ToastService.Instance.Show(RepairStatus, ToastType.Success, scope: "maintenance"); } public void ReportRepairFailed(string operation, string reason) { IsRepairing = false; - RepairStatus = $"✗ {operation} failed: {reason}"; + RepairStatus = string.Format(L("Settings_Maintenance_Status_OperationFailed"), operation, reason); ToastService.Instance.Show(RepairStatus, ToastType.Error, scope: "maintenance"); } public void ReportBackupComplete(string fileName) { IsRepairing = false; - ToastService.Instance.Show($"✓ Database backed up successfully to {fileName}.", ToastType.Success, scope: "maintenance"); + ToastService.Instance.Show(string.Format(L("Settings_Maintenance_Status_BackupComplete"), fileName), ToastType.Success, scope: "maintenance"); } public void ReportBackupFailed(string reason) { IsRepairing = false; - ToastService.Instance.Show($"✗ Backup failed: {reason}", ToastType.Error, scope: "maintenance"); + ToastService.Instance.Show(string.Format(L("Settings_Maintenance_Status_BackupFailed"), reason), ToastType.Error, scope: "maintenance"); } #endregion } \ No newline at end of file diff --git a/ViewModels/Settings/SettingsViewModel.Preferences.cs b/ViewModels/Settings/SettingsViewModel.Preferences.cs index 9570652..783a7d8 100644 --- a/ViewModels/Settings/SettingsViewModel.Preferences.cs +++ b/ViewModels/Settings/SettingsViewModel.Preferences.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; @@ -27,6 +28,8 @@ namespace NullWave.ViewModels; +public sealed record TagWriteModeOption(TagWriteMode Value, string DisplayName); + public partial class SettingsViewModel { // General & Downloads @@ -113,6 +116,58 @@ public string EffectsTier } } + public EffectsTier CurrentEffectsTier => EffectsTierResolver.Shared.Current; + + public string EffectsTierLabel + { + get + { + var gpuName = HardwareDetector.CachedOrNull()?.GpuName ?? "Unknown"; + return CurrentEffectsTier == NullWave.Models.EffectsTier.Minimal + ? $"Minimal (on battery or low-end hardware: {gpuName})" + : $"Standard ({gpuName})"; + } + } + + public bool CanForceStandardTier => CurrentEffectsTier == NullWave.Models.EffectsTier.Minimal; + + [RelayCommand] + private void ForceStandardEffectsTier() + { + _prefsService.Update(p => + { + p.AutoEffectsTier = false; + p.EffectsTier = "Standard"; + }); + + OnPropertyChanged(nameof(AutoEffectsTier)); + OnPropertyChanged(nameof(EffectsTier)); + EffectsTierResolver.Shared.NotifyPreferencesChanged(); + OnPropertyChanged(nameof(CurrentEffectsTier)); + OnPropertyChanged(nameof(EffectsTierLabel)); + OnPropertyChanged(nameof(CanForceStandardTier)); + ToastService.Instance.Show("Effects tier set to Standard", ToastType.Success); + } + + private void OnEffectsTierResolverPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(EffectsTierResolver.Current)) + return; + + void Notify() + { + OnPropertyChanged(nameof(CurrentEffectsTier)); + OnPropertyChanged(nameof(EffectsTierLabel)); + OnPropertyChanged(nameof(CanForceStandardTier)); + + } + + if (Dispatcher.UIThread.CheckAccess()) + Notify(); + else + Dispatcher.UIThread.Post(Notify); + } + public string ActivePresetId { get @@ -336,11 +391,24 @@ public bool EnableExperimentalFeatures _prefsService.Update(p => p.EnableExperimentalFeatures = value); OnPropertyChanged(); OnPropertyChanged(nameof(IsExperimentalModeActive)); + OnPropertyChanged(nameof(ShowAdvancedFeatures)); } } + public bool ForceAllFeatures + { + get => _prefsService.Current.ForceAllFeatures; + set + { + _prefsService.Update(p => p.ForceAllFeatures = value); + OnPropertyChanged(); + OnPropertyChanged(nameof(ShowAdvancedFeatures)); + } + } + /// Returns true if Experimental Features OR Dev Mode is active. public bool IsExperimentalModeActive => EnableExperimentalFeatures || IsDevMode; + public bool ShowAdvancedFeatures => ForceAllFeatures || IsExperimentalModeActive; // Localized Formatters @@ -349,5 +417,23 @@ public bool EnableExperimentalFeatures public string CrossfadeDurationDisplay => $"{CrossfadeDurationSeconds} s"; public string ScrobbleThresholdDisplay => string.Format(L("Settings_Dynamic_Scrobble_Threshold"), ScrobbleThreshold); public bool AIFeaturesControlsEnabled => AIFeaturesEnabled; + + public IReadOnlyList TagWriteModeOptions => new[] + { + new TagWriteModeOption(TagWriteMode.Never, L("Settings_Downloads_TagWriteMode_Never")), + new TagWriteModeOption(TagWriteMode.DownloadsOnly, L("Settings_Downloads_TagWriteMode_DownloadsOnly")), + new TagWriteModeOption(TagWriteMode.Always, L("Settings_Downloads_TagWriteMode_Always")) + }; + + public TagWriteMode TagWriteMode + { + get => _prefsService.Current.TagWriteMode; + set + { + _prefsService.Update(p => p.TagWriteMode = value); + OnPropertyChanged(); + Log.Information("[Settings] TagWriteMode changed to {Mode}", value); + } + } } \ No newline at end of file diff --git a/ViewModels/Settings/SettingsViewModel.Updates.cs b/ViewModels/Settings/SettingsViewModel.Updates.cs index 116452f..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; @@ -54,8 +55,9 @@ public partial class SettingsViewModel [ObservableProperty] private string _updateStatus = "Not checked yet"; [ObservableProperty] private bool _isStagingUpdate; [ObservableProperty] private bool _updateStaged; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsDepsChecking))] private bool _isCheckingDeps; - public bool IsDepsChecking => YtDlpStatus == "Checking..." || VlcStatus == "Checking..."; + public bool IsDepsChecking => IsCheckingDeps || YtDlpStatus == "Checking..." || VlcStatus == "Checking..."; public bool IsYtDlpReady => !string.IsNullOrEmpty(YtDlpStatus) && YtDlpStatus != "Not installed" && YtDlpStatus != "Checking..."; public bool IsVlcReady => !string.IsNullOrEmpty(VlcStatus) && VlcStatus != "Not installed" && VlcStatus != "Checking..."; public bool IsDepsReady => IsYtDlpReady && IsVlcReady; @@ -73,7 +75,14 @@ [RelayCommand] private async Task CheckForUpdateAsync() UpdateAvailable = result.IsUpdateAvailable; LatestVersion = result.LatestVersion; ReleaseUrl = result.ReleaseUrl; - UpdateStatus = result.IsUpdateAvailable ? $"Update available: v{result.LatestVersion}" : $"You are up to date (v{result.CurrentVersion})"; + + // FIX: Use centralized status text logic + UpdateStatus = UpdateVersioning.StatusText(result); + } + catch (Exception ex) + { + Log.Error(ex, "[Settings] Update check failed"); + UpdateStatus = "Couldn't check for updates"; } finally { IsCheckingUpdate = false; } } @@ -83,7 +92,6 @@ [RelayCommand] private async Task DownloadUpdateAsync() IsStagingUpdate = true; try { - // Velopack handles RID matching automatically UpdateStaged = await _updater.StageUpdateAsync(); ToastService.Instance.Show( UpdateStaged ? "Update downloaded — restart to install." : "No update available.", @@ -113,9 +121,21 @@ [RelayCommand] private async Task UpdateYtDlpAsync() YtDlpStatus = "Updating yt-dlp..."; try { - await _deps.UpdateYtDlpAsync(); + var msg = await _deps.UpdateYtDlpAsync(); var info = await _deps.GetYtDlpInfoAsync(); - YtDlpStatus = info.IsInstalled ? $"yt-dlp {info.InstalledVersion} (up to date)" : "yt-dlp not found"; + + // FIX: Honest status based on update result and version comparison + YtDlpStatus = !info.IsInstalled ? "Not installed" + : msg.StartsWith("Update failed") ? $"{info.InstalledVersion} (update failed)" + : msg == "Already up to date" ? $"{info.InstalledVersion} (up to date)" + : info.LatestVersion != "unknown" && info.InstalledVersion != info.LatestVersion + ? $"{info.InstalledVersion} → {info.LatestVersion}" + : $"{info.InstalledVersion} (up to date)"; + } + catch (Exception ex) + { + Log.Error(ex, "[Settings] yt-dlp update failed"); + YtDlpStatus = "Update failed"; } finally { IsUpdatingYtDlp = false; } } @@ -123,27 +143,78 @@ [RelayCommand] private async Task UpdateYtDlpAsync() [RelayCommand] private async Task InstallVlcAsync() { VlcStatus = "Installing..."; - VlcStatus = await _deps.InstallVlcAsync(); + try + { + var status = await _deps.InstallVlcAsync(ConfirmPackageInstallAsync); + VlcStatus = status == DependencyUpdateService.InstallCancelledStatus + ? LocalizationService.Instance["Settings_Updates_InstallDeclined"] + : status; + } + catch (Exception ex) + { + Log.Error(ex, "[Settings] VLC install failed"); + VlcStatus = "Install failed"; + } + } + + 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 + if (IsCheckingDeps) return; + + IsCheckingDeps = true; YtDlpStatus = VlcStatus = FfmpegStatus = DotNetStatus = "Checking..."; - var ytDlp = await _deps.GetYtDlpInfoAsync(); - var vlc = await _deps.GetVlcInfoAsync(); - var ffmpeg = await _deps.GetFfmpegInfoAsync(); - var dotNet = await _deps.GetDotNetInfoAsync(); - YtDlpStatus = ytDlp.IsInstalled ? ytDlp.InstalledVersion : "Not installed"; - VlcStatus = vlc.IsInstalled ? vlc.InstalledVersion : "Not installed"; - FfmpegStatus = ffmpeg.IsInstalled ? ffmpeg.InstalledVersion : "Not installed"; - DotNetStatus = dotNet.IsInstalled ? dotNet.InstalledVersion : "Not found"; + + try + { + // FIX: Run all probes in parallel for faster UI response + var ytDlpTask = _deps.GetYtDlpInfoAsync(); + var vlcTask = _deps.GetVlcInfoAsync(); + var ffmpegTask = _deps.GetFfmpegInfoAsync(); + var dotNetTask = _deps.GetDotNetInfoAsync(); + + await Task.WhenAll(ytDlpTask, vlcTask, ffmpegTask, dotNetTask); + + var ytDlp = ytDlpTask.Result; + var vlc = vlcTask.Result; + var ffmpeg = ffmpegTask.Result; + var dotNet = dotNetTask.Result; + + YtDlpStatus = ytDlp.IsInstalled ? ytDlp.InstalledVersion : "Not installed"; + VlcStatus = vlc.IsInstalled ? vlc.InstalledVersion : "Not installed"; + FfmpegStatus = ffmpeg.IsInstalled ? ffmpeg.InstalledVersion : "Not installed"; + DotNetStatus = dotNet.IsInstalled ? dotNet.InstalledVersion : "Not found"; + } + catch (Exception ex) + { + Log.Error(ex, "[Settings] Dependency check failed"); + YtDlpStatus = VlcStatus = FfmpegStatus = DotNetStatus = "Check failed"; + } + finally + { + IsCheckingDeps = false; + } } [RelayCommand] private void ClearYtDlpCache() => ClearYtDlpCacheRequested?.Invoke(); [RelayCommand] private async Task CopyDebugInfoAsync() { - var text = $"NullWave v{CurrentVersion} “{VersionCodename}”\n" + + var text = $"NullWave v{CurrentVersion} \"{VersionCodename}\"\n" + $"OS: {OsLabel} ({RuntimeInformation.OSDescription})\n" + $"RID: {RuntimeInformation.RuntimeIdentifier}\n" + $".NET: {Environment.Version}"; diff --git a/ViewModels/Settings/SettingsViewModel.cs b/ViewModels/Settings/SettingsViewModel.cs index 038ae52..dc217a8 100644 --- a/ViewModels/Settings/SettingsViewModel.cs +++ b/ViewModels/Settings/SettingsViewModel.cs @@ -12,11 +12,13 @@ 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; using NullWave.Helpers.Logging; using NullWave.Models; +using NullWave.Plugins.Contracts; using NullWave.Services; using NullWave.Services.Plugins; using NullWave.Services.Security; @@ -42,10 +44,12 @@ public partial class SettingsViewModel : ObservableObject, IDisposable private readonly LocalAIService _localAI; private readonly PluginManager _plugins; private System.Threading.Timer? _aiHealthTimer; + public bool IntegrationsNeedsAttention => PluginRows.Any(r => r.NeedsAttention); + public bool ApiKeysNeedsAttention => PluginRows.Any(r => r.NeedsAttention && r.SetupPage == "ApiKeys"); #endregion #region Constructor - public SettingsViewModel(KeyStoreService keyStore, SecureDeleteService secureDelete, PreferencesService prefsService, LocalAIService localAI, PluginManager plugins) + public SettingsViewModel(KeyStoreService keyStore, SecureDeleteService secureDelete, PreferencesService prefsService, LocalAIService localAI, PluginManager plugins, ProviderRegistry providerRegistry) { IsDevMode = CheckDevAccess(); _keyStore = keyStore; @@ -55,10 +59,15 @@ public SettingsViewModel(KeyStoreService keyStore, SecureDeleteService secureDel _plugins = plugins; _updater = new UpdateService(); _deps = new DependencyUpdateService(); + EffectsTierResolver.Shared.PropertyChanged += OnEffectsTierResolverPropertyChanged; + + // Initialize Sources tab + Sources = new SourcesTabViewModel(providerRegistry, _prefsService); _isSettingsSidebarCollapsed = prefsService.Current.SettingsSidebarCollapsed; _selectedLanguage = prefsService.Current.Language; + // API Keys Init _youtubeApiKey = _keyStore.GetKey("YouTube") ?? string.Empty; _spotifyClientId = _keyStore.GetKey("Spotify:ClientId") ?? string.Empty; @@ -93,6 +102,15 @@ public SettingsViewModel(KeyStoreService keyStore, SecureDeleteService secureDel } })); } + foreach (var row in PluginRows) + { + row.PropertyChanged += (_, e) => + { + OnPropertyChanged(nameof(IntegrationsNeedsAttention)); + OnPropertyChanged(nameof(ApiKeysNeedsAttention)); + }; + } + Hub = new IntegrationsHubViewModel(PluginRows, new StaticPluginCatalog()); LogViewer = new LogViewerViewModel(_prefsService.Current.VerboseLogging, BuildSettingsSummary); } #endregion @@ -123,6 +141,8 @@ private static void OpenFolder(string path) #region UI State & Navigation public ObservableCollection ActiveToasts => ToastService.Instance.ActiveToasts; public ObservableCollection PluginRows { get; private set; } = new(); + public SourcesTabViewModel Sources { get; } + public IntegrationsHubViewModel Hub { get; } private bool _isSettingsSidebarCollapsed; public bool IsSettingsSidebarCollapsed @@ -137,18 +157,36 @@ public bool IsSettingsSidebarCollapsed OnPropertyChanged(nameof(SettingsSidebarWidth)); OnPropertyChanged(nameof(SettingsSidebarCollapseIconKind)); _prefsService.Update(p => p.SettingsSidebarCollapsed = value); - + } } public bool IsSettingsSidebarExpanded => !IsSettingsSidebarCollapsed; - public double SettingsSidebarWidth => IsSettingsSidebarCollapsed ? 69 : 220; + public double SettingsSidebarWidth => IsSettingsSidebarCollapsed ? 64 : 220; public Material.Icons.MaterialIconKind SettingsSidebarCollapseIconKind => IsSettingsSidebarCollapsed ? Material.Icons.MaterialIconKind.ChevronRight : Material.Icons.MaterialIconKind.ChevronLeft; + public Material.Icons.MaterialIconKind CurrentSettingsPageIcon => CurrentSettingsPage switch + { + "General" => Material.Icons.MaterialIconKind.TuneVariant, + "Appearance" => Material.Icons.MaterialIconKind.Palette, + "Audio" => Material.Icons.MaterialIconKind.MusicNote, + "Downloads" => Material.Icons.MaterialIconKind.Download, + "Integrations" => Material.Icons.MaterialIconKind.Puzzle, + "ApiKeys" => Material.Icons.MaterialIconKind.Key, + "SmartFeatures" => Material.Icons.MaterialIconKind.AutoFixHigh, + "Maintenance" => Material.Icons.MaterialIconKind.Tools, + "Updates" => Material.Icons.MaterialIconKind.Update, + "About" => Material.Icons.MaterialIconKind.InformationOutline, + "Help" => Material.Icons.MaterialIconKind.HelpCircle, + "DevTools" => Material.Icons.MaterialIconKind.CodeTags, + _ => Material.Icons.MaterialIconKind.Cog + }; + [ObservableProperty] private int _currentSectionIndex = 0; [ObservableProperty] [NotifyPropertyChangedFor(nameof(CurrentSettingsPageTitle))] [NotifyPropertyChangedFor(nameof(CurrentSettingsPageDescription))] + [NotifyPropertyChangedFor(nameof(CurrentSettingsPageIcon))] private string _currentSettingsPage = "General"; public string CurrentSettingsPageTitle => LocalizationService.Instance[$"Settings_PageTitle_{CurrentSettingsPage}"]; @@ -165,6 +203,7 @@ partial void OnSelectedLanguageChanged(string value) { LocalizationService.Instance.SetLanguage(value); OnPropertyChanged(nameof(GalleryItems)); + OnPropertyChanged(nameof(TagWriteModeOptions)); OnPropertyChanged(nameof(IsLanguageApplyVisible)); } @@ -231,7 +270,10 @@ partial void OnSelectedLanguageChanged(string value) [RelayCommand] private async Task BrowseDownloadDirAsync() { if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || desktop.MainWindow == null) return; - var folders = await desktop.MainWindow.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Select Download Directory" }); + var folders = await desktop.MainWindow.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = LocalizationService.Instance["Settings_Downloads_Picker_Title"] + }); if (folders.Count > 0) DownloadDirectory = folders[0].Path.LocalPath; } @@ -264,9 +306,9 @@ [RelayCommand] private async Task BackupDatabaseAsync() if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || desktop.MainWindow == null) return; var file = await desktop.MainWindow.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { - Title = "Backup Library Database", + Title = LocalizationService.Instance["Settings_Maintenance_Backup_Picker_Title"], SuggestedFileName = $"nullwave_backup_{DateTime.UtcNow:yyyyMMdd_HHmm}.db", - FileTypeChoices = new[] { new FilePickerFileType("SQLite Database") { Patterns = new[] { "*.db" } } } + FileTypeChoices = new[] { new FilePickerFileType(LocalizationService.Instance["Settings_Maintenance_Backup_FileType"]) { Patterns = new[] { "*.db" } } } }); if (file != null) { @@ -285,8 +327,12 @@ [RelayCommand] private async Task BackupDatabaseAsync() #region IDisposable public void Dispose() { + EffectsTierResolver.Shared.PropertyChanged -= OnEffectsTierResolverPropertyChanged; StopHealthCheck(); LogViewer?.Dispose(); + Sources?.Dispose(); + foreach (var pluginRow in PluginRows) + pluginRow.Dispose(); GC.SuppressFinalize(this); } #endregion diff --git a/ViewModels/Settings/SourcesTabViewModel.cs b/ViewModels/Settings/SourcesTabViewModel.cs new file mode 100644 index 0000000..b7129f4 --- /dev/null +++ b/ViewModels/Settings/SourcesTabViewModel.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using NullWave.Services.Plugins; +using NullWave.Services; +using NullWave.Helpers; +using NullWave.Models; +using Serilog; +using NullWave.Plugins.Contracts; + +namespace NullWave.ViewModels.Settings; + +public partial class SourceRowViewModel : ObservableObject +{ + public IProvider Provider { get; } + public ProviderDescriptor Descriptor => Provider.Descriptor; + + [ObservableProperty] private bool _isActive; + [ObservableProperty] private bool _isEnabled; + [ObservableProperty] private string _statusLabel = string.Empty; + + public string Id => Descriptor.Id; + public string DisplayName => Descriptor.Id switch + { + "yt-dlp" => Descriptor.DisplayName.Replace( + "yt-dlp", + LocalizationService.Instance["Settings_Integrations_Provider_YtDlp"], + StringComparison.Ordinal), + "lastfm" => LocalizationService.Instance["Settings_Integrations_Provider_LastFm"], + "openweather" => LocalizationService.Instance["Settings_Integrations_Provider_OpenWeather"], + "ollama" => LocalizationService.Instance["Settings_Integrations_Provider_Ollama"], + _ => Descriptor.DisplayName + }; + public ProviderKind Kind => Descriptor.Kind; + + public SourceRowViewModel(IProvider provider) + { + Provider = provider; + _isEnabled = provider.IsEnabled; + _isActive = false; + UpdateStatusLabel(); + } + + public void UpdateStatusLabel() + { + StatusLabel = Provider.State switch + { + PluginState.Available => $"✓ {LocalizationService.Instance["Settings_Integrations_Status_Available"]}", + PluginState.Loading => $"⟳ {LocalizationService.Instance["Settings_Integrations_Status_Loading"]}", + PluginState.Error => $"✗ {LocalizationService.Instance["Settings_Integrations_Status_Error"]}", + PluginState.Unavailable => $"○ {LocalizationService.Instance["Settings_Integrations_Status_Unavailable"]}", + PluginState.Disabled => $"⊘ {LocalizationService.Instance["Settings_Integrations_Status_Disabled"]}", + _ => LocalizationService.Instance["Settings_Integrations_Status_Unknown"] + }; + OnPropertyChanged(nameof(StatusLabel)); + } + + public void RefreshLocalization() + { + OnPropertyChanged(nameof(DisplayName)); + UpdateStatusLabel(); + } +} + +public partial class SourceKindGroupViewModel : ObservableObject +{ + public ProviderKind Kind { get; } + private readonly string _nameKey; + private readonly string _descriptionKey; + public string DisplayName => LocalizationService.Instance[_nameKey]; + public string Description => LocalizationService.Instance[_descriptionKey]; + public bool IsSingleActive { get; } + + public ObservableCollection Providers { get; } = new(); + + [ObservableProperty] private SourceRowViewModel? _selectedProvider; + [ObservableProperty] private string _pairingMessage = string.Empty; + private string? _pairedProviderId; + + public SourceKindGroupViewModel(ProviderKind kind, string nameKey, string descriptionKey, bool isSingleActive) + { + Kind = kind; + _nameKey = nameKey; + _descriptionKey = descriptionKey; + IsSingleActive = isSingleActive; + } + + public void RefreshLocalization() + { + OnPropertyChanged(nameof(DisplayName)); + OnPropertyChanged(nameof(Description)); + PairingMessage = _pairedProviderId is null + ? string.Empty + : string.Format(LocalizationService.Instance["Settings_Integrations_PairedWith"], _pairedProviderId); + foreach (var provider in Providers) + provider.RefreshLocalization(); + } + + public void SetPairedProvider(string? providerId) + { + _pairedProviderId = providerId; + RefreshLocalization(); + } + + public Action? SelectionChanged { get; set; } + + partial void OnSelectedProviderChanged(SourceRowViewModel? value) + { + if (value != null && SelectionChanged != null) + { + SelectionChanged.Invoke(value); + } + } +} + +public partial class SourcesTabViewModel : ObservableObject, IDisposable +{ + private readonly ProviderRegistry _registry; + private readonly PreferencesService _prefs; + private bool _building; + + public ObservableCollection KindGroups { get; } = new(); + + // --- Consent Gate Properties --- + public string ConsentStatus => _prefs.Current.DownloaderConsent is { IsCurrent: true } c + ? string.Format(LocalizationService.Instance["Settings_Downloads_Consent_Accepted"], c.AcceptedAtUtc.ToLocalTime().ToString("d"), c.AcceptedVersion) + : LocalizationService.Instance["Settings_Downloads_Consent_NotAccepted"]; + + public bool IsConsentAccepted => _prefs.Current.DownloaderConsent is { IsCurrent: true }; + + [RelayCommand] + private void RevokeConsent() + { + _prefs.Update(p => p.DownloaderConsent = null); + OnPropertyChanged(nameof(ConsentStatus)); + OnPropertyChanged(nameof(IsConsentAccepted)); + ToastService.Instance.Show(LocalizationService.Instance["Settings_Downloads_Consent_Revoked"], type: ToastType.Info, scope: "download"); + } + + public SourcesTabViewModel(ProviderRegistry registry, PreferencesService prefs) + { + _registry = registry; + _prefs = prefs; + + // FIX: Refresh UI immediately when consent is accepted/revoked elsewhere (e.g. via Dialog) + _prefs.Changed += OnPrefsChanged; + LocalizationService.Instance.PropertyChanged += OnLocalizationChanged; + + BuildGroups(); + _registry.ProvidersChanged += OnProvidersChanged; + } + + private void OnPrefsChanged(HashSet changedKeys) + { + OnPropertyChanged(nameof(ConsentStatus)); + OnPropertyChanged(nameof(IsConsentAccepted)); + } + + private void OnLocalizationChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + OnPropertyChanged(nameof(ConsentStatus)); + foreach (var group in KindGroups) + group.RefreshLocalization(); + } + + public void Dispose() + { + _prefs.Changed -= OnPrefsChanged; + _registry.ProvidersChanged -= OnProvidersChanged; + LocalizationService.Instance.PropertyChanged -= OnLocalizationChanged; + } + + private void OnProvidersChanged() + { + Dispatcher.UIThread.Post(BuildGroups); + } + + private void BuildGroups() + { + _building = true; + try + { + KindGroups.Clear(); + + var kinds = new (ProviderKind Kind, string NameKey, string DescKey, bool Single)[] + { + (ProviderKind.Download, "Settings_Integrations_Group_Download", "Settings_Integrations_Group_Download_Desc", true), + (ProviderKind.Metadata, "Settings_Integrations_Group_Metadata", "Settings_Integrations_Group_Metadata_Desc", true), + (ProviderKind.Search, "Settings_Integrations_Group_Search", "Settings_Integrations_Group_Search_Desc", false), + (ProviderKind.Mood, "Settings_Integrations_Group_Mood", "Settings_Integrations_Group_Mood_Desc", true), + (ProviderKind.Weather, "Settings_Integrations_Group_Weather", "Settings_Integrations_Group_Weather_Desc", true), + (ProviderKind.Radio, "Settings_Integrations_Group_Radio", "Settings_Integrations_Group_Radio_Desc", false), + (ProviderKind.PlaylistImport, "Settings_Integrations_Group_PlaylistImport", "Settings_Integrations_Group_PlaylistImport_Desc", false), + }; + + foreach (var (kind, nameKey, descKey, single) in kinds) + { + var providers = _registry.List(kind); + if (providers.Count == 0) continue; + + var group = new SourceKindGroupViewModel(kind, nameKey, descKey, single); + + foreach (var provider in providers) + { + var row = new SourceRowViewModel(provider); + var active = _registry.GetActive(kind); + row.IsActive = active?.Descriptor.Id == provider.Descriptor.Id; + group.Providers.Add(row); + } + + if (single && group.Providers.Count > 0) + { + group.SelectedProvider = group.Providers.FirstOrDefault(p => p.IsActive) ?? group.Providers[0]; + } + + var activeProvider = _registry.GetActive(kind); + if (activeProvider?.Descriptor.PairedWithProviderId != null) + { + group.SetPairedProvider(activeProvider.Descriptor.PairedWithProviderId); + } + + group.SelectionChanged = row => + { + if (!_building) + { + _registry.SetActive(row.Kind, row.Id); + Log.Information("[Sources] Activated provider {Id} for kind {Kind}", row.Id, row.Kind); + } + }; + + KindGroups.Add(group); + } + } + finally + { + _building = false; + } + OnPropertyChanged(nameof(RoutableGroups)); + OnPropertyChanged(nameof(HasRoutableGroups)); + } + + public IEnumerable RoutableGroups => + KindGroups.Where(g => g.Providers.Count > 1).ToList(); + + public bool HasRoutableGroups => + KindGroups.Any(g => g.Providers.Count > 1); + + [RelayCommand] + private void SelectProvider(SourceRowViewModel? row) + { + if (row == null) return; + _registry.SetActive(row.Kind, row.Id); + Log.Information("[Sources] Activated provider {Id} for kind {Kind}", row.Id, row.Kind); + BuildGroups(); + } + + [RelayCommand] + private void ToggleProvider(SourceRowViewModel? row) + { + if (row == null) return; + Log.Information("[Sources] Toggled provider {Id} for kind {Kind}", row.Id, row.Kind); + BuildGroups(); + } +} \ No newline at end of file diff --git a/ViewModels/Shell/MainViewModel.Integration.cs b/ViewModels/Shell/MainViewModel.Integration.cs index 85eccf1..80eee95 100644 --- a/ViewModels/Shell/MainViewModel.Integration.cs +++ b/ViewModels/Shell/MainViewModel.Integration.cs @@ -27,7 +27,7 @@ private void InitializeServices() { _identity = new IdentityService(_keyStore); _prefsService = PreferencesService.Shared; - _effectsTier = new EffectsTierResolver(_prefsService); + _effectsTier = EffectsTierResolver.Shared; _effectsTier.PropertyChanged += OnEffectsTierPropertyChanged; WallpaperService.Instance.MinimalTier = CurrentEffectsTier == EffectsTier.Minimal; @@ -62,7 +62,10 @@ private void InitializeServices() _plugins.Register(new OpenWeatherProvider(_weatherService, _prefsService)); _plugins.Register(new OllamaAIProvider(_localAI, _prefsService)); - Settings = new SettingsViewModel(_keyStore, _secureDelete, _prefsService, _localAI, _plugins); + // Provider registry (M3 foundation) - ASSIGN TO FIELD, not local variable + _providerRegistry = new ProviderRegistry(_prefsService); + + Settings = new SettingsViewModel(_keyStore, _secureDelete, _prefsService, _localAI, _plugins, _providerRegistry); Settings.ClearYtDlpCacheRequested += OnClearYtDlpCacheRequested; Settings.DevEffectsTierOverrideChanged += tier => _effectsTier.SetDevOverride(tier); Settings.EffectsTierPreferencesChanged += () => @@ -72,6 +75,8 @@ private void InitializeServices() }; } + private readonly HashSet _activePlaylistImports = new(); + // --- Extracted Singleton Event Handlers --- private void OnLibraryChanged(object? sender, EventArgs e) { @@ -133,7 +138,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(); @@ -147,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); @@ -156,7 +164,7 @@ private void InitializeChildViewModels() Queue = new QueueViewModel(_library); Nav = new NavigationViewModel( - _prefsService, _playlists, + _prefsService, _playlists, smartPlaylistService, () => NavigateLibraryCommand, () => NavigatePlaylistsCommand, () => NavigateRadioCommand, @@ -167,6 +175,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 +203,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() @@ -280,18 +298,60 @@ private void WireCoreEvents() private void WireIntegrationEvents() { - Input.PlaylistImportRequested += (playlistUrl) => + Input.PlaylistImportRequested += async (playlistUrl) => { - if (_plugins.Get() is not { } ytDlpProvider || !ytDlpProvider.SupportsUrl(playlistUrl)) + // Normalize URL for comparison (strip tracking params) + var normalizedUrl = NormalizePlaylistUrl(playlistUrl); + + // Guard: prevent double imports + lock (_activePlaylistImports) { - ToastService.Instance.Show("Downloads are disabled. Enable yt-dlp in Settings.", ToastType.Warning); - return; + if (_activePlaylistImports.Contains(normalizedUrl)) + { + Log.Information("[MainViewModel] Playlist import already in progress: {Url}", normalizedUrl); + ToastService.Instance.Show("This playlist is already being imported.", ToastType.Info, scope: "import"); + return; + } + _activePlaylistImports.Add(normalizedUrl); } - _ = _downloadService.DownloadPlaylistAsync(playlistUrl, onTrackReady: (downloadedTrack) => + + // NEW: Detect YouTube auto-generated mixes (RD...) and cap them + if (playlistUrl.Contains("list=RD", StringComparison.OrdinalIgnoreCase)) { - if (_plugins.Get() is { }) _enrichment.EnrichAsync(downloadedTrack); - Dispatcher.UIThread.Post(() => Library.Refresh()); - }); + var confirmDialog = new Views.ConfirmDialog( + "Large Auto-Generated Mix", + "This is a YouTube auto-generated mix that may contain 1000+ tracks. Importing it will take a long time and may hit rate limits.\n\nContinue anyway?"); + + var confirmed = await DialogService.Instance.ShowDialogAsync(confirmDialog); + if (!confirmed) + { + lock (_activePlaylistImports) { _activePlaylistImports.Remove(normalizedUrl); } + return; + } + } + + try + { + if (_plugins.Get() is not { } ytDlpProvider || !ytDlpProvider.SupportsUrl(playlistUrl)) + { + ToastService.Instance.Show("Downloads are disabled. Enable yt-dlp in Settings.", ToastType.Warning); + return; + } + + await _downloadService.DownloadPlaylistAsync(playlistUrl, onTrackReady: (downloadedTrack) => + { + if (_plugins.Get() is { }) _enrichment.EnrichAsync(downloadedTrack); + Dispatcher.UIThread.Post(() => Library.Refresh()); + }); + } + finally + { + // Always remove from active set + lock (_activePlaylistImports) + { + _activePlaylistImports.Remove(normalizedUrl); + } + } }; Input.RadioSiteRequested += async (stationName, channels) => @@ -372,6 +432,7 @@ private void WireIntegrationEvents() _downloadService.PlaylistBatchStarted += OnPlaylistBatchStarted; _downloadService.PlaylistBatchProgress += OnPlaylistBatchProgress; _downloadService.PlaylistBatchCompleted += OnPlaylistBatchCompleted; + _downloadService.PlaylistTrackStarted += OnPlaylistTrackStarted; Settings.LastFmConnectRequested += async () => { @@ -447,6 +508,28 @@ private void WireIntegrationEvents() }; } + private static string NormalizePlaylistUrl(string url) + { + // Strip common tracking parameters for comparison + var uri = new Uri(url); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + + // Remove tracking params + query.Remove("si"); + query.Remove("pp"); + query.Remove("utm_source"); + query.Remove("utm_medium"); + query.Remove("utm_campaign"); + + // Rebuild URL + var builder = new UriBuilder(uri) + { + Query = query.ToString() + }; + + return builder.Uri.ToString(); + } + // --- Extracted Download Event Handlers --- private void OnDownloadCompleted(string trackId, string filePath, bool isInteractive) { @@ -557,7 +640,7 @@ private void OnBackfillCompleted() if (_prefsService.Current.AutoGenerateMoodPlaylist) _ = RunMoodPlaylistAsync(forceRefresh: false); } - private void ApplyBatteryEffectsTier(PowerState state) + private void ApplyBatteryEffectsTier(PowerState state) { if (state == PowerState.Battery && _prefsService.Current.AutoEffectsTier) _effectsTier.SetBatteryOverride(EffectsTier.Minimal); @@ -569,8 +652,8 @@ private async void OnClearYtDlpCacheRequested() { try { - var ytDlpPath = PlatformHelper.ResolveExecutable("yt-dlp"); - var result = await ProcessRunner.RunAsync(ytDlpPath, new[] { "--rm-cache-dir" }, TimeSpan.FromSeconds(30)); + // FIX: Pass raw name, let ProcessRunner handle the .exe resolution + var result = await ProcessRunner.RunAsync("yt-dlp", new[] { "--rm-cache-dir" }, TimeSpan.FromSeconds(30)); if (result.ExitCode == 0) { @@ -668,4 +751,19 @@ private static int StableHash(string s) return (int)(h & 0x7FFFFFFF); } } + + private void OnPlaylistTrackStarted(string title, int current, int total) + { + LiveNotification? activity; + lock (_playlistBatches) + { + var match = _playlistBatches.FirstOrDefault(b => b.Total == total && b.Total > 0); + activity = match != default ? match.Activity : _playlistBatches.LastOrDefault().Activity; + } + if (activity == null) return; + + // Truncate long titles to prevent UI overflow + var displayTitle = title.Length > 50 ? title[..47] + "..." : title; + ToastService.Instance.UpdateLiveActivity(activity, $"Downloading {current}/{total}: {displayTitle}"); + } } diff --git a/ViewModels/Shell/MainViewModel.Maintenance.cs b/ViewModels/Shell/MainViewModel.Maintenance.cs index 88fd14c..5a44b04 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 () => @@ -92,8 +99,13 @@ private void WireMaintenanceEvents() Settings.RepairPathsRequested += () => RunMaintenanceTask("RepairPaths", false, async () => { + // 1. Auto-relink them using the [nw-id-videoId] suffix FIRST! + var relinked = await Task.Run(() => _library.RelinkMissingFiles()); + + // 2. Clear remaining dead paths var (total, missing, removed) = await Task.Run(() => _library.RepairPaths(removeDeadEntries: true)); - return () => { Library.Refresh(); Settings.ReportRepairPathsComplete(total, missing, removed); }; + + return () => { Library.Refresh(); Settings.ReportRepairPathsComplete(total, missing, removed, relinked); }; }); Settings.ReimportAssetsRequested += () => RunMaintenanceTask("ReimportAssets", false, async () => @@ -136,20 +148,20 @@ await Dispatcher.UIThread.InvokeAsync(async () => { var files = await desktop.MainWindow.StorageProvider.OpenFilePickerAsync(new Avalonia.Platform.Storage.FilePickerOpenOptions { - Title = "Select Database Backup (.db)", + Title = LocalizationService.Instance["Settings_Maintenance_Restore_Picker_Title"], AllowMultiple = false, - FileTypeFilter = new[] { new Avalonia.Platform.Storage.FilePickerFileType("SQLite Backup") { Patterns = new[] { "*.db" } } } + FileTypeFilter = new[] { new Avalonia.Platform.Storage.FilePickerFileType(LocalizationService.Instance["Settings_Maintenance_Restore_FileType"]) { Patterns = new[] { "*.db" } } } }); if (files.Count > 0) selectedFile = files[0].Path.LocalPath; } }); - if (string.IsNullOrEmpty(selectedFile)) return () => ToastService.Instance.Show("Restore cancelled.", ToastType.Info); + if (string.IsNullOrEmpty(selectedFile)) return () => ToastService.Instance.Show(LocalizationService.Instance["Settings_Maintenance_Status_RestoreCancelled"], ToastType.Info); string targetPath = NullWavePaths.DatabasePath; string tempPath = targetPath + ".restoring"; File.Copy(selectedFile, tempPath, overwrite: true); - return () => ToastService.Instance.Show("Backup staged! Please restart NullWave to apply.", ToastType.Success, 10000, scope: "maintenance"); + return () => ToastService.Instance.Show(LocalizationService.Instance["Settings_Maintenance_Status_RestoreStaged"], ToastType.Success, 10000, scope: "maintenance"); }); Settings.BackupDatabaseRequested += path => RunMaintenanceTask("BackupDatabase", false, async () => @@ -167,10 +179,17 @@ private void RunMaintenanceTask(string taskName, bool dryRun, Func> _ = Task.Run(async () => { LiveNotification? activity = null; - string prefix = dryRun ? "Previewing" : "Running"; + string taskLabel = LocalizationService.Instance[$"Settings_Maintenance_Task_{taskName}"]; + string activityLabel = string.Format( + LocalizationService.Instance[dryRun ? "Settings_Maintenance_Task_Previewing" : "Settings_Maintenance_Task_Running"], + taskLabel); await Dispatcher.UIThread.InvokeAsync(() => - activity = ToastService.Instance.StartLiveActivity($"{prefix} {taskName}", "Processing...", isIndeterminate: true, scope: "maintenance")); + activity = ToastService.Instance.StartLiveActivity( + activityLabel, + LocalizationService.Instance["Settings_Maintenance_Task_Processing"], + isIndeterminate: true, + scope: "maintenance")); try { @@ -186,10 +205,25 @@ await Dispatcher.UIThread.InvokeAsync(() => NullActionLogger.Error(nameof(MainViewModel), ex, $"{taskName} failed"); await Dispatcher.UIThread.InvokeAsync(() => { - ToastService.Instance.CompleteLiveActivity(activity, $"{taskName} failed.", finalType: ToastType.Error); + ToastService.Instance.CompleteLiveActivity( + activity, + string.Format(LocalizationService.Instance["Settings_Maintenance_Task_Failed"], taskLabel), + finalType: ToastType.Error); _isMaintenanceRunning = false; }); } }); } + + 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/ViewModels/Shell/MainViewModel.cs b/ViewModels/Shell/MainViewModel.cs index 42b52f4..a08bbcf 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; @@ -169,7 +171,8 @@ public SortField ToolbarCurrentSort CurrentPage == "Playlists" ? Playlist.ToggleSortDirectionCommand : ActiveLibraryVM.ToggleSortDirectionCommand; public EffectsTier CurrentEffectsTier => _effectsTier.Current; - public bool CanAnimateSeekWave => CurrentEffectsTier != EffectsTier.Minimal; + public bool CanAnimateSeekWave => true; // Always animate, but at different frame rates + public int WaveFrameIntervalMs => CurrentEffectsTier == EffectsTier.Minimal ? 66 : 33; // 15fps on Minimal, 30fps on Standard // --- Child ViewModels --- public TrackInputViewModel Input { get; private set; } = null!; @@ -186,6 +189,7 @@ public SortField ToolbarCurrentSort public UserProfileViewModel Profile { get; private set; } = null!; public NavigationViewModel Nav { get; private set; } = null!; public QueueViewModel Queue { get; private set; } = null!; + public LibraryService LibraryService => _library; public ObservableCollection ActiveToasts => ToastService.Instance.ActiveToasts; public const double RightPanelWidth = 320; 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/ExportViewModel.cs b/ViewModels/Tools/ExportViewModel.cs index 9664e28..3b7b9c8 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; @@ -6,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; @@ -19,78 +24,95 @@ public class ExportViewModel : ViewModelBase public ICommand ExportJsonCommand { get; } public ICommand ExportCsvCommand { get; } + public ICommand ExportM3uCommand { get; } + public ICommand ExportFolderCommand { get; } public ExportViewModel(LibraryService library, ExportService 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[] + FileTypeChoices = new[] { new FilePickerFileType(format.ToUpper()) { Patterns = new[] { $"*.{format}" } } } + }); + + if (file == null) return; + + var path = file.Path.LocalPath; + 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); + else if (format == "m3u8" || format == "m3u") { - new FilePickerFileType(format.ToUpper()) - { - Patterns = new[] { $"*.{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; } + + 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 (file == null) return; // User cancelled - - var path = file.Path.LocalPath; - if (string.IsNullOrWhiteSpace(path)) + if (folders.Count == 0) return; + var destDir = folders[0].Path.LocalPath; + + var tracks = _library.GetAll().Where(t => !string.IsNullOrEmpty(t.FilePath) && File.Exists(t.FilePath)).ToList(); + + if (tracks.Count == 0) { - ErrorReporter.Report("Invalid file path selected.", "export"); + 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 tracks = _library.GetAll(); - - if (format == "json") - _export.ExportToJson(tracks, path); - else - _export.ExportToCsv(tracks, path); - - 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" - ); + var cts = new CancellationTokenSource(); + // Runs on a pool thread inside ExportService. + 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 diff --git a/ViewModels/Tools/ImportViewModel.cs b/ViewModels/Tools/ImportViewModel.cs index 9f0a836..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,54 +62,70 @@ private async Task ImportFolderAsync() return; } + // Honor the saved preference; only prompt if ImportMode == "Ask". + var copyToLibrary = await ResolveCopyDecisionAsync(); + 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 + var originalPath = files[i]; + + // 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; - + 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 = TrackNormalizer.TrySplit(rawTitle); + if (split != null) { artist = split.Value.Artist; title = split.Value.Title; } } var track = new Track { Title = title, Artist = artist, - FilePath = filePath, + FilePath = originalPath, Source = TrackSource.Local, Duration = duration }; - var (album, trackNum) = _metadata.FetchAlbumAndTrackNumber(filePath); + 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( @@ -116,12 +134,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); @@ -138,12 +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 mode = _prefs.Current.ImportMode; + if (string.Equals(mode, "Copy", StringComparison.OrdinalIgnoreCase)) return true; + if (string.Equals(mode, "Keep", StringComparison.OrdinalIgnoreCase)) return false; + + // "Ask" or unrecognized value: prompt the user. 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); + } + + 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 diff --git a/ViewModels/Tools/TrackInputViewModel.cs b/ViewModels/Tools/TrackInputViewModel.cs index 943585f..b9db39c 100644 --- a/ViewModels/Tools/TrackInputViewModel.cs +++ b/ViewModels/Tools/TrackInputViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using System.Windows.Input; using Avalonia; @@ -35,6 +36,11 @@ public class TrackInputViewModel : ViewModelBase private string _inputTitle = string.Empty; private string _inputArtist = string.Empty; private TrackSource _selectedSource = TrackSource.Unknown; + private CancellationTokenSource? _fetchDebounce; + private string? _autoTitle; + private string? _autoArtist; + private readonly Dictionary _pendingIds = new(); // FIX: Track IDs for in-flight fetches + public event Action? RadioCatalogRequested; private bool _isFetching; private bool _isUrlInputVisible; @@ -80,18 +86,33 @@ public string InputUrl OnPropertyChanged(); OnPropertyChanged(nameof(IsInputUrlValid)); - if (ShareLink.TryParseTrack(value, out _, out _)) + var normalized = SourceDetector.NormalizeUrl(value); + + if (normalized != _lastFetchedUrl) + { + if (InputTitle == _autoTitle) InputTitle = string.Empty; + if (InputArtist == _autoArtist) InputArtist = string.Empty; + _autoTitle = _autoArtist = null; + _lastFetchedThumbnail = null; + } + + if (ShareLink.TryParseTrack(normalized, out _, out _)) { SelectedSource = TrackSource.Unknown; return; } - SelectedSource = SourceDetector.Detect(value); - if (_urlParser.IsValidUrl(value) && value != _lastFetchedUrl - && DetectMediaType(value) != MediaType.Radio) + SelectedSource = SourceDetector.Detect(normalized); + if (_urlParser.IsValidUrl(normalized) && SourceDetector.IsPlayableUrl(normalized) && normalized != _lastFetchedUrl + && DetectMediaType(normalized) != MediaType.Radio) { - _lastFetchedUrl = value; - _ = FetchMetadataAsync(value); + _lastFetchedUrl = normalized; + _fetchDebounce?.Cancel(); + var cts = _fetchDebounce = new CancellationTokenSource(); + _ = Task.Delay(400, cts.Token).ContinueWith(t => + { + if (!t.IsCanceled) Avalonia.Threading.Dispatcher.UIThread.Post(() => _ = FetchMetadataAsync(normalized)); + }, TaskScheduler.Default); } } } @@ -100,7 +121,7 @@ public bool IsInputUrlValid { get { - var url = InputUrl.Trim(); + var url = SourceDetector.NormalizeUrl(InputUrl); if (string.IsNullOrWhiteSpace(url)) return false; if (ShareLink.IsProfileLink(url) || @@ -161,7 +182,7 @@ public TrackInputViewModel( _library.Update(track); }; - AddTrackCommand = new RelayCommand(AddTrack); + AddTrackCommand = new SafeRelayCommand(AddTrackSafeAsync, errorScope: "add-track"); AddLocalFileCommand = new RelayCommand(async () => await AddLocalFileAsync()); ShowUrlInputCommand = new RelayCommand(() => IsUrlInputVisible = !IsUrlInputVisible); BrowseRadioCatalogCommand = new RelayCommand(() => RadioCatalogRequested?.Invoke()); @@ -173,16 +194,55 @@ private static string StripQueryStringForDisplay(string url) return qIndex > 0 ? url[..qIndex] : url; } - private bool IsYouTubePlaylist(string url) + private static bool IsYouTubePlaylist(string url) => + SourceDetector.Detect(url) == TrackSource.YouTube && + url.Contains("/playlist?", StringComparison.OrdinalIgnoreCase); + + public void AddTrack() => _ = AddTrackSafeAsync(); + + private async Task AddTrackSafeAsync() { - return url.Contains("list=", StringComparison.OrdinalIgnoreCase) || - url.Contains("/playlist?", StringComparison.OrdinalIgnoreCase); + try { await AddTrackAsync(); } + catch (Exception ex) + { + NullActionLogger.Error(nameof(TrackInputViewModel), ex, "AddTrack failed"); + ToastService.Instance.Show("Couldn't add that track. See the log for details.", ToastType.Warning); + } } - public void AddTrack() + private async Task AddTrackAsync() { var providedTitle = InputTitle.Trim(); - var url = InputUrl.Trim(); + var rawUrl = InputUrl.Trim(); + + // --- MIX / PLAYLIST FRONT DOOR --- + // Inspect the URL *before* NormalizeUrl strips the list= parameter + var withList = SourceDetector.NormalizeUrl(rawUrl, keepListParam: true); + if (SourceDetector.Detect(withList) == TrackSource.YouTube && + withList.Contains("list=", StringComparison.OrdinalIgnoreCase) && + !withList.Contains("/playlist?", StringComparison.OrdinalIgnoreCase)) + { + var isMix = withList.Contains("list=RD", StringComparison.OrdinalIgnoreCase) || + withList.Contains("start_radio=1", StringComparison.OrdinalIgnoreCase); + + var msg = isMix + ? "This is a YouTube Mix (endless radio).\n\nImport the first 50 tracks as a playlist, or just add this single song?" + : "This song is inside a YouTube Playlist.\n\nImport the whole playlist, or just add this single song?"; + + var importList = await DialogService.Instance.ShowDialogAsync( + new Views.ConfirmDialog(isMix ? "YouTube Mix" : "YouTube Playlist", msg)); + + if (importList) + { + PlaylistImportRequested?.Invoke(withList); + ClearInputs(); + IsUrlInputVisible = false; + return; + } + } + + // Standard normalization (strips list= if they chose single song) + var url = SourceDetector.NormalizeUrl(rawUrl); if (string.IsNullOrWhiteSpace(url)) { IsUrlInputVisible = true; @@ -215,7 +275,7 @@ public void AddTrack() return; } - if (IsYouTubePlaylist(url)) + if (IsYouTubePlaylist(url) || (SelectedSource == TrackSource.Bandcamp && url.Contains("/album/", StringComparison.OrdinalIgnoreCase))) { PlaylistImportRequested?.Invoke(url); ClearInputs(); @@ -289,6 +349,10 @@ public void AddTrack() TrackSource.SoundCloud => "SoundCloud track", TrackSource.YouTube => "YouTube track", TrackSource.Spotify => "Spotify track", + TrackSource.Bandcamp => "Bandcamp track", + TrackSource.Audiomack => "Audiomack track", + TrackSource.ArchiveOrg => "Archive.org track", + TrackSource.Mixcloud => "Mixcloud set", _ => DetectMediaType(url) == MediaType.Radio ? FriendlyRadioName(url) : StripQueryStringForDisplay(url) @@ -314,6 +378,10 @@ public void AddTrack() if (match.Success) newTrack.Url = $"https://www.youtube.com/watch?v={match.Groups[1].Value}"; } + // Strip tracking params from display URLs for new sources to help duplicate detection + if (source is TrackSource.Bandcamp or TrackSource.Audiomack or TrackSource.ArchiveOrg or TrackSource.Mixcloud) + newTrack.Url = StripQueryStringForDisplay(newTrack.Url); + if (_library.IsDuplicate(newTrack)) { ToastService.Instance.Show("Track is already in your library.", ToastType.Info, durationMs: 3000); @@ -322,19 +390,25 @@ public void AddTrack() return; } + // FIX: Check consent BEFORE adding to the library to prevent ghost rows on decline + if (SourceDetector.IsYtDlpSource(source)) + { + if (!await _download.EnsureConsentAsync()) return; + } + _library.Add(newTrack); NullActionLogger.TrackAdded(newTrack.Id.ToString(), newTrack.Url, nameof(TrackInputViewModel)); if (usedFallbackTitle) { - _ = FetchMetadataAsync(url); + _ = FetchMetadataAsync(url, newTrack.Id); } ClearInputs(); IsUrlInputVisible = false; TrackAdded?.Invoke(); - if (source == TrackSource.YouTube || source == TrackSource.SoundCloud) + if (SourceDetector.IsYtDlpSource(source)) { _ = Task.Run(async () => await _download.DownloadAsync( @@ -344,13 +418,34 @@ await _download.DownloadAsync( } } - private static MediaType DetectMediaType(string url) + internal static MediaType DetectMediaType(string url) { + if (SourceDetector.IsYtDlpSource(SourceDetector.Detect(url))) + { + var lower = url.ToLowerInvariant(); + if (lower.Contains("audiobook") || lower.Contains("librivox") || lower.Contains("spoken word") || + lower.Contains("full book") || lower.Contains("archive.org/details/audiobook")) + return MediaType.Audiobook; + return MediaType.Music; + } + var value = url.ToLowerInvariant(); 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; + } + if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + var host = uri.Host.ToLowerInvariant(); + if (host.Contains("radio") && + !host.Contains("radiohead") && + (host.StartsWith("radio") || host.EndsWith("radio") || host.Contains(".radio."))) + { + return MediaType.Radio; + } + } if (value.Contains("audiobook") || value.Contains("librivox") || value.Contains("spoken word") || value.Contains("full book") || value.Contains("archive.org/details/audiobook")) return MediaType.Audiobook; @@ -364,10 +459,13 @@ private static string FriendlyRadioName(string url) : url; } - private async Task FetchMetadataAsync(string url) + private async Task FetchMetadataAsync(string url, Guid? trackId = null) { if (!_fetchesInFlight.Add(url)) { + // FIX: If a fetch is already running, map the new trackId to the URL + // so the running fetch can update the correct database row when it finishes. + if (trackId is { } pid) _pendingIds[url] = pid; Log.Debug("[{Source}] Skipping duplicate in-flight metadata fetch for {Url}", nameof(TrackInputViewModel), url); return; @@ -377,27 +475,39 @@ private async Task FetchMetadataAsync(string url) try { var (title, artist, thumbnail, duration) = await _metadata.FetchFromUrlAsync(url); - if (!string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(InputTitle)) InputTitle = title; - if (!string.IsNullOrWhiteSpace(artist) && string.IsNullOrWhiteSpace(InputArtist)) InputArtist = artist; - _lastFetchedThumbnail = thumbnail; + + // FIX: Recover the trackId if it was registered while we were waiting + if (trackId == null && _pendingIds.Remove(url, out var pending)) trackId = pending; + + // FIX: Only touch the form if the user is still on this URL + if (SourceDetector.NormalizeUrl(InputUrl) == url) + { + if (!string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(InputTitle)) + { + InputTitle = title; + _autoTitle = title; + } + if (!string.IsNullOrWhiteSpace(artist) && string.IsNullOrWhiteSpace(InputArtist)) + { + InputArtist = artist; + _autoArtist = artist; + } + _lastFetchedThumbnail = thumbnail; + } + Log.Information("[{Source}] Metadata fetched: {Title} by {Artist}", nameof(TrackInputViewModel), title, artist); - var existing = _library.GetAll() - .FirstOrDefault(t => t.Url == url); + // FIX: Look up by track ID first to survive URL canonicalization + var existing = trackId is { } id + ? _library.GetAll().FirstOrDefault(t => t.Id == id) + : _library.GetAll().FirstOrDefault(t => t.Url == url); if (existing != null) { if (!string.IsNullOrWhiteSpace(title)) { - var parsed = NullWave.Helpers.TrackNormalizer.TrySplit(title); - var cleanTitle = title; - var cleanArtist = artist; - if (parsed != null && !string.IsNullOrWhiteSpace(parsed.Value.Title)) - { - cleanTitle = parsed.Value.Title; - if (!string.IsNullOrWhiteSpace(parsed.Value.Artist)) cleanArtist = parsed.Value.Artist; - } + var (cleanArtist, cleanTitle) = NullWave.Helpers.TrackNormalizer.ResolveArtistTitle(title, artist); if (NullWave.Helpers.TrackNormalizer.IsPlaceholderTitle(existing.Title)) existing.Title = cleanTitle; if (NullWave.Helpers.TrackNormalizer.IsPlaceholderArtist(existing.Artist) && !string.IsNullOrWhiteSpace(cleanArtist)) existing.Artist = cleanArtist; @@ -423,6 +533,7 @@ private async Task FetchMetadataAsync(string url) finally { _fetchesInFlight.Remove(url); + _pendingIds.Remove(url); IsFetching = false; } } @@ -524,6 +635,13 @@ private async Task HandleSpotifyUrlAsync(string spotifyUrl) return; } + // FIX: Check consent BEFORE adding to the library + if (!await _download.EnsureConsentAsync()) + { + StatusMessage = "Downloads disabled"; + return; + } + var track = new Track { Title = result.SpotifyTitle, @@ -531,6 +649,7 @@ private async Task HandleSpotifyUrlAsync(string spotifyUrl) Url = result.YouTubeUrl, Source = TrackSource.Spotify }; + _library.Add(track); NullActionLogger.TrackAdded(track.Id.ToString(), result.YouTubeUrl, nameof(TrackInputViewModel)); TrackAdded?.Invoke(); @@ -550,5 +669,7 @@ private void ClearInputs() InputArtist = string.Empty; SelectedSource = TrackSource.Unknown; _lastFetchedThumbnail = null; + _lastFetchedUrl = string.Empty; + _autoTitle = _autoArtist = null; } -} +} \ No newline at end of file diff --git a/Views/Controls/AppearanceThemeModeView.axaml b/Views/Controls/AppearanceThemeModeView.axaml index 453f38f..bf5c84b 100644 --- a/Views/Controls/AppearanceThemeModeView.axaml +++ b/Views/Controls/AppearanceThemeModeView.axaml @@ -12,7 +12,7 @@ - + - +
\ No newline at end of file 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..7089cca 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,96 @@ 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; + var importer = new M3uImporter(vm.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); + // WIRE FIX: Pass the result to PlaylistViewModel to handle the DB insertion + if (importVm.DialogResult != null) + { + await vm.Playlist.ImportM3uPlaylistAsync(importVm.DialogResult.Preview, importVm.DialogResult.PlaylistName); + } + } + private void OnTrackSelected(object? sender, SelectionChangedEventArgs e) { if (sender is not ListBox list) return; @@ -28,7 +127,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/Controls/SidebarView.axaml b/Views/Controls/SidebarView.axaml index 93a69c5..a638163 100644 --- a/Views/Controls/SidebarView.axaml +++ b/Views/Controls/SidebarView.axaml @@ -1,4 +1,4 @@ - - - - + + @@ -460,10 +460,10 @@ - + Foreground="{DynamicResource BrushTextMuted}" FontSize="{DynamicResource FontSizeXs}" TextTrimming="CharacterEllipsis"/> @@ -544,11 +544,11 @@ - - - + + @@ -634,6 +634,11 @@ @@ -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..3baa087 100644 --- a/Views/Controls/TrackDetailView.axaml +++ b/Views/Controls/TrackDetailView.axaml @@ -164,7 +164,7 @@ - @@ -306,7 +301,7 @@ + FontSize="{DynamicResource FontSizeXs}" VerticalAlignment="Center"/> @@ -689,14 +689,14 @@ - + - + @@ -720,7 +720,7 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -280,7 +293,7 @@ - + diff --git a/Views/Settings/AudioTab.axaml b/Views/Settings/AudioTab.axaml index e0f71bf..53ff1b9 100644 --- a/Views/Settings/AudioTab.axaml +++ b/Views/Settings/AudioTab.axaml @@ -3,31 +3,32 @@ 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"> - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Views/Settings/DownloadsTab.axaml.cs b/Views/Settings/DownloadsTab.axaml.cs new file mode 100644 index 0000000..e703ff8 --- /dev/null +++ b/Views/Settings/DownloadsTab.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; +using NullWave.ViewModels; + +namespace NullWave.Views.Settings; + +public partial class DownloadsTab : UserControl +{ + public DownloadsTab() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/Views/Settings/GeneralTab.axaml b/Views/Settings/GeneralTab.axaml index 311e867..dbe219d 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"> @@ -10,7 +11,7 @@ - + @@ -31,7 +32,6 @@ MinWidth="200" VerticalAlignment="Center"/> - + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - + + + + @@ -51,32 +62,19 @@ @@ -101,9 +99,9 @@ @@ -116,16 +114,10 @@ - - - - - - - - - - + + + + @@ -147,17 +139,11 @@ @@ -181,27 +167,27 @@ - + - + - + - + - + - + @@ -218,7 +204,7 @@ - + @@ -227,9 +213,9 @@ @@ -238,9 +224,9 @@ @@ -249,18 +235,86 @@ - + + + @@ -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 a0a62eb..ed24724 100644 --- a/Views/Settings/UpdatesTab.axaml +++ b/Views/Settings/UpdatesTab.axaml @@ -7,11 +7,11 @@ x:DataType="vm:SettingsViewModel"> - + - + @@ -57,11 +57,15 @@ - + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + + - - - - - - - - - - - - - - - - + diff --git a/Views/SettingsWindow.axaml.cs b/Views/SettingsWindow.axaml.cs index 2d50038..692f44d 100644 --- a/Views/SettingsWindow.axaml.cs +++ b/Views/SettingsWindow.axaml.cs @@ -1,32 +1,128 @@ +using System.ComponentModel; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Animation; using Avalonia.Controls; 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; + private Transitions? _beadTransitions; + private bool _beadPlaced; + public SettingsWindow() { InitializeComponent(); WallpaperChromeSync.Attach(this); - // CRITICAL: single-host toast routing - while Settings is open, - // toasts render here ONLY (MainWindow overlay hides itself). + _beadTransitions = SpineBead.Transitions; + 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; + _vm.Hub.RefreshAll(); + 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.IsSettingsSidebarCollapsed)) + 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; + + if (key == "Integrations" && _vm != null) + _vm.Hub.RefreshAll(); + + 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