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 @@
-A personal music organizer with download, playback, and AI-powered smart sorting capabilities, built with C#/.NET 8 and Avalonia UI on Linux.
+[](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