# Phase 6: Virtual Soulfind Mesh - Detailed Implementation Design > **New Phase**: T-800 to T-840 (41 tasks) > **Branch**: `experimental/virtual-soulfind` > **Dependencies**: Phases 1-5 (MusicBrainz, multi-swarm, public DHT rendezvous, mesh DHT, overlay) > **Estimated Duration**: 12-16 weeks > **Project Note**: This is a fork of [slskd](https://github.com/slskd/slskd). See [../README.md](../README.md#acknowledgments) for attribution. > **DHT terminology**: this design's shadow-index and scene records belong to > the slskdN mesh DHT. The public BitTorrent DHT is only an optional rendezvous > mechanism for finding mesh overlay endpoints. Mesh metadata and file bytes > use the TLS-protected overlay. See [DHT and Mesh Architecture](DHT_MESH_ARCHITECTURE.md). --- ## Overview The Virtual Soulfind Mesh transforms slskdn into a **truly decentralized music sharing network** that: 1. **Enhances Soulseek** when the server is available (hybrid mode) 2. **Replaces Soulseek** when the server is unavailable (disaster mode) 3. **Never requires** central servers or privileged nodes This is a **peer-to-peer "virtual server"** where each slskdn instance contributes to: - **Shadow index**: Decentralized MBID→peers mapping via the mesh DHT - **Scenes**: Decentralized rooms/communities via mesh-DHT topics - **Disaster resilience**: Mesh-only operation when Soulseek is down --- ## What Problem Does This Solve? ### The Soulseek Server Problem **Risk**: The official Soulseek server is a single point of failure: - If it shuts down, the entire network dies - If you're banned, you lose access to everything - Centralized control over a decentralized network ### The Virtual Soulfind Solution **Decentralized Intelligence**: - Each slskdn peer **observes** Soulseek traffic and **learns** what's available - Peers **share** this knowledge via the mesh DHT (shadow index) - When Soulseek dies, the mesh **already knows** who has what - Transfers continue via overlay, guided by the shadow index **Think of it as**: public BitTorrent DHT rendezvous + mesh-DHT MBID awareness + overlay multi-swarm = resilient music network --- ## Phase 6A: Capture & Normalization Pipeline (T-800 to T-804) ### Task T-800: Soulseek Traffic Observer **Purpose**: Passively monitor Soulseek traffic to build knowledge graph. #### Data Model ```csharp namespace slskd.VirtualSoulfind.Capture { /// /// Observed Soulseek search result (pre-normalization). /// public class SearchObservation { public string ObservationId { get; set; } public DateTimeOffset Timestamp { get; set; } // Search context public string Query { get; set; } public string SoulseekUsername { get; set; } // File details public string FilePath { get; set; } public long SizeBytes { get; set; } public int? BitRate { get; set; } public int? DurationSeconds { get; set; } public string Extension { get; set; } // Metadata extraction (best-effort from path) public string Artist { get; set; } public string Album { get; set; } public string Title { get; set; } } /// /// Observed completed transfer. /// public class TransferObservation { public string TransferId { get; set; } public DateTimeOffset CompletedAt { get; set; } public string SoulseekUsername { get; set; } public string FilePath { get; set; } public string LocalPath { get; set; } // Where we saved it public long SizeBytes { get; set; } public TimeSpan Duration { get; set; } public double ThroughputBytesPerSec { get; set; } public bool Success { get; set; } } } ``` #### Implementation ```csharp namespace slskd.VirtualSoulfind.Capture { public interface ITrafficObserver { /// /// Called when search results are received from Soulseek server. /// Task OnSearchResultsAsync(string query, SearchResponse response, CancellationToken ct = default); /// /// Called when a Soulseek transfer completes. /// Task OnTransferCompleteAsync(Transfer transfer, CancellationToken ct = default); } public class TrafficObserver : ITrafficObserver { private readonly ILogger log; private readonly INormalizationPipeline normalization; public async Task OnSearchResultsAsync(string query, SearchResponse response, CancellationToken ct) { foreach (var user in response.Responses) { foreach (var file in user.Files) { var observation = new SearchObservation { ObservationId = Ulid.NewUlid().ToString(), Timestamp = DateTimeOffset.UtcNow, Query = query, SoulseekUsername = user.Username, FilePath = file.Filename, SizeBytes = file.Size, BitRate = file.BitRate, DurationSeconds = file.Length, Extension = Path.GetExtension(file.Filename) }; // Extract metadata from path (heuristic) ExtractMetadataFromPath(observation); // Send to normalization pipeline await normalization.ProcessSearchObservationAsync(observation, ct); } } } public async Task OnTransferCompleteAsync(Transfer transfer, CancellationToken ct) { if (transfer.State != TransferStates.Completed) return; var observation = new TransferObservation { TransferId = transfer.Id, CompletedAt = DateTimeOffset.UtcNow, SoulseekUsername = transfer.Username, FilePath = transfer.Filename, LocalPath = Path.Combine(transfer.DestinationDirectory, transfer.DestinationFilename), SizeBytes = transfer.Size, Duration = transfer.ElapsedTime ?? TimeSpan.Zero, ThroughputBytesPerSec = transfer.AverageSpeed ?? 0, Success = true }; // Send to normalization (includes fingerprinting) await normalization.ProcessTransferObservationAsync(observation, ct); } private void ExtractMetadataFromPath(SearchObservation obs) { // Heuristic parsing of "Artist - Album/Track.flac" style paths // This is best-effort; real metadata comes from fingerprinting var parts = obs.FilePath.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries); if (parts.Length >= 2) { // Common pattern: "Artist/Album/Track.ext" obs.Artist = parts[0]; obs.Album = parts.Length > 2 ? parts[1] : null; obs.Title = Path.GetFileNameWithoutExtension(parts[^1]); } } } } ``` #### Integration Points ```csharp // In SearchService.cs public async Task SearchAsync(SearchOptions options, CancellationToken ct) { var response = await soulseek.SearchAsync(options.Query, ct); // Notify observer await trafficObserver.OnSearchResultsAsync(options.Query, response, ct); return response; } // In TransferService.cs private async Task OnTransferCompletedAsync(Transfer transfer) { // Existing completion logic... // Notify observer await trafficObserver.OnTransferCompleteAsync(transfer, CancellationToken.None); } ``` #### Implementation Checklist - [x] Define `SearchObservation` and `TransferObservation` models - [x] Implement `ITrafficObserver` interface - [x] Add path metadata extraction heuristics - [x] Integrate with `SearchService` (hook search results) - [x] Integrate with `TransferService` (hook completions) - [x] Add database schema for raw observations (optional, for debugging) - [x] Add configuration toggle: `mesh.capture.enabled` - [x] Add unit tests for metadata extraction - [x] Add integration test with mock search/transfer --- ### Task T-801: MBID Normalization Pipeline **Purpose**: Convert observations into MB-aware `AudioVariant` records. #### Implementation ```csharp namespace slskd.VirtualSoulfind.Capture { public interface INormalizationPipeline { Task ProcessSearchObservationAsync(SearchObservation obs, CancellationToken ct = default); Task ProcessTransferObservationAsync(TransferObservation obs, CancellationToken ct = default); } public class NormalizationPipeline : INormalizationPipeline { private readonly IFingerprintExtractionService fingerprinting; private readonly IAcoustIdClient acoustId; private readonly IMusicBrainzClient musicBrainz; private readonly IShadowIndexBuilder shadowIndex; public async Task ProcessSearchObservationAsync(SearchObservation obs, CancellationToken ct) { // For search results, we only have path + heuristic metadata // Can't fingerprint without the file, so we do best-effort MB lookup if (string.IsNullOrEmpty(obs.Artist) || string.IsNullOrEmpty(obs.Title)) { return; // Not enough metadata } // Query MusicBrainz by artist + title var mbResults = await musicBrainz.SearchRecordingAsync(obs.Artist, obs.Title, ct); if (mbResults.Count == 0) { log.Debug("[NORM] No MB matches for {Artist} - {Title}", obs.Artist, obs.Title); return; } // Take best match (first result, typically highest score) var recording = mbResults.First(); // Create provisional variant entry var variant = new AudioVariant { VariantId = Ulid.NewUlid().ToString(), MusicBrainzRecordingId = recording.Id, // Technical properties (from Soulseek metadata) Codec = GuessCodecFromExtension(obs.Extension), Container = obs.Extension?.TrimStart('.').ToUpperInvariant(), BitrateKbps = obs.BitRate ?? 0, DurationMs = (obs.DurationSeconds ?? 0) * 1000, FileSizeBytes = obs.SizeBytes, // Placeholder quality (will be refined if we download this file) QualityScore = 0.5, // Unknown TranscodeSuspect = false, FirstSeenAt = obs.Timestamp, LastSeenAt = obs.Timestamp, SeenCount = 1 }; // Feed to shadow index await shadowIndex.AddVariantObservationAsync( obs.SoulseekUsername, recording.Id, variant, ct); } public async Task ProcessTransferObservationAsync(TransferObservation obs, CancellationToken ct) { if (!obs.Success) return; if (!File.Exists(obs.LocalPath)) return; // We have the actual file! Extract fingerprint var fingerprint = await fingerprinting.ExtractFingerprintAsync(obs.LocalPath, ct); if (fingerprint == null) { log.Warning("[NORM] Failed to fingerprint {Path}", obs.LocalPath); return; } // Resolve MusicBrainz Recording ID via AcoustID var acoustIdResult = await acoustId.LookupAsync( fingerprint.Fingerprint, fingerprint.SampleRate, fingerprint.DurationSeconds, ct); if (acoustIdResult?.Recordings == null || acoustIdResult.Recordings.Count == 0) { log.Warning("[NORM] No AcoustID match for {Path}", obs.LocalPath); return; } var recordingId = acoustIdResult.Recordings.First().Id; // Build full AudioVariant with quality scoring using var tagFile = TagLib.File.Create(obs.LocalPath); var props = tagFile.Properties; var variant = new AudioVariant { VariantId = Ulid.NewUlid().ToString(), MusicBrainzRecordingId = recordingId, // Accurate technical properties Codec = props.Description, Container = Path.GetExtension(obs.LocalPath).TrimStart('.').ToUpperInvariant(), SampleRateHz = props.AudioSampleRate, BitDepth = props.BitsPerSample, Channels = props.AudioChannels, BitrateKbps = props.AudioBitrate, DurationMs = (int)props.Duration.TotalMilliseconds, FileSizeBytes = obs.SizeBytes, AudioFingerprint = fingerprint.Fingerprint, FileSha256 = await ComputeFileSha256Async(obs.LocalPath, ct), FirstSeenAt = obs.CompletedAt, LastSeenAt = obs.CompletedAt, SeenCount = 1 }; // Compute quality score var scorer = new QualityScorer(); variant.QualityScore = scorer.ComputeQualityScore(variant); // Detect transcodes var detector = new TranscodeDetector(); var (isSuspect, reason) = detector.DetectTranscode(variant); variant.TranscodeSuspect = isSuspect; variant.TranscodeReason = reason; // Feed to shadow index await shadowIndex.AddVariantObservationAsync( obs.SoulseekUsername, recordingId, variant, ct); } } } ``` #### Implementation Checklist - [x] Implement `INormalizationPipeline` interface - [x] Implement search observation processing (heuristic MB lookup) - [x] Implement transfer observation processing (fingerprinting) - [x] Integrate quality scoring from Phase 2 - [x] Integrate transcode detection from Phase 2 - [x] Add configuration for MB search strictness - [x] Add unit tests for normalization logic - [x] Add integration tests with sample files --- ### Tasks T-802 to T-804: Supporting Infrastructure **T-802**: Username pseudonymization (map Soulseek username → overlay peer ID) **T-803**: Observation database schema (optional persistence for debugging) **T-804**: Privacy controls (anonymization settings, data retention) --- ## Phase 6B: Shadow Index Over Mesh DHT (T-805 to T-812) ### Task T-805: Mesh-DHT Key Derivation **Purpose**: Map MBIDs and scenes to mesh-DHT keys. ```csharp namespace slskd.VirtualSoulfind.ShadowIndex { public static class DhtKeyDerivation { private const string NAMESPACE_MBID_RELEASE = "slskdn-vsf-mbid-release-v1"; private const string NAMESPACE_MBID_RECORDING = "slskdn-vsf-mbid-recording-v1"; private const string NAMESPACE_SCENE = "slskdn-vsf-scene-v1"; public static byte[] DeriveReleaseKey(string mbReleaseId) { return SHA1.HashData(Encoding.UTF8.GetBytes($"{NAMESPACE_MBID_RELEASE}:{mbReleaseId}")); } public static byte[] DeriveRecordingKey(string mbRecordingId) { return SHA1.HashData(Encoding.UTF8.GetBytes($"{NAMESPACE_MBID_RECORDING}:{mbRecordingId}")); } public static byte[] DeriveSceneKey(string sceneId) { return SHA1.HashData(Encoding.UTF8.GetBytes($"{NAMESPACE_SCENE}:{sceneId}")); } } } ``` --- ### Task T-806: Shadow Index Shard Format **Purpose**: Define compact mesh-DHT value format. ```csharp namespace slskd.VirtualSoulfind.ShadowIndex { /// /// Compact shadow index shard (stored in the mesh DHT). /// public class ShadowIndexShard { public string ShardVersion { get; set; } = "1.0"; public DateTimeOffset Timestamp { get; set; } public int TTLSeconds { get; set; } = 3600; // 1 hour default // Compact peer set (hashed overlay IDs, first 8 bytes) public List PeerIdHints { get; set; } // Each 8 bytes // Canonical variant hints public List CanonicalVariants { get; set; } public int ApproximatePeerCount { get; set; } } public class VariantHint { public string Codec { get; set; } // "FLAC", "MP3" public int BitrateKbps { get; set; } public long SizeBytes { get; set; } public byte[] HashPrefix { get; set; } // First 16 bytes of SHA256 } } ``` **Serialization**: Use MessagePack or Protocol Buffers for compactness. --- ### Task T-807: Shadow Index Builder **Purpose**: Aggregate observations into shards. ```csharp public interface IShadowIndexBuilder { Task AddVariantObservationAsync(string username, string recordingId, AudioVariant variant, CancellationToken ct = default); Task BuildShardAsync(string mbid, CancellationToken ct = default); } ``` --- ### Tasks T-808 to T-812: Mesh-DHT Publishing & Querying **T-808**: Shard publisher (periodic background task) **T-809**: Mesh-DHT query interface (resolve MBID → peer hints) **T-810**: Shard merging logic (combine shards from multiple peers) **T-811**: TTL and eviction policy **T-812**: Rate limiting for mesh-DHT writes --- ## Phase 6C: Scenes / Micro-Networks (T-813 to T-820) ### Task T-813: Scene Management Service ```csharp namespace slskd.VirtualSoulfind.Scenes { public interface ISceneService { Task> GetJoinedScenesAsync(CancellationToken ct = default); Task JoinSceneAsync(string sceneId, CancellationToken ct = default); Task LeaveSceneAsync(string sceneId, CancellationToken ct = default); Task GetSceneMetadataAsync(string sceneId, CancellationToken ct = default); } public class Scene { public string SceneId { get; set; } public SceneType Type { get; set; } public string DisplayName { get; set; } public int MemberCount { get; set; } public DateTimeOffset JoinedAt { get; set; } } public enum SceneType { Label, // e.g., "scene:label:warp-records" Genre, // e.g., "scene:genre:dub-techno" Private // e.g., "scene:key::friends" } } ``` --- ### Tasks T-814 to T-820: Scene Infrastructure **T-814**: Scene mesh-DHT announcements **T-815**: Scene membership tracking **T-816**: Overlay pubsub for scene gossip **T-817**: Scene-scoped job creation (label crate from scene) **T-818**: Scene UI (list, join, leave) **T-819**: Scene chat (optional, overlay pubsub messages) **T-820**: Scene moderation (local mute/block) --- ## Phase 6D: Disaster Mode & Failover (T-821 to T-830) ### Task T-821: Soulseek Health Monitor ```csharp namespace slskd.VirtualSoulfind.DisasterMode { public enum SoulseekHealth { Healthy, // Connected and responsive Degraded, // Slow or intermittent Unavailable // Cannot connect or banned } public interface ISoulseekHealthMonitor { SoulseekHealth CurrentHealth { get; } Task StartMonitoringAsync(CancellationToken ct = default); event EventHandler HealthChanged; } public class SoulseekHealthMonitor : ISoulseekHealthMonitor { public SoulseekHealth CurrentHealth { get; private set; } = SoulseekHealth.Healthy; public async Task StartMonitoringAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { var health = await CheckHealthAsync(ct); if (health != CurrentHealth) { log.Warning("[HEALTH] Soulseek health changed: {Old} → {New}", CurrentHealth, health); CurrentHealth = health; HealthChanged?.Invoke(this, health); } await Task.Delay(TimeSpan.FromSeconds(30), ct); } } private async Task CheckHealthAsync(CancellationToken ct) { if (soulseek.State != SoulseekClientStates.Connected) { // Try to reconnect try { await soulseek.ConnectAsync(ct); } catch (SoulseekClientException ex) when (ex.Message.Contains("banned")) { return SoulseekHealth.Unavailable; // Banned } catch { return SoulseekHealth.Unavailable; // Can't connect } } // Check responsiveness with ping/pong try { await soulseek.PingAsync(TimeSpan.FromSeconds(5), ct); return SoulseekHealth.Healthy; } catch (TimeoutException) { return SoulseekHealth.Degraded; } } } } ``` --- ### Task T-822: Disaster Mode Coordinator ```csharp public interface IDisasterModeCoordinator { bool IsDisasterModeActive { get; } Task ActivateDisasterModeAsync(CancellationToken ct = default); Task DeactivateDisasterModeAsync(CancellationToken ct = default); } ``` When disaster mode activates: 1. Disable Soulseek search/transfer paths 2. Switch all resolvers to mesh DHT + overlay only 3. Show UI indicator 4. Emit telemetry event --- ### Tasks T-823 to T-830: Disaster Mode Features **T-823**: Mesh-only search (MBID → mesh DHT → peers) **T-824**: Mesh-only transfers (overlay multi-swarm only) **T-825**: Scene-based peer discovery (fallback when mesh DHT is sparse) **T-826**: Disaster mode UI indicator **T-827**: Configuration: auto vs forced disaster mode **T-828**: Graceful degradation (partial Soulseek availability) **T-829**: Disaster mode telemetry **T-830**: Recovery logic (re-enable Soulseek when healthy) --- ## Phase 6E: Integration & Polish (T-831 to T-840) **T-831**: Integrate shadow index with existing job resolvers **T-832**: Integrate scenes with label crate jobs **T-833**: Integrate disaster mode with rescue mode **T-834**: Privacy audit (ensure username anonymization) **T-835**: Performance optimization (mesh-DHT query caching) **T-836**: Configuration UI (mesh settings panel) **T-837**: Telemetry dashboard (shadow index stats, disaster events) **T-838**: Documentation (user guide for disaster mode) **T-839**: Integration tests (full disaster mode simulation) **T-840**: Load testing (mesh-DHT scalability, shard size limits) --- ## Configuration ```yaml mesh: enabled: true capture: enabled: true anonymize_usernames: true retention_days: 30 shadow_index: enabled: true publish_interval_minutes: 15 shard_ttl_hours: 1 max_shards_per_publish: 100 scenes: enabled: true max_joined_scenes: 20 enable_chat: false # Opt-in for scene chat disaster_mode: auto: true # Auto-detect and activate force: false # Force mesh-only (for testing) unavailable_threshold_minutes: 10 privacy: anonymize_usernames: true never_publish_paths: true dht_rate_limit_per_minute: 100 # mesh-DHT write budget ``` --- ## Success Criteria ### Normal Mode - ✅ Shadow index populated from Soulseek traffic - ✅ Scenes created and joined via mesh DHT - ✅ MBID jobs enriched with shadow index hints - ✅ Privacy maintained (no username leaks in mesh DHT) ### Disaster Mode - ✅ Auto-detection when Soulseek unreachable - ✅ MBID jobs resolve via mesh DHT + overlay only - ✅ Transfers complete via overlay multi-swarm - ✅ UI shows disaster mode indicator - ✅ Recovery when Soulseek returns --- ## Estimated Timeline - **Phase 6A** (Capture): 3 weeks (T-800 to T-804) - **Phase 6B** (Shadow Index): 4 weeks (T-805 to T-812) - **Phase 6C** (Scenes): 3 weeks (T-813 to T-820) - **Phase 6D** (Disaster Mode): 4 weeks (T-821 to T-830) - **Phase 6E** (Integration): 2 weeks (T-831 to T-840) **Total**: 16 weeks (~4 months) --- **This is the "killer feature" that makes slskdn truly revolutionary**: a decentralized music network that doesn't die when central servers do.