Developer Guide
This document explains the architecture, plugin interfaces, DI setup, and testing practices for MelodyBridge.
Architecture Overview
The solution follows a clean layered architecture:
MelodyBridge.Core : Interfaces, contracts, enums, models (no dependencies)
↕
MelodyBridge.Infrastructure : Implementations: downloaders, scanners, taggers, media servers
↕
MelodyBridge.Application : Orchestration: SyncEngine, DownloadManager, DI extension methods
↕
MelodyBridge.Server : ASP.NET Blazor web UI + REST API controllers
MelodyBridge.Desktop : Optional Photino desktop wrapper
MelodyBridge.UI.Components : Shared Blazor components
↕
MelodyBridge.Tests : NUnit test suite targeting all layersProject Dependencies
| Project | References |
|---|---|
| Core | (none) |
| Infrastructure | Core |
| Application | Core, Infrastructure |
| Server | Core, Application, Infrastructure, UI.Components |
| Desktop | Server |
| Tests | Core, Infrastructure, Application, Server |
The Data Flow
Spotify playlist URL
→ SpotifySourceProvider (embed page scraping, no API key)
→ PlaylistStore (SQLite snapshot, ExternalId identity, sync modes)
→ DownloadMissingAsync → DownloadManager waterfall → YtDlpDownloader
→ MP3 file + MELODY_ID tag + title/artist tags
→ LibraryScanner (reads tags, keeps DB current as files move)
→ SyncJobRunner → M3uGenerator (#EXTINF) or JellyfinSyncCore Interfaces
IDownloader: download plugins
public interface IDownloader
{
string Id { get; }
string Name { get; }
string Description => string.Empty;
Task<bool> IsAvailableAsync(CancellationToken ct = default);
Task<DownloaderSearchHit?> SearchAsync(
string artist, string title, DownloadQuality quality, CancellationToken ct = default);
Task<DownloaderDownloadResult> DownloadAsync(
string sourceUrl, string outputDirectory, string? melodyId,
DownloadQuality? quality = null, CancellationToken ct = default);
}Implement this to add a download source. Built-in plugins:
| Plugin | Id | Source | Notes |
|---|---|---|---|
LucidaDownloader | lucida | lucida.to (Tidal, Qobuz, Amazon Music) | High quality rips; needs a Cloudflare solver, otherwise skipped |
MonochromeDownloader | monochrome | monochrome.tf mirrors (community TIDAL API) | FLAC/AAC rips; tries each instance in order until one answers |
DoubleDoubleDownloader | doubledouble | us./eu.doubledouble.top | Submit-and-poll rips of direct track URLs (Tidal, Qobuz, Deezer, Amazon); SearchAsync returns null by design, the site search is captcha-gated |
SoundCloudDownloader | soundcloud | SoundCloud (via yt-dlp scsearch) | Original uploads, often 320 kbps; rejects files under 128 kbps |
ArchiveOrgDownloader | archiveorg | Internet Archive (public JSON APIs) | Public-domain and community recordings; rejects files under 128 kbps |
YtDlpDownloader | ytdlp | YouTube Music, then YouTube (via yt-dlp) | Widest fallback, best audio as MP3; ytmsearch1 tried before ytsearch1 everywhere |
Place new implementations in MelodyBridge.Infrastructure/Downloaders/ and register via services.AddSingleton<IDownloader, YourPlugin>(). Quality-gate downloads against the requested DownloadQuality band so bad rips never enter the library.
DownloadQuality carries a bitrate band: MinKbps and MaxKbps, both optional, both hard. Search hits whose measured bitrate falls outside the band are skipped, and downloaded files are probed with BitrateProbe.MeasureKbps and rejected+deleted when outside it. Unknown bitrates pass the search gate and leave the verdict to the post-download measurement.
IDownloaderRegistry manages the plugin waterfall: enable/disable and priority are persisted per plugin in the ProviderStates table. Plugin config values, declared via IDownloader.ConfigFields (Key, Label, Placeholder, Description) and read/written through GetConfigAsync/SetConfigAsync, persist in the DownloaderSettings table under plugin:{id}:{key} keys and are edited on the Plugins page in an expandable per-plugin panel.
DownloadCoordinator runs each playlist with up to download_max_concurrent parallel workers (setting, default 2, clamp 1-8, Advanced page). Workers are safe together because PlaylistStore.DownloadMissingAsync claims each track with an atomic conditional UPDATE (pending to in_progress) before downloading, so two callers never race on the same track.
ISourceProvider: playlist sources
public interface ISourceProvider
{
string Name { get; }
Platform Platform { get; }
bool CanHandle(string sourceIdentifier);
Task<Playlist> GetPlaylistAsync(string sourceIdentifier);
Task<string?> ResolveTrackUrlAsync(string query);
}Implement this to support a new playlist platform. PlaylistStore picks the provider whose CanHandle accepts the URL.
IMediaServerSync: media server targets
public interface IMediaServerSync
{
string Name { get; }
Task SyncPlaylistAsync(Playlist playlist, PlaylistOutputOptions options, CancellationToken ct = default);
}Implement this to sync playlists to a media server (e.g. Jellyfin). Place implementations in MelodyBridge.Infrastructure/MediaServers/.
IDownloadManager: the waterfall
public interface IDownloadManager
{
Task<string?> DownloadAsync(string sourceUrl, string outputDirectory, string melodyId, CancellationToken ct = default);
Task<string?> DownloadTrackAsync(string artist, string title, string outputDirectory, string melodyId, DownloadQuality? quality = null, CancellationToken ct = default);
IReadOnlyList<DownloadProgress> SnapshotProgress();
}DownloadTrackAsync iterates enabled plugins by priority: each searches by artist/title and the first successful download wins. DownloadAsync passes a direct URL to the plugins that can handle it.
Other Key Types
Playlist,Track,TrackQuality,MediaType: Core models inMelodyBridge.Core/Classes.csPlaylistOutputOptions: Output path, relative path toggle, path remap dictionaryTrackEntity,PlaylistEntity,ProviderStateRow: EF Core entities for persistenceScanLocationEntity,SyncJobEntity,SyncJobRunEntity: Library paths and sync job trackingPlaylistSyncMode:Additive(removed tracks stay as flagged history) /Mirror(local copy matches the source exactly)
Dependency Injection
The MelodyBridge.Application project provides extension methods for registering services:
AddMelodyBridge()
Registers core services: DownloadManager, SyncEngine, library scanner, M3U generator, and all infrastructure services including:
PlaylistStore: playlist snapshots, sync modes,DownloadMissingAsync, auto-sync due logicDownloaderRegistry: plugin enable/priority state (DB-persisted)SyncJobRunner: orchestrates sync jobs (resolve downloaded tracks → M3U / media server)AutoSyncBackgroundService: syncs playlists whose per-playlist interval has elapsedScanSchedulingBackgroundService: scheduled library path scansSpotifySourceProvider,YouTubeSourceProvider: playlist sources
AddJellyfinSync()
Registers the Jellyfin media server sync plugin with HttpClient via AddHttpClient.
Usage in Program.cs
builder.Services.AddMelodyBridge();
builder.Services.AddJellyfinSync();Testing
The suite is NUnit 4 with Moq for UI-level DI mocks only.
Honest-test rules
- No InMemory provider for persistence logic: playlist/store tests use real SQLite files (
UseSqlite("Data Source=...")), deleted in teardown - Live tests hit the real network: real open.spotify.com fetches, real yt-dlp downloads (
[Category("Live")], CI runs them in a separate job) - Assertions read back from disk or a fresh DbContext: nothing is asserted from in-memory cached objects
- Downloaded files are validated deeply: the MELODY_ID tag is read from the actual bytes, durations ffprobe-validated
Running Tests
# Fast suite (CI default)
dotnet test MelodyBridge.sln --filter "FullyQualifiedName!~Tests.Integration"
# Live suite (needs yt-dlp on PATH + ffprobe)
dotnet test MelodyBridge.sln --filter "Category=PlaylistStore|Category=Live"Test Organization
MelodyBridge.Tests/
├── Core/ # Model and enum tests
├── Infrastructure/ # Scanner, tagger, M3U, DB context tests
│ ├── LibraryScannerTests.cs # Real tagged MP3s: register + move/update identity
│ ├── M3uGeneratorTests.cs # Read-back of produced .m3u files
│ ├── JellyfinSyncTests.cs # Jellyfin client behavior
│ ├── TaglibHelperTests.cs # Tag reading/writing
│ └── DbContextTests.cs
├── Services/
│ ├── PlaylistStoreLiveTests.cs # Live Spotify fetch, real SQLite
│ ├── PlaylistStoreSyncModeTests.cs # Additive/Mirror with real SQLite
│ ├── SpotifySourceProviderTests.cs
│ └── SyncEngineTests.cs
├── Integration/
│ ├── YtDlpDownloaderLiveTests.cs # Live search/download/tag/ffprobe
│ ├── DownloadMissingAsyncTests.cs # Real plugin writing real tagged files
│ └── SyncJobRunnerTests.cs # Real .m3u on disk + run history
└── Server/
├── UiTests/ # bUnit component tests
└── SyncControllerTests.csAdding New Tests
- Create a new
.csfile in the appropriate folder underMelodyBridge.Tests/ - Add
[TestFixture]/[Test]attributes; tag live tests with[Category("Live")] - Follow the honest-test rules above
- Run with
dotnet testto verify
Adding a New Downloader Plugin
- Create a class implementing
IDownloaderinMelodyBridge.Infrastructure/Downloaders/. - Register it:
services.AddSingleton<IDownloader, YourPlugin>(); - It appears in the UI (Downloads page) automatically with enable/priority controls.
- Add tests in
MelodyBridge.Tests/Integration/.
Adding a New Playlist Source
- Create a class implementing
ISourceProviderinMelodyBridge.Infrastructure/Services/. - Register it:
services.AddSingleton<ISourceProvider, YourProvider>(); PlaylistStore.AddOrRefreshAsync(url)will route byCanHandle.
Adding a New Media Server Plugin
- Create a class implementing
IMediaServerSyncinMelodyBridge.Infrastructure/MediaServers/. - Register it via
ServiceCollectionExtensions. - Add tests following the
JellyfinSyncTestspattern.