// =============================== // HELP & DOCS PAGE // =============================== function docsImg(src, alt) { return `
${alt} ${alt}
`; } function openDocsLightbox(wrapper) { const img = wrapper.querySelector('.docs-screenshot'); if (!img) return; const existing = document.querySelector('.docs-lightbox'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.className = 'docs-lightbox'; overlay.innerHTML = `${img.alt}`; document.body.appendChild(overlay); requestAnimationFrame(() => overlay.classList.add('active')); const close = () => { overlay.classList.remove('active'); setTimeout(() => overlay.remove(), 250); }; overlay.addEventListener('click', close); document.addEventListener('keydown', function handler(e) { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', handler); } }); } const DOCS_SECTIONS = [ { id: 'getting-started', title: 'Getting Started', icon: '/static/dashboard.jpg', children: [ { id: 'gs-overview', title: 'Overview' }, { id: 'gs-first-setup', title: 'First-Time Setup' }, { id: 'gs-connecting', title: 'Connecting Services' }, { id: 'gs-interface', title: 'Understanding the Interface' }, { id: 'gs-folders', title: 'Folder Setup (Downloads & Transfer)' }, { id: 'gs-docker', title: 'Docker & Deployment' } ], content: () => `

Overview

SoulSync is a self-hosted music download, sync, and library management platform. It connects to Spotify, Apple Music/iTunes, Deezer, Tidal, Qobuz, YouTube, and Beatport for metadata, and downloads from Soulseek, YouTube, Tidal, Qobuz, HiFi, and Deezer. Your library is served through Plex, Jellyfin, or Navidrome.

${docsImg('gs-overview.jpg', 'SoulSync dashboard overview')}

🎵 Download Music

Search and download tracks in FLAC, MP3, and more from 6 sources (Soulseek, YouTube, Tidal, Qobuz, HiFi, Deezer), with automatic metadata tagging and file organization.

🔄 Playlist Sync

Mirror playlists from Spotify, YouTube, Tidal, and Beatport. Discover official metadata and sync to your media server.

📚 Library Management

Browse, edit, and enrich your music library with metadata from 9 services. Write tags directly to audio files.

🤖 Automations

Schedule tasks, chain workflows with signals, and get notified via Discord, Pushbullet, or Telegram.

🔍 Artist Discovery

Discover new artists via similar-artist recommendations, seasonal playlists, genre exploration, and time-machine browsing.

👀 Watchlist

Follow artists and automatically scan for new releases. New tracks are added to your wishlist for download.

First-Time Setup

After launching SoulSync, head to the Settings page to configure your services. At minimum you need:

  1. Download Source — Connect at least one download source: Soulseek (slskd), YouTube, Tidal, Qobuz, HiFi, or Deezer. Soulseek offers the best quality selection; the others work as alternatives or fallbacks in Hybrid mode.
  2. Media Server — Connect Plex, Jellyfin, or Navidrome so SoulSync knows where your library lives and can trigger scans.
  3. Spotify (Recommended) — Connect Spotify for the richest metadata. Create an app at developer.spotify.com, enter your Client ID and Secret, then click Authenticate.
  4. Download Path — Set your download and transfer paths in the Download Settings section. The transfer path should point to your media server's monitored folder.
${docsImg('gs-first-setup.jpg', 'Settings page first-time setup')}
💡
You can start using SoulSync with just one download source. Spotify and other services add metadata enrichment but aren't strictly required — iTunes/Apple Music and Deezer are always available as free fallbacks.

Connecting Services

SoulSync integrates with many external services. Here's a quick reference for each:

ServicePurposeAuth Required
SpotifyPrimary metadata source (artists, albums, tracks, cover art, genres)OAuth — Client ID + Secret
iTunes / Apple MusicFallback metadata source, always free, no auth neededNone
Soulseek (slskd)Download source — P2P network, best for lossless and rare musicURL + API key
YouTubeDownload source — audio extraction via yt-dlpNone (optional cookies browser)
TidalDownload source + playlist import + enrichmentOAuth — Client ID + Secret
QobuzDownload source + enrichmentUsername + Password (app ID auto-fetched)
HiFiDownload source — free lossless via community APINone
DeezerDownload source + metadata fallbackARL cookie token
PlexMedia server — library scanning, metadata sync, audio streamingURL + Token
JellyfinMedia server — library scanning, audio streamingURL + API Key
NavidromeMedia server — auto-detects changes, audio streamingURL + Username + Password
Last.fmEnrichment — listener stats, tags, bios, similar artistsAPI Key
GeniusEnrichment — lyrics, descriptions, alternate namesAccess Token
AcoustIDAudio fingerprint verification of downloadsAPI Key
ListenBrainzListening history and recommendationsURL + Token
${docsImg('gs-connecting.jpg', 'Service credentials connected')}

Understanding the Interface

SoulSync uses a sidebar navigation layout. The left sidebar contains links to every page, a media player at the bottom, and service status indicators. The main content area changes based on the selected page.

${docsImg('gs-interface.jpg', 'SoulSync interface layout')}

Version & Updates: Click the version number in the sidebar footer to open the What's New modal, which shows detailed release notes for every feature and fix. SoulSync automatically checks for updates by comparing your running version against the latest GitHub commit. If an update is available, a banner appears in the modal. Docker users are notified when a new image has been pushed to the repo.

Folder Setup (Downloads & Transfer)

SoulSync uses three folders to manage your music files. Most setup issues come from incorrect folder configuration — especially in Docker. Read this section carefully.

⚠️
Docker users — there are TWO steps, not one!

Step 1: Map your volumes in docker-compose.yml — this makes folders accessible to the container.
Step 2: Configure the paths in SoulSync Settings → Download Settings — this tells the app where to look.

Setting up docker-compose volumes alone is not enough. You must also configure the app settings. If you skip Step 2, downloads will complete but nothing will transfer, post-processing will fail silently, and tracks will re-download repeatedly.

The Three Folders

FolderDefault (Docker)Purpose
Download Path/app/downloadsWhere slskd/YouTube/Tidal/Qobuz initially saves downloaded files. This is a temporary staging area — files should not stay here permanently.
Transfer Path/app/TransferWhere post-processed files are moved after tagging and renaming. This must be the folder your media server (Plex/Jellyfin/Navidrome) monitors.
Staging Path/app/StagingFor the Import feature only. Drop audio files here to import them into your library via the Import page.
${docsImg('gs-folders.jpg', 'Download settings folder configuration')}

How Files Flow

ℹ️
The complete download-to-library pipeline:

1. You search for music in SoulSync and click download
2. SoulSync tells slskd to download the file → slskd saves it to its download folder
3. SoulSync detects the completed download in the Download Path
4. Post-processing runs: AcoustID verification → metadata tagging → cover art embedding → lyrics fetch
5. File is renamed and organized (e.g., Artist/Album/01 - Title.flac)
6. File is moved from Download Path → Transfer Path
7. Media server scan is triggered → file appears in your library

If any step fails, the pipeline stops. The most common failure point is Step 3 — SoulSync can't find the file because the Download Path doesn't match where slskd actually saved it.

Docker Setup: The Full Picture

${docsImg('gs-folder-docker.jpg', 'Docker folder mapping')}

In Docker, every app runs in its own isolated container with its own filesystem. Volume mounts in docker-compose create "bridges" between your host folders and the container. But SoulSync doesn't automatically know where those bridges go — you have to tell it via the Settings page.

Here's what happens with a properly configured setup:

🗂️
HOST (your server)
/mnt/data/slskd-downloads/ ← where slskd saves files on your server
/mnt/media/music/ ← where Plex/Jellyfin/Navidrome watches

docker-compose.yml (the bridges)
/mnt/data/slskd-downloads:/app/downloads
/mnt/media/music:/app/Transfer

CONTAINER (what SoulSync sees)
/app/downloads/ ← same files as /mnt/data/slskd-downloads/
/app/Transfer/ ← same files as /mnt/media/music/

SoulSync Settings (what you enter in the app)
Download Path: /app/downloads
Transfer Path: /app/Transfer

The #1 Mistake: Not Configuring App Settings

Many users set up their docker-compose volumes correctly but never open SoulSync Settings to configure the paths. The app defaults may not match your volume mounts. You must go to Settings → Download Settings and verify that:

⚠️
"I set up my docker-compose but nothing transfers" — this almost always means the app settings weren't configured. Docker-compose makes the folders accessible. The app settings tell SoulSync where to look. Both are required.

The #2 Mistake: Download Path Doesn't Match slskd

The Download Path in SoulSync must point to the exact same physical folder where slskd saves its completed downloads. If they don't match, SoulSync can't find the files and post-processing fails silently.

ℹ️
Both SoulSync and slskd must see the same download folder.

slskd container:
• slskd downloads to /downloads/complete inside its own container
• slskd docker-compose: - /mnt/data/slskd-downloads:/downloads/complete

SoulSync container:
• SoulSync docker-compose: - /mnt/data/slskd-downloads:/app/downloads (same host folder!)
• SoulSync Setting: Download Path = /app/downloads

The key: both containers mount the same host folder (/mnt/data/slskd-downloads). The container-internal paths can be different — that's fine. What matters is they point to the same physical directory on your server.

The #3 Mistake: Using Host Paths in Settings

If you're running in Docker, the paths you enter in SoulSync's Settings page must be container-side paths (the right side of the : in your volume mount), not host paths (the left side). SoulSync runs inside the container and can only see its own filesystem.

Setting ValueResult
/app/downloadsCorrect — this is the container-side path (right side of :)
/app/TransferCorrect — this is the container-side path (right side of :)
/mnt/data/slskd-downloadsWrong — this is the host path (left side of :), doesn't exist inside the container
/mnt/musicWrong — host path, the container can't see this
./downloadsWrong — relative path, use the full container path /app/downloads

Transfer Path = Media Server's Music Folder

Your Transfer Path must ultimately point to the same physical directory your media server monitors. This is how new music appears in Plex/Jellyfin/Navidrome.

💡
Example with Plex:

• Plex monitors /mnt/media/music on the host
• SoulSync docker-compose: - /mnt/media/music:/app/Transfer:rw
• SoulSync Settings: Transfer Path = /app/Transfer

Result: SoulSync writes to /app/Transfer inside the container → appears at /mnt/media/music on the host → Plex sees it and adds it to your library.

Complete Docker Compose Example (slskd + SoulSync)

Here's a working example showing both slskd and SoulSync configured to share the same download folder:

📋
# docker-compose.yml
services:
  slskd:
    image: slskd/slskd:latest
    volumes:
      # slskd saves completed downloads here
      - /mnt/data/slskd-downloads:/downloads
      - /docker/slskd/config:/app

  soulsync:
    image: boulderbadgedad/soulsync:latest
    volumes:
      # SAME host folder as slskd — this is the key!
      - /mnt/data/slskd-downloads:/app/downloads

      # Your media server's music folder
      - /mnt/media/music:/app/Transfer:rw

      # Config, logs, staging, database
      - /docker/soulsync/config:/app/config
      - /docker/soulsync/logs:/app/logs
      - /docker/soulsync/staging:/app/Staging
      - soulsync_database:/app/data

# Then in SoulSync Settings:
# Download Path: /app/downloads
# Transfer Path: /app/Transfer
${docsImg('gs-docker.jpg', 'Docker compose configuration')}

Setup Checklist

Go through every item. If you miss any single one, the pipeline will break:

  1. slskd download folder is mounted in SoulSync's container — Both containers must mount the same host directory. The host paths (left side of :) must be identical.
  2. Media server's music folder is mounted as Transfer — Mount the folder your Plex/Jellyfin/Navidrome monitors as /app/Transfer with :rw permissions.
  3. SoulSync Settings are configured — Open Settings → Download Settings. Set Download Path to /app/downloads and Transfer Path to /app/Transfer (or whatever container paths you used on the right side of :).
  4. slskd URL and API key are set — In Settings → Soulseek, enter your slskd URL (e.g., http://slskd:5030 or http://host.docker.internal:5030) and API key.
  5. PUID/PGID match your host user — Run id on your host. Set those values in docker-compose environment variables. Both slskd and SoulSync should use the same PUID/PGID.
  6. Test with one track — Download a single track. Watch the logs. If it downloads but doesn't transfer, the paths are wrong.

Permissions

If paths are correct but files still won't transfer, it's usually a permissions issue. SoulSync needs read + write access to all three folders.

Verifying Your Setup

Run these commands to confirm everything is wired up correctly:

  1. Verify downloads are visible: docker exec soulsync-webui ls -la /app/downloads — you should see slskd's downloaded files here. If empty or "No such file or directory", your volume mount is wrong.
  2. Verify Transfer is writable: docker exec soulsync-webui touch /app/Transfer/test.txt && echo "OK" — then check that test.txt appears in your media server's music folder on the host. Clean up after: rm /mnt/media/music/test.txt
  3. Verify permissions: docker exec soulsync-webui id — the uid and gid should match your PUID/PGID values.
  4. Verify app settings: Open SoulSync Settings → Download Settings. Confirm the Download Path and Transfer Path show container paths (like /app/downloads), not host paths.
  5. Test a single download: Search for a track, download it, and watch the logs. Enable DEBUG logging in Settings for full detail. Check logs/app.log for any path errors.

Troubleshooting

SymptomLikely CauseFix
Files download but never transferApp settings not configured — docker-compose volumes are set but SoulSync Settings still have defaults or wrong pathsOpen Settings → Download Settings and set Download Path + Transfer Path to your container-side mount paths.
Post-processing log is emptySoulSync can't find the downloaded file at the expected path — the Download Path in Settings doesn't match where slskd actually saves files inside the containerRun docker exec soulsync-webui ls /app/downloads to see what's actually there. The Download Path in Settings must match this path exactly.
Same tracks downloading multiple timesPost-processing fails so SoulSync thinks the track was never downloaded successfully. On resume, it tries again.Fix the folder paths first. Once post-processing works, files move to Transfer and SoulSync knows they exist.
Files not renamed properlyPost-processing isn't running (path mismatch) or file organization is disabled in SettingsVerify File Organization is enabled in Settings → Processing & Organization. Fix Download Path first.
Permission denied in logsContainer user can't write to the Transfer folder on the hostSet PUID/PGID to match the host user that owns the music folder. Run chmod -R 755 on the Transfer host folder.
Media server doesn't see new filesTransfer Path doesn't map to the folder your media server monitorsEnsure the host path in your SoulSync volume mount (/mnt/media/music:/app/Transfer) is the same folder Plex/Jellyfin/Navidrome watches.
slskd downloads work fine on their own but not through SoulSyncslskd's download folder and SoulSync's Download Path point to different physical locationsBoth containers must mount the same host directory. Check the left side of : in both docker-compose volume entries — they must match.
💡
Still stuck? Enable DEBUG logging in Settings, download a single track, and check logs/app.log. The post-processing log will show exactly where the file pipeline breaks — whether it's a path not found, permission denied, or verification failure. If the post-processing log is empty, the issue is almost certainly a path mismatch (SoulSync never found the file to process).

Docker & Deployment

SoulSync runs in Docker with the following environment variables:

VariableDefaultDescription
DATABASE_PATH./databaseDirectory where the SQLite database is stored. Mount a volume here to persist data across container restarts.
SOULSYNC_CONFIG_PATH./configDirectory where config.json and the encryption key are stored. Mount a volume here to persist settings.
SOULSYNC_COMMIT_SHA(auto)Baked in at Docker build time. Used for update detection — compares against GitHub's latest commit.

Key Volume Mounts

Your docker-compose volumes section must include these mappings. The left side is your host path, the right side is where SoulSync sees it inside the container:

MountContainer PathWhat Goes Here
slskd downloads/app/downloadsMust be the same physical folder slskd writes completed downloads to. Both containers mount the same host directory.
Music library/app/TransferYour media server's monitored music folder. Add :rw to ensure write access.
Staging/app/Staging(Optional) For the Import feature — drop files here to import them.
Config/app/configStores config.json and encryption key. Persists settings across restarts.
Logs/app/logsApplication logs including app.log and post-processing.log.
Database/app/dataMust use a named volume (not a host path). Host path mounts can cause database corruption.
⚠️
slskd + SoulSync shared downloads: If slskd runs in a separate container, both containers must mount the same host directory for downloads. A common issue is slskd writing to a path that SoulSync can't read because the volume mounts don't align. Both containers must see the same files. See the Folder Setup section above for detailed examples.
⚠️
Database volume: Always use a named volume for the database (soulsync_database:/app/data), never a host path mount. Host path mounts can cause SQLite corruption, especially on networked file systems or when permissions don't align.

Podman / Rootless Docker: SoulSync supports Podman rootless (keep-id) and rootless Docker setups. The entrypoint handles permission alignment automatically.

Config migration: When upgrading from older versions, SoulSync automatically migrates settings from config.json to the database on first startup. No manual migration is needed.

` }, { id: 'workflows', title: 'Quick Start Workflows', icon: '/static/help.jpg', children: [ { id: 'wf-first', title: 'What Should I Do First?' }, { id: 'wf-download', title: 'How to: Download an Album' }, { id: 'wf-sync', title: 'How to: Sync a Spotify Playlist' }, { id: 'wf-auto', title: 'How to: Set Up Auto-Downloads' }, { id: 'wf-import', title: 'How to: Import Existing Music' }, { id: 'wf-media', title: 'How to: Connect Your Media Server' } ], content: () => `

What Should I Do First?

SoulSync can do a lot, but you don't need to learn everything at once. Here are the 6 essential workflows that cover 90% of what most users need. Start with whichever one matches your goal, and explore the rest later.

🎵
Download an Album
5 steps

Search for any album, pick your tracks, and download in FLAC or MP3 with full metadata.

View Guide →
🔄
Sync a Spotify Playlist
4 steps

Import your Spotify playlists and download every track to your local library.

View Guide →
🤖
Set Up Auto-Downloads
4 steps

Follow your favorite artists and automatically download their new releases.

View Guide →
📥
Import Existing Music
5 steps

Bring your existing music files into SoulSync with proper tags and organization.

View Guide →
📺
Connect Your Media Server
3 steps

Link Plex, Jellyfin, or Navidrome so downloads appear in your library automatically.

View Guide →
🏁
First Things After Setup
5 steps

Once connected, do these 5 things to get the most out of SoulSync right away.

See below ↓

First Things After Setup

  1. Download one album — Verify your folder paths and post-processing work end-to-end
  2. Run a Database Update — Dashboard → Database Updater → Full Refresh to import your existing media server library
  3. Add 5–10 artists to your Watchlist — This seeds the discovery pool for recommendations
  4. Check the Automations page — Enable the system automations you want (auto-process wishlist, auto-scan watchlist, auto-backup)
  5. Explore the Discover page — Once your watchlist has artists, recommendations and playlists appear here

How to: Download an Album

Goal: Find an album and download it to your library with full metadata, cover art, and proper file organization.

Prerequisites: At least one download source connected (Soulseek, YouTube, Tidal, or Qobuz). Download and Transfer paths configured.

  1. Open Search — Click the Search page in the sidebar (make sure Enhanced Search is active)
  2. Type the album name — Results appear in a categorized dropdown: Artists, Albums, Singles & EPs, Tracks
  3. Click the album result — The download modal opens showing cover art, tracklist, and album details
  4. Select tracks — All tracks are selected by default. Uncheck any you don't want
  5. Click Download — SoulSync searches for each track, downloads, tags, and organizes the files automatically
${docsImg('wf-download-album.gif', 'Downloading an album')}

Result: Tracks appear in your Transfer folder as Artist/Album/01 - Title.flac and your media server is notified to scan.

💡
If a track fails to download, click the retry icon or use the candidate selector to pick an alternative source file from a different user.

How to: Sync a Spotify Playlist

Goal: Import a Spotify playlist and download all its tracks to your local library.

  1. Go to the Sync page — Click Sync in the sidebar
  2. Click Refresh — Your Spotify playlists load automatically (or paste a playlist URL directly)
  3. Click Sync on a playlist — This adds all missing tracks to your wishlist
  4. Wait for auto-processing — The wishlist processor runs every 30 minutes and downloads queued tracks. Or click "Process Wishlist" in Automations to start immediately
${docsImg('wf-sync-playlist.gif', 'Syncing a Spotify playlist')}
💡
Use the "Download Missing" button on any playlist to see exactly which tracks are missing and download them all at once.

How to: Set Up Auto-Downloads

Goal: Automatically download new releases from your favorite artists without manual intervention.

  1. Add artists to your Watchlist — Search for artists on the Artists page and click the Watch button on each one
  2. Go to Automations — The built-in "Auto-Scan Watchlist" automation checks for new releases every 24 hours
  3. Enable "Auto-Process Wishlist" — This automation picks up new releases found by the scan and downloads them every 30 minutes
  4. Done! — New releases from watched artists are automatically found, queued, downloaded, tagged, and added to your library
${docsImg('wf-auto-downloads.gif', 'Setting up auto-downloads')}
💡
Customize per-artist settings (click the gear icon on a watched artist) to control which release types are included: Albums, EPs, Singles, Live, Remixes, etc.

How to: Import Existing Music

Goal: Bring music files you already have into SoulSync with proper metadata and organization.

  1. Place files in your staging folder — Put album folders (e.g., Artist - Album/) in the Staging path configured in Settings
  2. Go to the Import page — SoulSync detects the files and suggests album matches
  3. Search for the correct album — If the auto-suggestion is wrong, search Spotify/iTunes for the right album
  4. Match tracks — Drag-and-drop files onto the correct track slots, or click Auto-Match
  5. Click Confirm — Files are tagged with official metadata, organized, and moved to your library
${docsImg('wf-import-music.gif', 'Importing music')}
💡
For loose singles (not in album folders), use the Singles tab on the Import page.

How to: Connect Your Media Server

Goal: Link your media server so downloaded music automatically appears in your library and can be streamed via the built-in player.

  1. Go to Settings — Scroll to the Media Server section
  2. Enter your server details — URL and credentials for Plex (URL + Token), Jellyfin (URL + API Key), or Navidrome (URL + Username + Password). Select your music library from the dropdown
  3. Click Test Connection — Verify the connection is working. A green checkmark confirms success
${docsImg('wf-media-server.gif', 'Connecting media server')}
💡
Make sure your Transfer Path points to the same folder your media server monitors. This is how new downloads automatically appear in your library.
` }, { id: 'dashboard', title: 'Dashboard', icon: '/static/dashboard.jpg', children: [ { id: 'dash-overview', title: 'Overview & Stats' }, { id: 'dash-workers', title: 'Enrichment Workers' }, { id: 'dash-tools', title: 'Tool Cards' }, { id: 'dash-retag', title: 'Retag Tool' }, { id: 'dash-backup', title: 'Backup Manager' }, { id: 'dash-repair', title: 'Repair & Maintenance' }, { id: 'dash-activity', title: 'Activity Feed' } ], content: () => `

Overview & Stats

The dashboard is your command center. At the top you'll see service status indicators for Spotify, your media server, and Soulseek — showing connected/disconnected state at a glance. Below that, stat cards display your library totals: artists, albums, tracks, and total library size.

Stats update in real-time via WebSocket — no page refresh needed.

${docsImg('dash-overview.jpg', 'Dashboard overview')}

Enrichment Workers

The header bar contains enrichment worker icons for each metadata service. Hover over any icon to see its current status, what item it's processing, and progress counts (e.g., "142/500 matched").

Workers run automatically in the background, enriching your library with metadata from:

Spotify

Artist genres, follower counts, images, album release dates, track preview URLs

MusicBrainz

MBIDs for artists, albums, and tracks — enables accurate cross-referencing

Deezer

Deezer IDs, genres, album metadata

AudioDB

Artist descriptions, artist art, album info

iTunes

iTunes/Apple Music IDs, preview links

Last.fm

Listener/play counts, bios, tags, similar artists for every artist/album/track

Genius

Lyrics, descriptions, alternate names, song artwork

Tidal

Tidal IDs, artist images, album labels, explicit flags, ISRCs

Qobuz

Qobuz IDs, artist images, album labels, genres, explicit flags

${docsImg('dash-workers.jpg', 'Enrichment workers status')}
ℹ️
Workers retry "not found" items every 30 days and errored items every 7 days. You can pause/resume any worker from the dashboard.

Rate Limit Protection: Workers include smart rate limiting for all APIs. If Spotify returns a rate limit with a Retry-After greater than 60 seconds, the app seamlessly switches to iTunes/Apple Music — an amber indicator appears in the sidebar, searches automatically use Apple Music, and the enrichment worker pauses. When the ban expires, everything recovers automatically. No action needed from the user.

Tool Cards

The dashboard features several tool cards for library maintenance:

ToolWhat It Does
Database UpdaterRefreshes your library by scanning your media server. Choose incremental (new only) or full refresh.
Metadata UpdaterTriggers all 9 enrichment workers to re-check your library against all connected services.
Quality ScannerScans library for tracks below your quality preferences. Shows how many meet standards and finds replacements.
Duplicate CleanerIdentifies and removes duplicate tracks from your library, freeing up disk space.
Discovery PoolView and fix matched/failed discovery results across all mirrored playlists.
Retag ToolBatch retag downloaded files with correct album metadata from Spotify/iTunes.
Backup ManagerCreate, download, restore, and delete database backups. Rolling cleanup keeps the 5 most recent.
${docsImg('dash-tools.jpg', 'Dashboard tool cards')}
💡
Each tool card has a help button (?) that opens detailed instructions for that specific tool.

Retag Tool

The Retag Tool lets you fix incorrect metadata tags on files already in your library. This is useful when files were downloaded with wrong or incomplete tags.

  1. Open the Retag Tool card on the Dashboard
  2. Select an artist and album from the dropdown filters
  3. The tool displays all tracks in the album with their current file tags alongside the correct metadata from Spotify or iTunes
  4. Review the tag differences — mismatches are highlighted
  5. Click Retag to write the corrected metadata to the audio files
${docsImg('dash-retag.jpg', 'Retag tool interface')}

The retag operation writes title, artist, album artist, album, track number, disc number, year, and genre. Cover art can optionally be re-embedded.

Backup Manager

The Backup Manager protects your SoulSync database (all library data, watchlists, playlists, automations, and settings).

${docsImg('dash-backup.jpg', 'Backup manager')}

The system automation Auto-Backup Database creates a backup every 3 days automatically. You can adjust the interval in Automations.

Repair & Maintenance

Additional maintenance tools accessible from the dashboard:

Activity Feed

The activity feed at the bottom of the dashboard shows recent system events: downloads completed, syncs started, settings changed, automation runs, and errors. Events appear in real-time via WebSocket.

Events include: downloads started/completed/failed, playlist syncs, watchlist scans, automation runs, enrichment worker progress, settings changes, and system errors. The feed shows the 10 most recent events and updates in real-time via WebSocket. Older events are available in the application logs.

` }, { id: 'sync', title: 'Playlist Sync', icon: '/static/sync.jpg', children: [ { id: 'sync-overview', title: 'Overview' }, { id: 'sync-spotify', title: 'Spotify Playlists' }, { id: 'sync-spotify-public', title: 'Spotify Public Links' }, { id: 'sync-youtube', title: 'YouTube Playlists' }, { id: 'sync-tidal', title: 'Tidal Playlists' }, { id: 'sync-deezer', title: 'Deezer Playlists' }, { id: 'sync-listenbrainz', title: 'ListenBrainz' }, { id: 'sync-beatport', title: 'Beatport' }, { id: 'sync-import-file', title: 'Import from File' }, { id: 'sync-mirrored', title: 'Mirrored Playlists' }, { id: 'sync-history', title: 'Sync History' }, { id: 'sync-m3u', title: 'M3U Export' }, { id: 'sync-discovery', title: 'Discovery Pipeline' } ], content: () => `

Overview

The Sync page lets you import playlists from Spotify, YouTube, Tidal, and Beatport. Once imported, playlists are mirrored — they persist in your SoulSync instance and can be refreshed, discovered, and synced to your wishlist for downloading.

${docsImg('sync-overview.jpg', 'Playlist sync page')}

Spotify Playlists

If Spotify is connected, click Refresh to load all your Spotify playlists. Each playlist shows its cover art, track count, and sync status.

For each playlist you can:

${docsImg('sync-spotify.jpg', 'Spotify playlists loaded')}
💡
Spotify-sourced playlists are auto-discovered at confidence 1.0 during refresh — no separate discovery step needed.

YouTube Playlists

Paste a YouTube playlist URL into the input field and click Parse Playlist. SoulSync extracts the track list and attempts to match each track to official Spotify/iTunes metadata.

${docsImg('sync-youtube.jpg', 'YouTube playlist import')}
⚠️
YouTube tracks often have non-standard titles (e.g., "Artist - Song (Official Video)"). The discovery pipeline handles this, but some manual fixes may be needed for edge cases.

Tidal Playlists

Requires Tidal authentication in Settings. Once connected, refresh to load your Tidal playlists. You can also select Tidal download quality: HQ (320kbps), HiFi (FLAC 16-bit), or HiFi Plus (up to 24-bit).

ListenBrainz

If ListenBrainz is configured in Settings, the Sync page includes a ListenBrainz tab for browsing and importing playlists from your ListenBrainz account:

ListenBrainz tracks are matched against Spotify/iTunes using a 4-strategy search: direct match, swapped artist/title, album-based lookup, and extended fuzzy search. Discovered tracks can be synced to your library like any other playlist.

Beatport

The Beatport tab provides deep integration with electronic music content across three views:

Browse — Featured content organized into sections:

Genre Browser — Browse 12+ electronic music genres (House, Techno, Drum & Bass, Trance, etc.) with per-genre views: Top 10 tracks, staff picks, hype rankings, latest releases, and new charts.

Charts — Top 100 and Hype charts with full track listings. Each track can be manually matched against Spotify for metadata, then synced and downloaded.

${docsImg('sync-beatport.jpg', 'Beatport genre browser')}
ℹ️
Beatport data is cached with a configurable TTL. The system automation Refresh Beatport Cache runs every 24 hours to keep content fresh.

Spotify Public Links

Sync Spotify playlists and albums without OAuth credentials. Paste any public Spotify playlist or album URL and SoulSync will load the tracks for download. Useful when you don't want to connect a Spotify account or want to sync from someone else's public playlist.

Deezer Playlists

Import Deezer playlists by URL. Paste a Deezer playlist URL, click Load Playlist, and SoulSync parses the tracks for discovery and download. Tracks go through the same discovery pipeline as YouTube and Tidal playlists.

Import from File

Import track lists from CSV, TSV, or plain text files. Drag and drop a file or click to browse. SoulSync parses the file, lets you preview and map columns, then creates a mirrored playlist for discovery and download.

Sync History

View a log of all sync operations. The Sync History button in the page header opens a modal showing every playlist sync, album download, and wishlist processing operation with timestamps, track counts, and completion status.

Mirrored Playlists

Every parsed playlist from any source is automatically mirrored. The Mirrored tab shows all saved playlists with source-branded cards, live discovery status, and download progress.

${docsImg('sync-mirror.jpg', 'Mirrored playlist cards')}

M3U Export

Export any mirrored playlist as an M3U file for use in external media players or media servers. Enable M3U export in Settings and use the export button on any playlist card.

M3U files reference the actual file paths in your library, so they work with any M3U-compatible player.

Auto-Save — When enabled in Settings, M3U files are automatically regenerated every time a playlist is synced or updated. Manual Export — The export button on any playlist modal creates an M3U file on demand, even when auto-save is disabled.

Discovery Pipeline

For non-Spotify playlists (YouTube, Tidal), tracks need to be discovered before syncing. Discovery matches raw titles to official Spotify/iTunes metadata using fuzzy matching with a 0.7 confidence threshold.

  1. Import a playlist (YouTube or Tidal)
  2. Click Discover on the playlist card (or automate with the "Discover Playlist" action)
  3. SoulSync matches each track to official metadata — results are cached globally
  4. Sync the playlist — only discovered tracks are included; unmatched tracks are skipped
💡
Chain automations for hands-free operation: Refresh Playlist → Playlist Changed → Discover → Discovery Complete → Sync
` }, { id: 'search', title: 'Music Downloads', icon: '/static/search.jpg', children: [ { id: 'search-enhanced', title: 'Enhanced Search' }, { id: 'search-basic', title: 'Basic Search' }, { id: 'search-sources', title: 'Download Sources' }, { id: 'search-downloading', title: 'Downloading Music' }, { id: 'search-postprocess', title: 'Post-Processing Pipeline' }, { id: 'search-quality', title: 'Quality Profiles' }, { id: 'search-manager', title: 'Download Manager' } ], content: () => `

Enhanced Search

The default search mode. Type an artist, album, or track name and results appear in a categorized dropdown: In Your Library, Artists, Albums, Singles & EPs, and Tracks. Results come from your primary metadata source (Spotify by default).

${docsImg('dl-enhanced-search.jpg', 'Enhanced search results')}

Basic Search

Toggle to Basic Search mode for direct Soulseek queries. This shows raw search results with detailed info: format, bitrate, quality score, file size, uploader name, upload speed, and availability.

Filters let you narrow results by type (Albums/Singles), format (FLAC/MP3/OGG/AAC/WMA), and sort by relevance, quality, size, bitrate, duration, or uploader speed.

${docsImg('dl-basic-search.jpg', 'Basic Soulseek search')}

Download Sources

SoulSync supports multiple download sources, configurable in Settings → Download Settings:

SourceDescriptionBest For
SoulseekP2P network via slskd — largest selection of lossless and rare musicFLAC, rare tracks, DJ sets
YouTubeYouTube audio extraction via yt-dlpLive performances, remixes, tracks not on Soulseek
TidalTidal HiFi streaming rip (requires auth)Guaranteed quality, official releases
QobuzQobuz Hi-Res streaming rip (requires auth)Audiophile quality, up to 24-bit/192kHz
HiFiFree lossless downloads via community-run API instancesNo account needed, good FLAC availability
DeezerDeezer streaming rip via ARL token (FLAC/MP3)Large catalog, easy setup, FLAC with HiFi sub
HybridTries your primary source first, then automatically falls back to alternatesBest overall success rate
💡
Hybrid mode is recommended for most users. It tries your primary source first, then falls back through your configured priority order. All six sources (Soulseek, YouTube, Tidal, Qobuz, HiFi, Deezer) can be ordered via drag-and-drop in Settings.

YouTube settings include cookies browser selection (for bot detection bypass), download delay (seconds between requests), and minimum confidence threshold for title matching.

Downloading Music

When you select an album or track to download, a modal appears with:

Downloads can be started from multiple places: Enhanced Search results, artist discography, Download Missing modal, wishlist auto-processing, and playlist sync.

Download Candidate Selection: If a download fails or no suitable source is found, you can view the cached search candidates and manually pick an alternative file from a different user. This lets you recover failed downloads without restarting the entire search.

${docsImg('dl-candidates.jpg', 'Download candidate selection')}

Post-Processing Pipeline

After a file is downloaded, it goes through an automatic pipeline before appearing in your library:

  1. AcoustID Fingerprint Verification — If AcoustID is configured, the downloaded file is fingerprinted and compared against the expected track. Title and artist are fuzzy-matched (title ≥ 70% similarity, artist ≥ 60%). Files that fail verification are quarantined instead of added to your library. Note: AcoustID is skipped for streaming sources (Tidal, Qobuz, Deezer, HiFi) since files are downloaded by exact track ID. However, streaming search results are still verified by artist and title matching before download to prevent wrong-track matches (e.g. same title, different artist).
  2. Metadata Tagging — The file is tagged with official metadata: title, artist, album artist, album, track number, disc number, year, genre, and composer. Tags are written using Mutagen (supports MP3, FLAC, OGG, M4A).
  3. Cover Art Embedding — Album artwork is downloaded from the metadata source and embedded directly into the audio file.
  4. File Organization — The file is renamed and moved to your transfer path following customizable templates. Separate templates for albums, singles, and playlists are configured in Settings. Available variables include $artist, $album, $title, $track, $year, $quality, and $albumtype (resolves to Album, Single, EP, or Compilation). For multi-disc albums, a Disc N/ subfolder is automatically created when the album has more than one disc (or use $disc in your template for manual control).
  5. Lyrics (LRC) — Synced lyrics are fetched from the LRClib API and saved as .lrc sidecar files alongside the audio file. Compatible media players (foobar2000, MusicBee, Plex, etc.) will display time-synced lyrics automatically. Falls back to plain-text lyrics if synced versions aren't available.
  6. Lossy Copy — If enabled in settings, a lower-bitrate copy is created alongside the original (useful for mobile device syncing).
  7. Media Server Scan — Your media server (Plex/Jellyfin) is notified to scan for the new file. Navidrome auto-detects changes.
${docsImg('dl-post-processing.jpg', 'Post-processing pipeline complete')}
ℹ️
Quarantine: Files that fail AcoustID verification are moved to a quarantine folder instead of your library. You can review quarantined files and manually approve or delete them. The automation engine can trigger notifications when files are quarantined.

Quality Profiles

Configure your quality preferences in Settings → Quality Profile. Quick presets:

PresetPriority
AudiophileFLAC first, then MP3 320
BalancedMP3 320 first, then FLAC, then MP3 256
Space SaverMP3 256 first, then MP3 192

Each format has configurable bitrate ranges and a priority order. Enable Fallback to accept any quality when preferred formats aren't available.

💡
Streaming source quality: Tidal, Qobuz, HiFi, and Deezer each have their own quality dropdown in Settings. By default, if your preferred quality isn't available for a track, the source falls back to the next lower tier (e.g. FLAC → AAC 320). Disable Allow quality fallback next to the quality dropdown to enforce strict quality — the source will skip tracks it can't deliver at your chosen quality, and the orchestrator will try the next source in your priority list.
${docsImg('dl-quality-profiles.jpg', 'Quality profile settings')}

Download Manager

Toggle the download manager panel (right sidebar) to see all active and completed downloads. Each download shows real-time progress: track name, format, speed, ETA, and a cancel button. Use Clear Completed to clean up finished items.

` }, { id: 'discover', title: 'Discover Artists', icon: '/static/discover.jpg', children: [ { id: 'disc-hero', title: 'Featured Artists' }, { id: 'disc-playlists', title: 'Discovery Playlists' }, { id: 'disc-build', title: 'Build Custom Playlist' }, { id: 'disc-seasonal', title: 'Seasonal & Curated' }, { id: 'disc-timemachine', title: 'Time Machine' } ], content: () => `

Featured Artists

The hero slider showcases recommended artists based on your watchlist. Each slide shows the artist's image, name, popularity score, genres, and similarity context. Use the arrows or dots to navigate, or click:

${docsImg('disc-hero.jpg', 'Featured artist hero slider')}

Discovery & Personalized Playlists

SoulSync generates playlists from two sources: your discovery pool (50 similar artists refreshed during watchlist scans) and your library listening data:

PlaylistSourceDescription
Popular PicksDiscovery PoolTop tracks from discovery pool artists
Hidden GemsDiscovery PoolRare and deeper cuts from pool artists
Discovery ShuffleDiscovery PoolRandomized mix across all pool artists
Recently AddedLibraryTracks most recently added to your collection
Top TracksLibraryYour most-played or highest-rated tracks
Forgotten FavoritesLibraryTracks you haven't listened to in a while
Decade MixesLibraryTracks grouped by release decade (70s, 80s, 90s, etc.)
Daily MixesLibraryAuto-generated daily playlists based on your taste profile
Familiar FavoritesLibraryWell-known tracks from artists you follow
${docsImg('disc-playlists.jpg', 'Discovery playlist cards')}

Each playlist can be played in the media player, downloaded, or synced to your media server.

Genre Browser — Filter discovery pool content by specific genres. Browse available genres and view top tracks within each genre category.

${docsImg('disc-genre-browser.jpg', 'Genre browser')}

ListenBrainz Playlists — If ListenBrainz is configured, the Discover page also shows personalized playlists generated from your listening history: Created For You, Your Playlists, and Collaborative playlists.

Build Custom Playlist

Search for 1–5 artists, select them, and click Generate to create a custom playlist from their catalogs. You can then download or sync the generated playlist.

${docsImg('disc-build-playlist.jpg', 'Build custom playlist')}

Seasonal & Curated Content

The Discover page includes auto-generated seasonal content based on the current time of year, plus two curated sections:

Both can be synced to your media server with live progress tracking.

Time Machine

Browse discovery pool content by decade — tabs from the 1950s through the 2020s. Each decade pulls top tracks from pool artists active in that era.

${docsImg('disc-time-machine.jpg', 'Time Machine decade browser')}
` }, { id: 'artists', title: 'Artists & Watchlist', icon: '/static/artists.jpg', children: [ { id: 'art-search', title: 'Artist Search' }, { id: 'art-detail', title: 'Artist Detail & Discography' }, { id: 'art-watchlist', title: 'Watchlist' }, { id: 'art-scanning', title: 'New Release Scanning' }, { id: 'art-wishlist', title: 'Wishlist' }, { id: 'art-settings', title: 'Watchlist Settings' } ], content: () => `

Artist Detail & Discography

The artist detail page shows a full discography organized by category:

At the top, View on buttons link to the artist on each matched external service (Spotify, Apple Music, MusicBrainz, Deezer, AudioDB, Last.fm, Genius, Tidal, Qobuz). Service badges on artist cards also indicate which services have matched this artist.

Similar Artists appear as clickable bubbles below the discography for further exploration and discovery.

${docsImg('art-detail.jpg', 'Artist detail page')}

Watchlist

The watchlist tracks artists you want to follow for new releases. When SoulSync scans your watchlist, it checks each artist's discography and adds any new tracks to your wishlist for downloading.

${docsImg('art-watchlist.jpg', 'Watchlist page')}

New Release Scanning

Click Scan for New Releases or let the system automation handle it (runs every 24 hours). The scan shows a live activity panel with:

${docsImg('art-scan.jpg', 'New release scan panel')}

Wishlist

The wishlist is the queue of tracks waiting to be downloaded. Tracks are added to the wishlist from multiple sources:

Auto-Processing: The system automation runs every 30 minutes, picking up wishlist items and attempting to download them from your configured source. Processing alternates between album and singles cycles — one run processes albums, the next run processes singles. If one category is empty, it automatically switches to the other. Failed items are retried with increasing backoff.

Manual Processing: Use the Process Wishlist automation action to trigger processing on demand. Options include processing all items, albums only, or singles only.

Cleanup: The Cleanup Wishlist action removes duplicates (same track added multiple times) and items you already own in your library.

ℹ️
Each wishlist item tracks its source (watchlist scan, playlist sync, manual), number of retry attempts, last error message, and status (pending, downloading, failed, complete).
${docsImg('art-wishlist.jpg', 'Wishlist queue')}

Watchlist Settings

Per-Artist Settings — Click the config icon on any watched artist to customize what release types to include: Albums, EPs, Singles, Live versions, Remixes, Acoustic versions, Compilations.

Global Settings — Override all per-artist settings at once. Enable Global Override, select which types to include, and all watchlist scans will follow the global config.

` }, { id: 'automations', title: 'Automations', icon: '/static/automation.jpg', children: [ { id: 'auto-overview', title: 'Overview' }, { id: 'auto-builder', title: 'Builder' }, { id: 'auto-triggers', title: 'All Triggers' }, { id: 'auto-actions', title: 'All Actions' }, { id: 'auto-then', title: 'Then-Actions & Signals' }, { id: 'auto-history', title: 'Execution History' }, { id: 'auto-system', title: 'System Automations' } ], content: () => `

Overview

Automations let you schedule tasks and react to events with a visual WHEN → DO → THEN builder. Create custom workflows like "When a download completes, update the database, then notify me on Discord."

Each automation card shows its trigger/action flow, last run time, next scheduled run (with countdown), and a Run Now button for instant execution.

${docsImg('auto-overview.jpg', 'Automations page')}

Builder

Click + New Automation to open the builder. Drag or click blocks from the sidebar into the three slots:

  1. WHEN (Trigger) — What event starts this automation
  2. DO (Action) — What task to perform. Optionally add a delay (minutes) before executing.
  3. THEN (Post-Action) — Up to 3 notification or signal actions after the DO completes

Add Conditions to filter when the automation runs. Match modes: All (AND) or Any (OR). Operators: contains, equals, starts_with, not_contains.

${docsImg('auto-builder.jpg', 'Automation builder')}

All Triggers

TriggerDescription
ScheduleRun on a timer interval (minutes/hours/days)
Daily TimeRun every day at a specific time
Weekly TimeRun on specific weekdays at a set time
App StartedFires when SoulSync starts up
Track DownloadedWhen a track finishes downloading
Download FailedWhen a track permanently fails to download
Download QuarantinedWhen AcoustID verification rejects a download
Batch CompleteWhen an album/playlist batch download finishes
Wishlist Item AddedWhen a track is added to the wishlist
Wishlist Processing DoneWhen auto-wishlist processing finishes
New Release FoundWhen a watchlist scan finds new music
Watchlist Scan DoneWhen the full watchlist scan completes
Artist Watched/UnwatchedWhen an artist is added to or removed from the watchlist
Playlist SyncedWhen a playlist sync completes
Playlist ChangedWhen a mirrored playlist detects changes from the source
Discovery CompleteWhen playlist track discovery finishes
Library Scan DoneWhen a media library scan finishes
Database UpdatedWhen a library database refresh finishes
Quality Scan DoneWhen quality scan finishes (with counts of quality met vs low quality)
Duplicate Scan DoneWhen duplicate cleaner finishes (with files scanned, duplicates found, space freed)
Import CompleteWhen an album/track import finishes
Playlist MirroredWhen a new playlist is mirrored for the first time
Signal ReceivedCustom signal fired by another automation

All Actions

ActionDescription
Process WishlistRetry failed downloads (all, albums only, or singles only)
Scan WatchlistCheck watched artists for new releases
Cleanup WishlistRemove duplicate/owned tracks from wishlist
Scan LibraryTrigger a media server library scan
Update DatabaseRefresh library database (incremental or full)
Deep Scan LibraryFull library comparison without losing enrichment data
Refresh Mirrored PlaylistRe-fetch playlist tracks from the source
Sync PlaylistSync a specific playlist to your media server
Discover PlaylistFind official metadata for playlist tracks
Run Duplicate CleanerScan for and remove duplicate files
Run Quality ScanScan for low-quality audio files
Clear QuarantineDelete all quarantined files
Update DiscoveryRefresh the discovery artist pool
Backup DatabaseCreate a timestamped database backup
Refresh Beatport CacheScrape Beatport homepage and warm the data cache
Clean Search HistoryRemove old searches from Soulseek (keeps 50 most recent)
Clean Completed DownloadsClear completed downloads and empty directories from the download folder
Full CleanupClear quarantine, download queue, staging folder, and search history in one sweep
Notify OnlyNo action — just trigger notifications

Then-Actions & Signals

After the DO action completes, up to 3 THEN actions run:

All notification messages support variable substitution: {name}, {status}, {time}, {run_count}, and context-specific variables from the action result.

Test Notifications: Use the test button next to any notification then-action to send a test message before saving. This verifies your webhook URL, API key, or bot token is working correctly.

ℹ️
Signal chaining lets you build multi-step workflows. Safety features include cycle detection (DFS), a 5-level chain depth limit, and a 10-second cooldown between signal fires.

Execution History

Each automation card shows its last run time and run count. For scheduled automations, a countdown timer shows when the next run will occur.

Use the Run Now button on any automation card to execute it immediately, regardless of its schedule. The result (success/failure) updates in real-time on the card. Running automations display a glow effect on their card.

Stall detection: If an automation action runs for more than 2 hours without completing, it is automatically flagged as stalled and terminated to prevent resource leaks.

The Dashboard activity feed also logs every automation execution with timestamps, so you can review the full history of what ran and when.

${docsImg('auto-history.jpg', 'Automation execution history')}

System Automations

SoulSync ships with 10 built-in automations that handle routine maintenance. You can enable/disable them and modify their configs, but you can't delete them or rename them.

AutomationSchedule
Auto-Process WishlistEvery 30 minutes
Auto-Scan WatchlistEvery 24 hours
Auto-Scan After DownloadsOn batch_complete event
Auto-Update DatabaseOn library_scan_completed event
Refresh Beatport CacheEvery 24 hours
Clean Search HistoryEvery 1 hour
Clean Completed DownloadsEvery 5 minutes
Auto-Deep Scan LibraryEvery 7 days
Auto-Backup DatabaseEvery 3 days
Full CleanupEvery 12 hours
${docsImg('auto-system.jpg', 'System automations')}
` }, { id: 'library', title: 'Music Library', icon: '/static/library.jpg', children: [ { id: 'lib-standard', title: 'Standard View' }, { id: 'lib-enhanced', title: 'Enhanced Library Manager' }, { id: 'lib-matching', title: 'Service Matching' }, { id: 'lib-tags', title: 'Write Tags to File' }, { id: 'lib-bulk', title: 'Bulk Operations' }, { id: 'lib-missing', title: 'Download Missing Tracks' } ], content: () => `

Standard View

The Library page shows all artists in your collection as cards with images, album/track counts, and service badges (Spotify, MusicBrainz, Deezer, AudioDB, iTunes, Last.fm, Genius, Tidal, Qobuz) indicating which services have matched this artist.

Use the search bar, alphabet navigation (A–Z, #), and watchlist filter (All/Watched/Unwatched) to browse. Click any artist card to view their discography.

The artist detail page shows albums, EPs, and singles as cards with completion percentages. Filter by category, content type (live/compilations/featured), or status (owned/missing). At the top, View on buttons link to the artist on each matched external service.

${docsImg('lib-standard.jpg', 'Library artist grid')}

Enhanced Library Manager

Toggle Enhanced on any artist's detail page to access the professional library management tool. This view is admin-only — non-admin profiles see the Standard view only.

${docsImg('lib-enhanced.jpg', 'Enhanced Library Manager')}

Service Matching

In the Enhanced view, each artist, album, and track shows match status chips for all 9 services. Click any chip to manually search and link the correct external ID. Run per-service enrichment from the dropdown to pull in metadata from a specific source.

Matched services show as clickable badges linking to the entity on that service's website.

Write Tags to File

Sync your database metadata to actual audio file tags:

  1. Click the pencil icon on any track, or use Write All Tags for an entire album, or select tracks and use the bulk bar's Write Tags
  2. A tag preview modal shows a diff table: current file tags vs. database values
  3. Optionally enable Embed cover art and Sync to server
  4. Click Write Tags to apply changes to the file
${docsImg('lib-tags.jpg', 'Tag preview modal')}

Supports MP3, FLAC, OGG, and M4A via Mutagen. After writing, optional server sync pushes metadata to Plex (per-track update), Jellyfin (library scan), or Navidrome (auto-detects).

Bulk Operations

Select tracks across multiple albums using the checkboxes. The bulk bar appears showing the selection count with actions:

${docsImg('lib-bulk.jpg', 'Bulk operations bar')}

Download Missing Tracks

From any album card showing missing tracks, click Download Missing to open a modal listing all tracks not in your library. Select tracks, choose a download source, and start the download. Progress is tracked per-track with status indicators.

Multi-Disc Albums: Albums with multiple discs are handled automatically. Tracks are organized into Disc N/ subfolders within the album directory, preventing track number collisions (e.g., Disc 1 Track 1 vs Disc 2 Track 1). The disc structure is detected from Spotify or iTunes metadata.

` }, { id: 'import', title: 'Import Music', icon: '/static/import.jpg', children: [ { id: 'imp-setup', title: 'Staging Setup' }, { id: 'imp-workflow', title: 'Import Workflow' }, { id: 'imp-singles', title: 'Singles Import' }, { id: 'imp-matching', title: 'Track Matching' }, { id: 'imp-textfile', title: 'Import from Text File' } ], content: () => `

Staging Setup

Set your staging folder path in Settings → Download Settings. Place audio files you want to import into this folder. SoulSync scans the folder and detects albums from the file structure.

Place albums in subfolders (e.g., Artist - Album/) and loose singles at the root level.

The import page header shows the total files in staging and their combined size.

${docsImg('imp-staging.jpg', 'Import staging page')}
💡
Files not showing up? Check that your staging folder path is correct in Settings and that the folder has read permissions. Docker users: make sure the staging volume mount is configured in your docker-compose.yml.

Import Workflow

  1. Place audio files in your staging folder
  2. Navigate to the Import page — SoulSync detects and suggests album matches
  3. Search for the correct album on Spotify/iTunes if the suggestion is wrong
  4. Match tracks — Drag-and-drop staged files onto album track slots, or let auto-match attempt it
  5. Review the match and click Confirm to import — files are tagged, organized, and added to your library
${docsImg('imp-matching.jpg', 'Track matching interface')}

Singles Import

The Singles tab handles individual tracks that aren't part of an album structure. Files in the staging root (not in subfolders) appear here. Search for the correct track on Spotify/iTunes, confirm the match, and import. The file is tagged, renamed, and placed in your library.

Track Matching

The import matching system compares staged files against official album track lists:

After matching, the import process tags files with the official metadata (title, artist, album, track number, cover art) and moves them to your transfer path following the standard file organization template.

Import from Text File

Import track lists from CSV, TSV, or TXT files. Upload a file with columns for artist, album, and track title:

  1. Click Import from File and select your text file
  2. Choose the separator (comma, tab, or pipe)
  3. Map columns to the correct fields (Artist, Album, Track)
  4. SoulSync searches for each track on Spotify/iTunes and adds matches to your wishlist for downloading
${docsImg('imp-textfile.jpg', 'Text file import')}
` }, { id: 'player', title: 'Media Player', icon: '/static/library.jpg', children: [ { id: 'player-controls', title: 'Playback Controls' }, { id: 'player-streaming', title: 'Streaming & Sources' }, { id: 'player-queue', title: 'Queue & Smart Radio' }, { id: 'player-shortcuts', title: 'Keyboard Shortcuts' } ], content: () => `

Playback Controls

The sidebar media player is always visible when a track is loaded. It shows album art, track info, a seekable progress bar, and playback controls (play/pause, previous, next, volume, repeat, shuffle).

${docsImg('player-sidebar.jpg', 'Sidebar media player')}

Click the sidebar player to open the Now Playing modal — a full-screen experience with large album art, ambient glow (dominant color from cover art), a frequency-driven audio visualizer, and expanded controls.

${docsImg('player-nowplaying.jpg', 'Now Playing modal')}

Streaming & Sources

The media player streams audio directly from your connected media server — no local file access needed:

The browser auto-detects which audio formats it can play. Album art, track metadata, and ambient colors are all pulled from your server in real-time.

Queue & Smart Radio

Add tracks to the queue from the Enhanced Library Manager or download results. Manage the queue in the Now Playing modal: reorder, remove individual tracks, or clear all.

Smart Radio mode (toggle in queue header) automatically adds similar tracks when the queue runs out, based on genre, mood, style, and artist similarity. Playback continues seamlessly.

Repeat modes: Off → Repeat All (loop queue) → Repeat One. Shuffle randomizes the next track from the remaining queue.

${docsImg('player-queue.jpg', 'Queue panel')}

Keyboard Shortcuts

KeyAction
SpacePlay / Pause
Seek forward / Next track
Seek backward / Previous track
Volume up
Volume down
MMute / Unmute
EscapeClose Now Playing modal

Media Session API — SoulSync integrates with your OS media controls (lock screen, system tray) for play/pause, next/previous, and seek.

` }, { id: 'settings', title: 'Settings', icon: '/static/settings.jpg', children: [ { id: 'set-services', title: 'Service Credentials' }, { id: 'set-media', title: 'Media Server Setup' }, { id: 'set-download', title: 'Download Settings' }, { id: 'set-processing', title: 'Processing & Organization' }, { id: 'set-quality', title: 'Quality Profiles' }, { id: 'set-other', title: 'Other Settings' } ], content: () => `

Service Credentials

Configure credentials for each external service. All fields are saved to your local database and encrypted at rest using a Fernet key generated on first launch. Nothing is sent to external servers except during actual API calls. Each service has a Test Connection button to verify your credentials are working.

Media Server Setup

Connect your media server so SoulSync can scan your library, trigger updates, stream audio, and sync metadata:

ServerCredentialsSetup Details
PlexURL + TokenAfter connecting, select which Music Library to use from the dropdown. SoulSync scans this library for your collection and triggers scans after downloads.
JellyfinURL + API KeySelect the User and Music Library to target. SoulSync uses the Jellyfin API for library scans and can stream audio directly.
NavidromeURL + Username + PasswordSelect the Music Folder to monitor. Navidrome auto-detects new files, so SoulSync doesn't need to trigger scans — just place files in the right folder.
${docsImg('settings-media-server.jpg', 'Media server setup')}

The media player streams audio directly from your connected server — tracks play through your Plex, Jellyfin, or Navidrome instance without needing local file access.

💡
Navidrome users: If artist images are broken after upgrading, use the Fix Navidrome URLs tool in Settings to convert old image URL formats to the correct Subsonic API format.

Download Settings

${docsImg('settings-downloads.jpg', 'Download settings')}
⚠️
Docker users: Always use container-side paths in these settings (e.g., /app/downloads, /app/Transfer). Never use host paths like /mnt/music — the container can't access those. Your docker-compose volumes section is where host paths are mapped to container paths. See Getting Started → Folder Setup for a complete walkthrough.

Processing & Organization

Control how downloaded files are processed and organized:

${docsImg('settings-processing.jpg', 'Processing settings')}

Quality Profiles

Set your preferred audio quality with presets (Audiophile/Balanced/Space Saver) or custom configuration per format. Each format has a configurable bitrate range and priority order. Enable Fallback to accept any quality when nothing matches.

Other Settings

` }, { id: 'profiles', title: 'Multi-Profile', icon: '/static/settings.jpg', children: [ { id: 'prof-overview', title: 'How Profiles Work' }, { id: 'prof-manage', title: 'Managing Profiles' }, { id: 'prof-permissions', title: 'Permissions & Page Access' }, { id: 'prof-home', title: 'Home Page & Preferences' } ], content: () => `

How Profiles Work

SoulSync supports Netflix-style multiple profiles for shared households. Each profile gets its own:

Shared across all profiles: Music library (files and metadata), service credentials, settings, and automations.

💡
Single-user installs see no changes until a second profile is created. The first profile is automatically the admin.

Managing Profiles

PINs are 4-6 digits. If you forget your PIN, the admin can reset it from Manage Profiles. The admin PIN protects settings and destructive operations when multiple profiles exist.

${docsImg('profiles-picker.jpg', 'Profile picker')} ${docsImg('profiles-create.jpg', 'Profile creation')}

Permissions & Page Access

Admins can control what each profile has access to. When creating or editing a non-admin profile:

${docsImg('profiles-permissions.jpg', 'Profile permissions')}

If the admin removes a page that was set as a user's home page, the home page automatically resets. Navigation guards prevent users from accessing restricted pages even via direct URL or browser history.

ℹ️
Existing profiles created before permissions were added have full access to all pages by default. The admin must explicitly restrict access per profile.

Home Page & Preferences

Each user can choose which page they land on when they log in:

` }, { id: 'api', title: 'REST API', icon: '/static/settings.jpg', children: [ { id: 'api-auth', title: 'Authentication' }, { id: 'api-system', title: 'System' }, { id: 'api-library', title: 'Library' }, { id: 'api-search', title: 'Search' }, { id: 'api-downloads', title: 'Downloads' }, { id: 'api-playlists', title: 'Playlists' }, { id: 'api-watchlist', title: 'Watchlist' }, { id: 'api-wishlist', title: 'Wishlist' }, { id: 'api-discover', title: 'Discover' }, { id: 'api-profiles', title: 'Profiles' }, { id: 'api-settings', title: 'Settings & Keys' }, { id: 'api-retag', title: 'Retag' }, { id: 'api-cache', title: 'Cache' }, { id: 'api-listenbrainz', title: 'ListenBrainz' }, { id: 'api-websocket', title: 'WebSocket Events' } ], content: () => { // --- API Endpoint definitions --- const E = (method, path, desc, params, bodyFields, example) => ({ method, path, desc, params, bodyFields, example }); const P = (name, type, req, desc, def) => ({ name, type, required: req, desc, default: def }); const apiGroups = [ { id: 'api-system', title: 'System', desc: 'Server status, activity feed, and combined statistics.', endpoints: [ E('GET', '/system/status', 'Server uptime and service connectivity', [], null, { response: '{\n "success": true,\n "data": {\n "uptime": "4h 32m 10s",\n "uptime_seconds": 16330,\n "services": {\n "spotify": true,\n "soulseek": true,\n "hydrabase": false\n }\n }\n}' }), E('GET', '/system/stats', 'Combined library and download statistics', [], null, { response: '{\n "success": true,\n "data": {\n "library": { "artists": 342, "albums": 1205, "tracks": 14832 },\n "database": { "size_mb": 45.2, "last_update": "2026-03-13T08:00:00Z" },\n "downloads": { "active": 3 }\n }\n}' }), E('GET', '/system/activity', 'Recent activity feed', [], null, { response: '{\n "success": true,\n "data": {\n "activities": [\n { "timestamp": "2026-03-13T10:30:00Z", "type": "download", "message": "Downloaded: Radiohead - Karma Police" }\n ]\n }\n}' }) ] }, { id: 'api-library', title: 'Library', desc: 'Browse artists, albums, tracks, genres, and library statistics. Most endpoints support ?fields= for field selection and pagination via ?page= and ?limit=.', endpoints: [ E('GET', '/library/artists', 'List library artists with search, letter filter, and pagination', [ P('search', 'string', false, 'Substring filter on artist name', '""'), P('letter', 'string', false, 'Filter by first letter, or "all"', '"all"'), P('watchlist', 'string', false, 'Filter by watchlist status', '"all"'), P('page', 'int', false, 'Page number', '1'), P('limit', 'int', false, 'Results per page (max 200)', '50'), P('fields', 'string', false, 'Comma-separated field names to include', 'all') ], null, { response: '{\n "success": true,\n "data": {\n "artists": [\n {\n "id": 1,\n "name": "Radiohead",\n "thumb_url": "https://...",\n "banner_url": "https://...",\n "genres": ["alternative rock", "art rock"],\n "summary": "English rock band...",\n "style": "Alternative/Indie", "mood": "Melancholy",\n "label": "XL Recordings",\n "musicbrainz_id": "a74b1b7f-...",\n "spotify_artist_id": "4Z8W4fKeB5YxbusRsdQVPb",\n "itunes_artist_id": "657515",\n "deezer_id": "399", "tidal_id": "3746724",\n "qobuz_id": "61592", "genius_id": "604",\n "lastfm_listeners": 5832451,\n "lastfm_playcount": 328456789,\n "genius_url": "https://genius.com/artists/Radiohead",\n "album_count": 9, "track_count": 101,\n "...": "all 50+ fields included"\n }\n ]\n },\n "pagination": {\n "page": 1, "limit": 50, "total": 342, "total_pages": 7,\n "has_next": true, "has_prev": false\n }\n}' }), E('GET', '/library/artists/{artist_id}', 'Get a single artist with all metadata and album list', [ P('fields', 'string', false, 'Comma-separated fields', 'all') ], null, { response: '{\n "success": true,\n "data": {\n "artist": {\n "id": 1, "name": "Radiohead",\n "thumb_url": "https://...", "banner_url": "https://...",\n "genres": ["alternative rock", "art rock"],\n "summary": "English rock band formed in 1985...",\n "style": "Alternative/Indie", "mood": "Melancholy",\n "label": "XL Recordings",\n "server_source": "plex",\n "created_at": "2026-01-15T10:00:00Z",\n "updated_at": "2026-03-13T08:00:00Z",\n "musicbrainz_id": "a74b1b7f-71a3-4b73-8c51-5c1f3a71c9e8",\n "spotify_artist_id": "4Z8W4fKeB5YxbusRsdQVPb",\n "itunes_artist_id": "657515",\n "audiodb_id": "111239",\n "deezer_id": "399",\n "tidal_id": "3746724",\n "qobuz_id": "61592",\n "genius_id": "604",\n "musicbrainz_match_status": "matched",\n "spotify_match_status": "matched",\n "itunes_match_status": "matched",\n "audiodb_match_status": "matched",\n "deezer_match_status": "matched",\n "lastfm_match_status": "matched",\n "genius_match_status": "matched",\n "tidal_match_status": "matched",\n "qobuz_match_status": "matched",\n "musicbrainz_last_attempted": "2026-03-10T08:00:00Z",\n "spotify_last_attempted": "2026-03-10T08:00:00Z",\n "itunes_last_attempted": "2026-03-10T08:00:00Z",\n "audiodb_last_attempted": "2026-03-10T08:00:00Z",\n "deezer_last_attempted": "2026-03-10T08:00:00Z",\n "lastfm_last_attempted": "2026-03-10T08:00:00Z",\n "genius_last_attempted": "2026-03-10T08:00:00Z",\n "tidal_last_attempted": "2026-03-10T08:00:00Z",\n "qobuz_last_attempted": "2026-03-10T08:00:00Z",\n "lastfm_listeners": 5832451,\n "lastfm_playcount": 328456789,\n "lastfm_tags": "alternative, rock, experimental",\n "lastfm_similar": "Thom Yorke, Atoms for Peace, Portishead",\n "lastfm_bio": "Radiohead are an English rock band...",\n "lastfm_url": "https://www.last.fm/music/Radiohead",\n "genius_description": "Radiohead is an English rock band...",\n "genius_alt_names": "On a Friday",\n "genius_url": "https://genius.com/artists/Radiohead",\n "album_count": 9, "track_count": 101\n },\n "albums": [\n { "id": 10, "title": "OK Computer", "year": 1997, "track_count": 12, "record_type": "album" }\n ]\n }\n}' }), E('GET', '/library/artists/{artist_id}/albums', 'List albums for an artist', [ P('fields', 'string', false, 'Comma-separated fields', 'all') ], null, { response: '{\n "success": true,\n "data": {\n "albums": [\n {\n "id": 10, "artist_id": 1, "title": "OK Computer", "year": 1997,\n "thumb_url": "https://...", "track_count": 12, "duration": 3214000,\n "genres": ["alternative rock"],\n "style": "Art Rock", "mood": "Atmospheric",\n "label": "Parlophone", "record_type": "album", "explicit": false,\n "upc": "0724385522529", "copyright": "1997 Parlophone Records",\n "spotify_album_id": "6dVIqQ8qmQ5GBnJ9shOYGE",\n "tidal_id": "17914997", "qobuz_id": "0724385522529",\n "lastfm_listeners": 1543000, "lastfm_playcount": 89234567,\n "...": "all 45+ fields included"\n }\n ]\n }\n}' }), E('GET', '/library/albums', 'List or search albums with pagination', [ P('search', 'string', false, 'Substring filter on album title', '""'), P('artist_id', 'int', false, 'Filter by artist ID'), P('year', 'int', false, 'Filter by release year'), P('page', 'int', false, 'Page number', '1'), P('limit', 'int', false, 'Results per page (max 200)', '50'), P('fields', 'string', false, 'Comma-separated fields', 'all') ], null, { response: '{\n "success": true,\n "data": { "albums": [ { "id": 10, "title": "OK Computer", "year": 1997, "artist_id": 1 } ] },\n "pagination": { "page": 1, "limit": 50, "total": 1205, "total_pages": 25, "has_next": true, "has_prev": false }\n}' }), E('GET', '/library/albums/{album_id}', 'Get a single album with metadata and embedded tracks', [ P('fields', 'string', false, 'Comma-separated fields', 'all') ], null, { response: '{\n "success": true,\n "data": {\n "album": {\n "id": 10, "artist_id": 1, "title": "OK Computer", "year": 1997,\n "thumb_url": "https://...",\n "genres": ["alternative rock"],\n "track_count": 12, "duration": 3214000,\n "style": "Art Rock", "mood": "Atmospheric",\n "label": "Parlophone", "explicit": false, "record_type": "album",\n "server_source": "plex",\n "created_at": "2026-01-15T10:00:00Z",\n "updated_at": "2026-03-13T08:00:00Z",\n "upc": "0724385522529", "copyright": "1997 Parlophone Records",\n "musicbrainz_release_id": "b1a9c0e7-...",\n "spotify_album_id": "6dVIqQ8qmQ5GBnJ9shOYGE",\n "itunes_album_id": "1097861387",\n "audiodb_id": "2115888",\n "deezer_id": "6575789",\n "tidal_id": "17914997",\n "qobuz_id": "0724385522529",\n "musicbrainz_match_status": "matched",\n "spotify_match_status": "matched",\n "itunes_match_status": "matched",\n "audiodb_match_status": "matched",\n "deezer_match_status": "matched",\n "lastfm_match_status": "matched",\n "tidal_match_status": "matched",\n "qobuz_match_status": "matched",\n "musicbrainz_last_attempted": "2026-03-10T08:00:00Z",\n "spotify_last_attempted": "2026-03-10T08:00:00Z",\n "itunes_last_attempted": "2026-03-10T08:00:00Z",\n "audiodb_last_attempted": "2026-03-10T08:00:00Z",\n "deezer_last_attempted": "2026-03-10T08:00:00Z",\n "lastfm_last_attempted": "2026-03-10T08:00:00Z",\n "tidal_last_attempted": "2026-03-10T08:00:00Z",\n "qobuz_last_attempted": "2026-03-10T08:00:00Z",\n "lastfm_listeners": 1543000,\n "lastfm_playcount": 89234567,\n "lastfm_tags": "alternative, 90s, rock",\n "lastfm_wiki": "OK Computer is the third studio album...",\n "lastfm_url": "https://www.last.fm/music/Radiohead/OK+Computer"\n },\n "tracks": [\n { "id": 100, "title": "Airbag", "track_number": 1, "duration": 284000, "bitrate": 1411 }\n ]\n }\n}' }), E('GET', '/library/albums/{album_id}/tracks', 'List tracks in an album', [ P('fields', 'string', false, 'Comma-separated fields', 'all') ], null, { response: '{\n "success": true,\n "data": {\n "tracks": [\n {\n "id": 100, "album_id": 10, "artist_id": 1, "title": "Airbag",\n "track_number": 1, "duration": 284000,\n "file_path": "/music/Radiohead/OK Computer/01 Airbag.flac",\n "bitrate": 1411, "bpm": 120.5, "explicit": false,\n "isrc": "GBAYE9700106",\n "spotify_track_id": "6anwyDGQmsg45JKiVKpKGA",\n "tidal_id": "17914998", "genius_id": "1342",\n "lastfm_listeners": 892000, "lastfm_playcount": 4567890,\n "genius_url": "https://genius.com/Radiohead-airbag-lyrics",\n "...": "all 55+ fields included"\n }\n ]\n }\n}' }), E('GET', '/library/tracks/{track_id}', 'Get a single track with all metadata', [ P('fields', 'string', false, 'Comma-separated fields', 'all') ], null, { response: '{\n "success": true,\n "data": {\n "track": {\n "id": 100, "album_id": 10, "artist_id": 1, "title": "Airbag",\n "track_number": 1, "duration": 284000,\n "file_path": "/music/Radiohead/OK Computer/01 Airbag.flac",\n "bitrate": 1411, "bpm": 120.5, "explicit": false,\n "style": "Art Rock", "mood": "Atmospheric",\n "repair_status": null, "repair_last_checked": null,\n "server_source": "plex",\n "created_at": "2026-01-15T10:00:00Z",\n "updated_at": "2026-03-13T08:00:00Z",\n "isrc": "GBAYE9700106", "copyright": "1997 Parlophone Records",\n "musicbrainz_recording_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",\n "spotify_track_id": "6anwyDGQmsg45JKiVKpKGA",\n "itunes_track_id": "1097861700",\n "audiodb_id": null,\n "deezer_id": "72420132",\n "tidal_id": "17914998",\n "qobuz_id": "24517824",\n "genius_id": "1342",\n "musicbrainz_match_status": "matched",\n "spotify_match_status": "matched",\n "itunes_match_status": "matched",\n "audiodb_match_status": "not_found",\n "deezer_match_status": "matched",\n "lastfm_match_status": "matched",\n "genius_match_status": "matched",\n "tidal_match_status": "matched",\n "qobuz_match_status": "matched",\n "musicbrainz_last_attempted": "2026-03-10T08:00:00Z",\n "spotify_last_attempted": "2026-03-10T08:00:00Z",\n "itunes_last_attempted": "2026-03-10T08:00:00Z",\n "audiodb_last_attempted": "2026-03-10T08:00:00Z",\n "deezer_last_attempted": "2026-03-10T08:00:00Z",\n "lastfm_last_attempted": "2026-03-10T08:00:00Z",\n "genius_last_attempted": "2026-03-10T08:00:00Z",\n "tidal_last_attempted": "2026-03-10T08:00:00Z",\n "qobuz_last_attempted": "2026-03-10T08:00:00Z",\n "lastfm_listeners": 892000,\n "lastfm_playcount": 4567890,\n "lastfm_tags": "alternative rock, radiohead",\n "lastfm_url": "https://www.last.fm/music/Radiohead/_/Airbag",\n "genius_lyrics": "In the next world war, in a jackknifed juggernaut...",\n "genius_description": "The opening track of OK Computer...",\n "genius_url": "https://genius.com/Radiohead-airbag-lyrics"\n }\n }\n}' }), E('GET', '/library/tracks', 'Search tracks by title and/or artist', [ P('title', 'string', false, 'Track title to search (at least one of title/artist required)', '""'), P('artist', 'string', false, 'Artist name to search', '""'), P('limit', 'int', false, 'Max results (max 200)', '50'), P('fields', 'string', false, 'Comma-separated fields', 'all') ], null, { response: '{\n "success": true,\n "data": {\n "tracks": [\n { "id": 100, "title": "Airbag", "artist_name": "Radiohead", "album_title": "OK Computer" }\n ]\n }\n}' }), E('GET', '/library/genres', 'List all genres with occurrence counts', [ P('source', 'string', false, '"artists" or "albums"', '"artists"') ], null, { response: '{\n "success": true,\n "data": {\n "genres": [ { "name": "alternative rock", "count": 45 }, { "name": "electronic", "count": 38 } ],\n "source": "artists"\n }\n}' }), E('GET', '/library/recently-added', 'Get recently added content', [ P('type', 'string', false, '"albums", "artists", or "tracks"', '"albums"'), P('limit', 'int', false, 'Max items (max 200)', '50'), P('fields', 'string', false, 'Comma-separated fields', 'all') ], null, { response: '{\n "success": true,\n "data": {\n "items": [ { "id": 10, "title": "OK Computer", "year": 1997, "created_at": "2026-03-12T10:00:00Z" } ],\n "type": "albums"\n }\n}' }), E('GET', '/library/lookup', 'Look up a library entity by external provider ID', [ P('type', 'string', true, '"artist", "album", or "track"'), P('provider', 'string', true, '"spotify", "musicbrainz", "itunes", "deezer", "audiodb", "tidal", "qobuz", or "genius"'), P('id', 'string', true, 'The external ID value'), P('fields', 'string', false, 'Comma-separated fields', 'all') ], null, { response: '{\n "success": true,\n "data": {\n "artist": { "id": 1, "name": "Radiohead", "spotify_artist_id": "4Z8W4fKeB5YxbusRsdQVPb" }\n }\n}' }), E('GET', '/library/stats', 'Get library statistics', [], null, { response: '{\n "success": true,\n "data": {\n "artists": 342,\n "albums": 1205,\n "tracks": 14832,\n "database_size_mb": 45.2,\n "last_update": "2026-03-13T08:00:00Z"\n }\n}' }) ] }, { id: 'api-search', title: 'Search', desc: 'Search external music sources (Spotify, iTunes, Hydrabase). All search endpoints use POST with a JSON body.', endpoints: [ E('POST', '/search/tracks', 'Search for tracks across music sources', [], [ P('query', 'string', true, 'Search query'), P('source', 'string', false, '"spotify", "itunes", or "auto"', '"auto"'), P('limit', 'int', false, 'Max results (1-50)', '20') ], { request: '{\n "query": "Karma Police",\n "source": "auto",\n "limit": 10\n}', response: '{\n "success": true,\n "data": {\n "tracks": [\n {\n "id": "3SVAN3BRByDmHOhKyIDxfC",\n "name": "Karma Police",\n "artists": ["Radiohead"],\n "album": "OK Computer",\n "duration_ms": 264066,\n "popularity": 78,\n "image_url": "https://...",\n "release_date": "1997-05-28"\n }\n ],\n "source": "spotify"\n }\n}' }), E('POST', '/search/albums', 'Search for albums', [], [ P('query', 'string', true, 'Search query'), P('limit', 'int', false, 'Max results (1-50)', '20') ], { request: '{\n "query": "OK Computer",\n "limit": 5\n}', response: '{\n "success": true,\n "data": {\n "albums": [\n {\n "id": "6dVIqQ8qmQ5GBnJ9shOYGE",\n "name": "OK Computer",\n "artists": ["Radiohead"],\n "release_date": "1997-05-28",\n "total_tracks": 12,\n "album_type": "album",\n "image_url": "https://..."\n }\n ],\n "source": "spotify"\n }\n}' }), E('POST', '/search/artists', 'Search for artists', [], [ P('query', 'string', true, 'Search query'), P('limit', 'int', false, 'Max results (1-50)', '20') ], { request: '{\n "query": "Radiohead",\n "limit": 5\n}', response: '{\n "success": true,\n "data": {\n "artists": [\n {\n "id": "4Z8W4fKeB5YxbusRsdQVPb",\n "name": "Radiohead",\n "popularity": 79,\n "genres": ["alternative rock", "art rock"],\n "followers": 8500000,\n "image_url": "https://..."\n }\n ],\n "source": "spotify"\n }\n}' }) ] }, { id: 'api-downloads', title: 'Downloads', desc: 'List active downloads, cancel individual or all downloads.', endpoints: [ E('GET', '/downloads', 'List active and recent download tasks', [], null, { response: '{\n "success": true,\n "data": {\n "downloads": [\n {\n "id": "abc123",\n "status": "downloading",\n "track_name": "Karma Police",\n "artist_name": "Radiohead",\n "album_name": "OK Computer",\n "username": "slsk_user42",\n "progress": 67,\n "size": 34500000,\n "batch_id": null,\n "error": null\n }\n ]\n }\n}' }), E('POST', '/downloads/{download_id}/cancel', 'Cancel a specific download', [], [ P('username', 'string', true, 'Soulseek username for the transfer') ], { request: '{\n "username": "slsk_user42"\n}', response: '{\n "success": true,\n "data": { "message": "Download cancelled." }\n}' }), E('POST', '/downloads/cancel-all', 'Cancel all active downloads and clear completed', [], null, { response: '{\n "success": true,\n "data": { "message": "All downloads cancelled and cleared." }\n}' }) ] }, { id: 'api-playlists', title: 'Playlists', desc: 'List and inspect playlists from Spotify or Tidal, and trigger playlist sync.', endpoints: [ E('GET', '/playlists', 'List user playlists from Spotify or Tidal', [ P('source', 'string', false, '"spotify" or "tidal"', '"spotify"') ], null, { response: '{\n "success": true,\n "data": {\n "playlists": [\n {\n "id": "37i9dQZF1DXcBWIGoYBM5M",\n "name": "Today\'s Top Hits",\n "owner": "spotify",\n "track_count": 50,\n "image_url": "https://..."\n }\n ],\n "source": "spotify"\n }\n}' }), E('GET', '/playlists/{playlist_id}', 'Get playlist details with tracks', [ P('source', 'string', false, 'Only "spotify" is supported', '"spotify"') ], null, { response: '{\n "success": true,\n "data": {\n "playlist": {\n "id": "37i9dQZF1DXcBWIGoYBM5M",\n "name": "Today\'s Top Hits",\n "owner": "spotify",\n "total_tracks": 50,\n "tracks": [\n {\n "id": "3SVAN3BRByDmHOhKyIDxfC",\n "name": "Karma Police",\n "artists": ["Radiohead"],\n "album": "OK Computer",\n "duration_ms": 264066,\n "image_url": "https://..."\n }\n ]\n },\n "source": "spotify"\n }\n}' }), E('POST', '/playlists/{playlist_id}/sync', 'Trigger playlist sync and download', [], [ P('playlist_name', 'string', true, 'Name of the playlist'), P('tracks', 'array', true, 'Array of track objects to sync') ], { request: '{\n "playlist_name": "My Playlist",\n "tracks": [\n { "id": "3SVAN3...", "name": "Karma Police", "artists": [{ "name": "Radiohead" }] }\n ]\n}', response: '{\n "success": true,\n "data": { "message": "Playlist sync started.", "playlist_id": "37i9dQZF1DXcBWIGoYBM5M" }\n}' }) ] }, { id: 'api-watchlist', title: 'Watchlist', desc: 'View, add, remove watched artists and trigger new release scans. Profile-scoped via X-Profile-Id header.', endpoints: [ E('GET', '/watchlist', 'List all watchlist artists for the current profile', [ P('fields', 'string', false, 'Comma-separated fields', 'all') ], null, { response: '{\n "success": true,\n "data": {\n "artists": [\n {\n "id": 1,\n "artist_name": "Radiohead",\n "spotify_artist_id": "4Z8W4fKeB5YxbusRsdQVPb",\n "image_url": "https://...",\n "date_added": "2026-01-15T10:00:00Z",\n "include_albums": true,\n "include_eps": true,\n "include_singles": true,\n "include_live": false,\n "include_remixes": false,\n "profile_id": 1\n }\n ]\n }\n}' }), E('POST', '/watchlist', 'Add an artist to the watchlist', [], [ P('artist_id', 'string', true, 'Spotify or iTunes artist ID'), P('artist_name', 'string', true, 'Artist display name') ], { request: '{\n "artist_id": "4Z8W4fKeB5YxbusRsdQVPb",\n "artist_name": "Radiohead"\n}', response: '{\n "success": true,\n "data": { "message": "Added Radiohead to watchlist." }\n}' }), E('DELETE', '/watchlist/{artist_id}', 'Remove an artist from the watchlist', [], null, { response: '{\n "success": true,\n "data": { "message": "Artist removed from watchlist." }\n}' }), E('PATCH', '/watchlist/{artist_id}', 'Update content type filters for a watchlist artist', [], [ P('include_albums', 'bool', false, 'Include albums'), P('include_eps', 'bool', false, 'Include EPs'), P('include_singles', 'bool', false, 'Include singles'), P('include_live', 'bool', false, 'Include live recordings'), P('include_remixes', 'bool', false, 'Include remixes'), P('include_acoustic', 'bool', false, 'Include acoustic versions'), P('include_compilations', 'bool', false, 'Include compilations') ], { request: '{\n "include_live": true,\n "include_remixes": false\n}', response: '{\n "success": true,\n "data": {\n "message": "Watchlist filters updated.",\n "updated": { "include_live": true, "include_remixes": false }\n }\n}' }), E('POST', '/watchlist/scan', 'Trigger a watchlist scan for new releases', [], null, { response: '{\n "success": true,\n "data": { "message": "Watchlist scan started." }\n}' }) ] }, { id: 'api-wishlist', title: 'Wishlist', desc: 'View, add, remove wishlist tracks and trigger download processing. Profile-scoped.', endpoints: [ E('GET', '/wishlist', 'List wishlist tracks with optional category filter', [ P('category', 'string', false, '"singles" or "albums"', 'all'), P('page', 'int', false, 'Page number', '1'), P('limit', 'int', false, 'Results per page (max 200)', '50'), P('fields', 'string', false, 'Comma-separated fields', 'all') ], null, { response: '{\n "success": true,\n "data": {\n "tracks": [\n {\n "id": 1,\n "spotify_track_id": "3SVAN3BRByDmHOhKyIDxfC",\n "track_name": "Karma Police",\n "artist_name": "Radiohead",\n "album_name": "OK Computer",\n "failure_reason": "No suitable source found",\n "retry_count": 2,\n "last_attempted": "2026-03-12T10:00:00Z",\n "date_added": "2026-03-10T08:00:00Z",\n "source_type": "playlist_sync"\n }\n ]\n },\n "pagination": { "page": 1, "limit": 50, "total": 12, "total_pages": 1, "has_next": false, "has_prev": false }\n}' }), E('POST', '/wishlist', 'Add a track to the wishlist', [], [ P('spotify_track_data', 'object', true, 'Full Spotify track data object'), P('failure_reason', 'string', false, 'Reason for adding', '"Added via API"'), P('source_type', 'string', false, 'Source identifier', '"api"') ], { request: '{\n "spotify_track_data": {\n "id": "3SVAN3BRByDmHOhKyIDxfC",\n "name": "Karma Police",\n "artists": [{ "name": "Radiohead" }],\n "album": { "name": "OK Computer", "album_type": "album" }\n },\n "source_type": "api"\n}', response: '{\n "success": true,\n "data": { "message": "Track added to wishlist." }\n}' }), E('DELETE', '/wishlist/{track_id}', 'Remove a track by Spotify track ID', [], null, { response: '{\n "success": true,\n "data": { "message": "Track removed from wishlist." }\n}' }), E('POST', '/wishlist/process', 'Trigger wishlist download processing', [], null, { response: '{\n "success": true,\n "data": { "message": "Wishlist processing started." }\n}' }) ] }, { id: 'api-discover', title: 'Discover', desc: 'Browse the discovery pool, similar artists, recent releases, and bubble snapshots. Profile-scoped.', endpoints: [ E('GET', '/discover/pool', 'List discovery pool tracks with optional filters', [ P('new_releases_only', 'string', false, '"true" to filter new releases only', 'false'), P('source', 'string', false, '"spotify" or "itunes"', 'all'), P('page', 'int', false, 'Page number', '1'), P('limit', 'int', false, 'Max tracks (max 500)', '100'), P('fields', 'string', false, 'Comma-separated fields', 'all') ], null, { response: '{\n "success": true,\n "data": {\n "tracks": [\n {\n "id": 1,\n "spotify_track_id": "3SVAN3...",\n "track_name": "Karma Police",\n "artist_name": "Radiohead",\n "album_name": "OK Computer",\n "album_cover_url": "https://...",\n "duration_ms": 264066,\n "popularity": 78,\n "is_new_release": false,\n "source": "spotify"\n }\n ]\n },\n "pagination": { "page": 1, "limit": 100, "total": 850, "total_pages": 9, "has_next": true, "has_prev": false }\n}' }), E('GET', '/discover/similar-artists', 'List top similar artists from the watchlist', [ P('limit', 'int', false, 'Max artists (max 200)', '50'), P('fields', 'string', false, 'Comma-separated fields', 'all') ], null, { response: '{\n "success": true,\n "data": {\n "artists": [\n {\n "id": 1,\n "similar_artist_name": "Thom Yorke",\n "similar_artist_spotify_id": "2x9SpqnPi8rlE9pjHBwN5z",\n "similarity_rank": 1,\n "occurrence_count": 5\n }\n ]\n }\n}' }), E('GET', '/discover/recent-releases', 'List recent releases from watched artists', [ P('limit', 'int', false, 'Max releases (max 200)', '50'), P('fields', 'string', false, 'Comma-separated fields', 'all') ], null, { response: '{\n "success": true,\n "data": {\n "releases": [\n {\n "id": 1,\n "album_name": "A Moon Shaped Pool",\n "album_spotify_id": "2ix8vWvvSp2Yo7rKMiWpkg",\n "release_date": "2016-05-08",\n "album_cover_url": "https://...",\n "track_count": 11,\n "source": "spotify"\n }\n ]\n }\n}' }), E('GET', '/discover/pool/metadata', 'Get discovery pool metadata', [], null, { response: '{\n "success": true,\n "data": {\n "last_populated": "2026-03-12T10:00:00Z",\n "track_count": 850,\n "updated_at": "2026-03-12T10:00:00Z"\n }\n}' }), E('GET', '/discover/bubbles', 'List all bubble snapshots for the current profile', [], null, { response: '{\n "success": true,\n "data": {\n "snapshots": {\n "artist_bubbles": { "snapshot_data": [...], "updated_at": "..." },\n "search_bubbles": null,\n "discover_downloads": null\n }\n }\n}' }), E('GET', '/discover/bubbles/{snapshot_type}', 'Get a specific bubble snapshot (artist_bubbles, search_bubbles, discover_downloads)', [], null, { response: '{\n "success": true,\n "data": {\n "snapshot": { "snapshot_data": [...], "updated_at": "2026-03-12T10:00:00Z" }\n }\n}' }) ] }, { id: 'api-profiles', title: 'Profiles', desc: 'Manage multi-profile support. Create, update, delete profiles with PIN protection and page access control.', endpoints: [ E('GET', '/profiles', 'List all profiles', [], null, { response: '{\n "success": true,\n "data": {\n "profiles": [\n {\n "id": 1,\n "name": "Admin",\n "is_admin": 1,\n "avatar_color": "#6366f1",\n "avatar_url": null,\n "created_at": "2026-01-01T00:00:00Z"\n }\n ]\n }\n}' }), E('GET', '/profiles/{profile_id}', 'Get a single profile by ID', [], null, { response: '{\n "success": true,\n "data": {\n "profile": {\n "id": 1, "name": "Admin", "is_admin": 1,\n "avatar_color": "#6366f1", "avatar_url": null\n }\n }\n}' }), E('POST', '/profiles', 'Create a new profile', [], [ P('name', 'string', true, 'Profile display name'), P('avatar_color', 'string', false, 'Hex color for avatar', '"#6366f1"'), P('avatar_url', 'string', false, 'Custom avatar image URL'), P('is_admin', 'bool', false, 'Admin privileges', 'false'), P('pin', 'string', false, 'PIN for profile protection') ], { request: '{\n "name": "Family Room",\n "is_admin": false,\n "avatar_color": "#22c55e",\n "pin": "1234"\n}', response: '{\n "success": true,\n "data": {\n "profile": {\n "id": 3, "name": "Family Room", "is_admin": 0,\n "avatar_color": "#22c55e"\n }\n }\n}' }), E('PUT', '/profiles/{profile_id}', 'Update a profile', [], [ P('name', 'string', false, 'New display name'), P('avatar_color', 'string', false, 'Hex color'), P('avatar_url', 'string', false, 'Avatar image URL'), P('is_admin', 'bool', false, 'Admin privileges'), P('pin', 'string', false, 'New PIN (empty string clears PIN)') ], { request: '{\n "name": "Kids Room",\n "avatar_color": "#f59e0b"\n}', response: '{\n "success": true,\n "data": {\n "profile": { "id": 3, "name": "Kids Room", "avatar_color": "#f59e0b" }\n }\n}' }), E('DELETE', '/profiles/{profile_id}', 'Delete a profile (cannot delete profile 1)', [], null, { response: '{\n "success": true,\n "data": { "message": "Profile 3 deleted." }\n}' }) ] }, { id: 'api-settings', title: 'Settings & API Keys', desc: 'Read and update application settings. Manage API keys. Sensitive values are always redacted in GET responses.', endpoints: [ E('GET', '/settings', 'Get current settings (sensitive values redacted)', [], null, { response: '{\n "success": true,\n "data": {\n "settings": {\n "spotify": {\n "client_id": "***REDACTED***",\n "country": "US"\n },\n "download_path": "/music",\n "download_source": "hybrid",\n "ui_appearance": {\n "accent_preset": "green",\n "particles_enabled": true\n }\n }\n }\n}' }), E('PATCH', '/settings', 'Update settings (partial, dot-notation keys accepted)', [], [ P('{key}', 'any', true, 'One or more key-value pairs. The "api_keys" key is blocked.') ], { request: '{\n "spotify.country": "GB",\n "download_path": "/new/music/path"\n}', response: '{\n "success": true,\n "data": {\n "message": "Settings updated.",\n "updated_keys": ["spotify.country", "download_path"]\n }\n}' }), E('GET', '/api-keys', 'List all API keys (prefix and label only, never the full key)', [], null, { response: '{\n "success": true,\n "data": {\n "keys": [\n {\n "id": "a1b2c3d4-...",\n "label": "My Bot",\n "key_prefix": "sk_AbCdEfGh",\n "created_at": "2026-03-01T10:00:00Z",\n "last_used_at": "2026-03-13T09:15:00Z"\n }\n ]\n }\n}' }), E('POST', '/api-keys', 'Generate a new API key (raw key returned once)', [], [ P('label', 'string', false, 'Descriptive label for the key', '""') ], { request: '{\n "label": "Home Assistant"\n}', response: '{\n "success": true,\n "data": {\n "key": "sk_AbCdEfGhIjKlMnOpQrStUvWxYz123456789...",\n "id": "a1b2c3d4-...",\n "label": "Home Assistant",\n "key_prefix": "sk_AbCdEfGh",\n "created_at": "2026-03-13T10:00:00Z"\n }\n}' }), E('DELETE', '/api-keys/{key_id}', 'Revoke an API key by its UUID', [], null, { response: '{\n "success": true,\n "data": { "message": "API key revoked." }\n}' }), E('POST', '/api-keys/bootstrap', 'Generate the first API key when none exist (NO AUTH REQUIRED)', [], [ P('label', 'string', false, 'Label for the key', '"Default"') ], { request: '{\n "label": "My First Key"\n}', response: '{\n "success": true,\n "data": {\n "key": "sk_...",\n "id": "...",\n "label": "My First Key",\n "key_prefix": "sk_...",\n "created_at": "2026-03-13T10:00:00Z"\n }\n}' }) ] }, { id: 'api-retag', title: 'Retag', desc: 'View and manage the pending metadata correction queue.', endpoints: [ E('GET', '/retag/groups', 'List all retag groups with track counts', [], null, { response: '{\n "success": true,\n "data": {\n "groups": [\n {\n "id": 1,\n "original_artist": "Radiohed",\n "corrected_artist": "Radiohead",\n "track_count": 5,\n "created_at": "2026-03-12T10:00:00Z"\n }\n ]\n }\n}' }), E('GET', '/retag/groups/{group_id}', 'Get a retag group with its tracks', [], null, { response: '{\n "success": true,\n "data": {\n "group": { "id": 1, "original_artist": "Radiohed", "corrected_artist": "Radiohead" },\n "tracks": [\n { "id": 100, "title": "Airbag", "file_path": "/music/..." }\n ]\n }\n}' }), E('DELETE', '/retag/groups/{group_id}', 'Delete a retag group and its tracks', [], null, { response: '{\n "success": true,\n "data": { "message": "Retag group 1 deleted." }\n}' }), E('DELETE', '/retag/groups', 'Delete all retag groups and tracks', [], null, { response: '{\n "success": true,\n "data": { "message": "Cleared 5 retag groups." }\n}' }), E('GET', '/retag/stats', 'Get retag queue statistics', [], null, { response: '{\n "success": true,\n "data": {\n "total_groups": 5,\n "total_tracks": 23,\n "pending": 18,\n "completed": 5\n }\n}' }) ] }, { id: 'api-cache', title: 'Cache', desc: 'Browse MusicBrainz and discovery match caches for debugging and inspection.', endpoints: [ E('GET', '/cache/musicbrainz', 'List cached MusicBrainz lookups', [ P('entity_type', 'string', false, '"artist", "album", or "track"'), P('search', 'string', false, 'Filter by entity name'), P('page', 'int', false, 'Page number', '1'), P('limit', 'int', false, 'Results per page (max 200)', '50') ], null, { response: '{\n "success": true,\n "data": {\n "entries": [\n {\n "entity_type": "artist",\n "entity_name": "Radiohead",\n "musicbrainz_id": "a74b1b7f-...",\n "last_updated": "2026-03-12T10:00:00Z",\n "metadata_json": { "type": "Group", "country": "GB" }\n }\n ]\n },\n "pagination": { "page": 1, "limit": 50, "total": 342, "total_pages": 7, "has_next": true, "has_prev": false }\n}' }), E('GET', '/cache/musicbrainz/stats', 'Get MusicBrainz cache statistics', [], null, { response: '{\n "success": true,\n "data": {\n "total": 1024,\n "matched": 890,\n "unmatched": 134,\n "by_type": { "artist": 342, "album": 450, "track": 232 }\n }\n}' }), E('GET', '/cache/discovery-matches', 'List cached discovery provider matches', [ P('provider', 'string', false, '"spotify", "itunes", etc.'), P('search', 'string', false, 'Filter by title or artist'), P('page', 'int', false, 'Page number', '1'), P('limit', 'int', false, 'Results per page (max 200)', '50') ], null, { response: '{\n "success": true,\n "data": {\n "entries": [\n {\n "provider": "spotify",\n "original_title": "Karma Police",\n "original_artist": "Radiohead",\n "matched_data_json": { "id": "3SVAN3...", "confidence": 0.95 },\n "use_count": 3,\n "last_used_at": "2026-03-12T10:00:00Z"\n }\n ]\n },\n "pagination": { "page": 1, "limit": 50, "total": 5000, "total_pages": 100, "has_next": true, "has_prev": false }\n}' }), E('GET', '/cache/discovery-matches/stats', 'Get discovery match cache statistics', [], null, { response: '{\n "success": true,\n "data": {\n "total": 5000,\n "total_uses": 18500,\n "avg_confidence": 0.872,\n "by_provider": { "spotify": 3200, "itunes": 1800 }\n }\n}' }) ] }, { id: 'api-listenbrainz', title: 'ListenBrainz', desc: 'Browse cached ListenBrainz playlists and their tracks.', endpoints: [ E('GET', '/listenbrainz/playlists', 'List cached ListenBrainz playlists', [ P('type', 'string', false, 'Filter by playlist_type (e.g. "weekly-jams")'), P('page', 'int', false, 'Page number', '1'), P('limit', 'int', false, 'Results per page (max 200)', '50') ], null, { response: '{\n "success": true,\n "data": {\n "playlists": [\n {\n "id": 1,\n "playlist_mbid": "a1b2c3d4-...",\n "title": "Weekly Jams for user",\n "playlist_type": "weekly-jams",\n "track_count": 50,\n "created_at": "2026-03-10T00:00:00Z"\n }\n ]\n },\n "pagination": { "page": 1, "limit": 50, "total": 12, "total_pages": 1, "has_next": false, "has_prev": false }\n}' }), E('GET', '/listenbrainz/playlists/{playlist_id}', 'Get a ListenBrainz playlist with tracks (ID or MBID)', [], null, { response: '{\n "success": true,\n "data": {\n "playlist": {\n "id": 1,\n "playlist_mbid": "a1b2c3d4-...",\n "title": "Weekly Jams for user",\n "playlist_type": "weekly-jams"\n },\n "tracks": [\n {\n "id": 1,\n "position": 0,\n "recording_mbid": "e1f2g3h4-...",\n "title": "Karma Police",\n "artist": "Radiohead"\n }\n ]\n }\n}' }) ] } ]; // --- Build endpoint HTML --- function methodClass(m) { return m.toLowerCase(); } function buildParamsTable(params) { if (!params || !params.length) return ''; let html = '
Parameters
'; html += ''; params.forEach(p => { const req = p.required ? 'required' : 'optional'; const def = p.default !== undefined ? ' (default: ' + p.default + ')' : ''; html += ''; }); html += '
NameTypeRequiredDescription
' + p.name + '' + p.type + '' + req + '' + p.desc + def + '
'; return html; } function buildBodyTable(fields) { if (!fields || !fields.length) return ''; let html = '
Request Body (JSON)
'; html += ''; fields.forEach(p => { const req = p.required ? 'required' : 'optional'; const def = p.default !== undefined ? ' (default: ' + p.default + ')' : ''; html += ''; }); html += '
FieldTypeRequiredDescription
' + p.name + '' + p.type + '' + req + '' + p.desc + def + '
'; return html; } function buildExample(ex) { if (!ex) return ''; let html = ''; if (ex.request) { html += '
Example Request Body
'; html += '
' + escHtml(ex.request) + '
'; } if (ex.response) { html += '
Example Response
'; html += '
' + escHtml(ex.response) + '
'; } return html; } function escHtml(s) { return s.replace(/&/g,'&').replace(//g,'>'); } function buildTryIt(ep, idx) { const hasPathParam = ep.path.includes('{'); const pathParams = []; const pathMatch = ep.path.match(/\{([^}]+)\}/g); if (pathMatch) pathMatch.forEach(m => pathParams.push(m.replace(/[{}]/g, ''))); let html = '
Try It
'; // Path param inputs if (pathParams.length) { html += '
'; pathParams.forEach(pp => { html += '
'; }); html += '
'; } // Query param inputs for GET endpoints if (ep.params && ep.params.length && (ep.method === 'GET')) { html += '
'; ep.params.forEach(p => { if (p.name === 'fields') return; // skip fields param in try-it html += '
'; }); html += '
'; } html += '
'; // Body textarea for POST/PUT/PATCH/DELETE with body if (ep.bodyFields && ep.bodyFields.length) { const defaultBody = ep.example && ep.example.request ? ep.example.request : '{}'; html += ''; } html += ''; html += '
'; html += '
'; return html; } // Build all sections let sectionsHTML = ''; // Auth section (not a group) sectionsHTML += '
'; sectionsHTML += '

Authentication

'; sectionsHTML += '

All API v1 endpoints require an API key (except POST /api-keys/bootstrap). Generate keys in Settings → API Keys or via the bootstrap endpoint.

'; sectionsHTML += '
Two authentication methods
'; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += '
MethodFormatExample
HeaderAuthorization: Bearer {key}Authorization: Bearer sk_AbCd...
Query?api_key={key}/api/v1/system/status?api_key=sk_AbCd...
'; sectionsHTML += '
Keys use the sk_ prefix. The raw key is shown exactly once at creation time. Only a SHA-256 hash is stored server-side. Rate limit: 60 requests per minute per IP.
'; sectionsHTML += '
Base URL
'; sectionsHTML += '

All endpoints are prefixed with /api/v1

'; sectionsHTML += '
Response Envelope
'; sectionsHTML += '

Every response follows this structure:

'; sectionsHTML += '
{\n "success": true | false,\n "data": { ... } | null,\n "error": { "code": "ERROR_CODE", "message": "..." } | null,\n "pagination": { "page": 1, "limit": 50, "total": 342, "total_pages": 7, "has_next": true, "has_prev": false } | null\n}
'; sectionsHTML += '
Error Codes
'; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += '
StatusCodeMeaning
400BAD_REQUESTMissing or invalid parameters
401AUTH_REQUIREDNo API key provided
403INVALID_KEY / FORBIDDENInvalid key or insufficient permissions
404NOT_FOUNDResource not found
409CONFLICTResource already exists or action in progress
429RATE_LIMITEDToo many requests
500*_ERRORInternal server error
'; sectionsHTML += '
cURL Example
'; sectionsHTML += '
curl -H "Authorization: Bearer sk_abc123..." \\\n http://localhost:5000/api/v1/system/status
'; sectionsHTML += '
'; // API key input bar sectionsHTML += '
'; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += 'Enter key to test endpoints'; sectionsHTML += '
'; // Endpoint groups let globalIdx = 0; const endpointRegistry = []; apiGroups.forEach(group => { sectionsHTML += '
'; sectionsHTML += '

' + group.title + '

'; sectionsHTML += '

' + group.desc + '

'; group.endpoints.forEach(ep => { const idx = globalIdx++; endpointRegistry.push(ep); sectionsHTML += '
'; sectionsHTML += '
'; sectionsHTML += '' + ep.method + ''; sectionsHTML += '' + ep.path + ''; sectionsHTML += '' + ep.desc + ''; sectionsHTML += ''; sectionsHTML += '
'; sectionsHTML += '
'; sectionsHTML += '

' + ep.desc + '

'; sectionsHTML += buildParamsTable(ep.params); sectionsHTML += buildBodyTable(ep.bodyFields); sectionsHTML += buildExample(ep.example); sectionsHTML += buildTryIt(ep, idx); sectionsHTML += '
'; }); sectionsHTML += '
'; }); // WebSocket section sectionsHTML += '
'; sectionsHTML += '

WebSocket Events

'; sectionsHTML += '

SoulSync uses Socket.IO for real-time updates. Connect to the same host/port as the web UI. No API key required for WebSocket connections.

'; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += ''; sectionsHTML += '
EventDescriptionKey Fields
download_progressPer-track download progresstitle, percent, speed, eta
download_completeTrack finished downloadingtitle, artist, album, file_path
batch_progressAlbum/playlist batch statusbatch_id, completed, total, current_track
worker_statusEnrichment worker updatesworker, status, matched, total, current
scan_progressLibrary/quality/duplicate scantype, progress, total, current
system_statusService connectivity changesservice, connected, rate_limited
activityActivity feed entriestimestamp, type, message
wishlist_updateWishlist item changesaction, track_id, track_name
automation_runAutomation execution eventsautomation_id, status, result
'; sectionsHTML += '
JavaScript Example
'; sectionsHTML += '
import { io } from "socket.io-client";\n\nconst socket = io("http://localhost:5000");\n\nsocket.on("download_progress", (data) => {\n console.log(`${data.title}: ${data.percent}%`);\n});\n\nsocket.on("worker_status", (data) => {\n console.log(`${data.worker}: ${data.status} (${data.matched}/${data.total})`);\n});\n\nsocket.on("activity", (data) => {\n console.log(`[${data.timestamp}] ${data.message}`);\n});
'; sectionsHTML += '
'; // Wire up API key status indicator setTimeout(() => { const keyInput = document.getElementById('api-tester-key'); const keyStatus = document.getElementById('api-key-status'); if (keyInput && keyStatus) { keyInput.addEventListener('input', () => { const val = keyInput.value.trim(); if (!val) { keyStatus.textContent = 'Enter key to test endpoints'; keyStatus.classList.remove('connected'); } else if (val.startsWith('sk_')) { keyStatus.textContent = 'Key set \u2713'; keyStatus.classList.add('connected'); } else { keyStatus.textContent = 'Key should start with sk_'; keyStatus.classList.remove('connected'); } }); } }, 0); // Register the try-it handler on window window._apiEndpointRegistry = endpointRegistry; window._apiTryIt = async function(idx) { const ep = endpointRegistry[idx]; const btn = document.getElementById('api-try-btn-' + idx); const resultDiv = document.getElementById('api-try-result-' + idx); const apiKey = document.getElementById('api-tester-key')?.value?.trim(); if (!apiKey) { resultDiv.innerHTML = '
Enter your API key above first
'; return; } // Build path let path = ep.path; const pathMatch = path.match(/\{([^}]+)\}/g); if (pathMatch) { for (const m of pathMatch) { const paramName = m.replace(/[{}]/g, ''); const input = document.getElementById('api-try-path-' + idx + '-' + paramName); const val = input?.value?.trim(); if (!val) { resultDiv.innerHTML = '
Fill in path parameter: ' + paramName + '
'; return; } path = path.replace(m, encodeURIComponent(val)); } } // Build query string for GET let qs = ''; if (ep.method === 'GET' && ep.params) { const parts = []; ep.params.forEach(p => { if (p.name === 'fields') return; const input = document.getElementById('api-try-q-' + idx + '-' + p.name); const val = input?.value?.trim(); if (val) parts.push(encodeURIComponent(p.name) + '=' + encodeURIComponent(val)); }); if (parts.length) qs = '?' + parts.join('&'); } const url = '/api/v1' + path + qs; const fetchOpts = { method: ep.method === 'PATCH' ? 'PATCH' : ep.method, headers: { 'Authorization': 'Bearer ' + apiKey } }; // Body if (ep.bodyFields && ep.bodyFields.length) { const bodyEl = document.getElementById('api-try-body-' + idx); if (bodyEl) { fetchOpts.headers['Content-Type'] = 'application/json'; fetchOpts.body = bodyEl.value; } } btn.classList.add('loading'); btn.innerHTML = '⏳ Sending...'; resultDiv.innerHTML = ''; const startTime = performance.now(); try { const resp = await fetch(url, fetchOpts); const elapsed = Math.round(performance.now() - startTime); let bodyText; try { bodyText = await resp.text(); } catch(e) { bodyText = '(empty response)'; } let formatted = bodyText; try { const parsed = JSON.parse(bodyText); formatted = JSON.stringify(parsed, null, 2); } catch(e) {} const statusClass = resp.status < 300 ? 's2xx' : resp.status < 500 ? 's4xx' : 's5xx'; resultDiv.innerHTML = '
' + '
' + '' + resp.status + ' ' + resp.statusText + '' + '' + elapsed + 'ms' + '
' + '
' + syntaxHighlight(escHtml2(formatted)) + '
' + '
'; } catch(err) { resultDiv.innerHTML = '
Network Error
' + escHtml2(err.message) + '
'; } btn.classList.remove('loading'); btn.innerHTML = '▶ Send'; }; function escHtml2(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } function syntaxHighlight(json) { return json.replace(/"([^"]+)":/g, '"$1":') .replace(/: "((?:[^"\\]|\\.)*)"/g, ': "$1"') .replace(/: (-?\d+\.?\d*)/g, ': $1') .replace(/: (true|false)/g, ': $1') .replace(/: (null)/g, ': $1'); } return sectionsHTML; } } ]; function _showDebugTextModal(text) { // Remove existing modal if any const existing = document.getElementById('debug-text-modal'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'debug-text-modal'; overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;'; overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; const modal = document.createElement('div'); modal.style.cssText = 'background:#1a1a2e;border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:20px;width:90%;max-width:700px;max-height:80vh;display:flex;flex-direction:column;gap:12px;'; const header = document.createElement('div'); header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;'; header.innerHTML = 'Debug Info — Select All & Copy'; const closeBtn = document.createElement('button'); closeBtn.textContent = '\u2715'; closeBtn.style.cssText = 'background:none;border:none;color:#888;font-size:18px;cursor:pointer;'; closeBtn.onclick = () => overlay.remove(); header.appendChild(closeBtn); const ta = document.createElement('textarea'); ta.value = text; ta.readOnly = true; ta.style.cssText = 'width:100%;height:50vh;background:#0d0d1a;color:#e0e0e0;border:1px solid rgba(255,255,255,0.1);border-radius:8px;padding:12px;font-family:monospace;font-size:12px;resize:none;outline:none;'; modal.appendChild(header); modal.appendChild(ta); overlay.appendChild(modal); document.body.appendChild(overlay); // Auto-select all text for easy copying ta.focus(); ta.select(); } let _docsInitialized = false; function initializeDocsPage() { if (_docsInitialized) return; _docsInitialized = true; const nav = document.getElementById('docs-nav'); const content = document.getElementById('docs-content'); if (!nav || !content) return; // Build sidebar nav let navHTML = ''; DOCS_SECTIONS.forEach(section => { navHTML += `
`; navHTML += `
`; navHTML += ``; navHTML += `${section.title}`; navHTML += ``; navHTML += `
`; if (section.children && section.children.length) { navHTML += `
`; section.children.forEach(child => { navHTML += `
${child.title}
`; }); navHTML += `
`; } navHTML += `
`; }); nav.innerHTML = navHTML; // Add debug info panel to sidebar header const sidebarHeader = document.querySelector('.docs-sidebar-header'); if (sidebarHeader) { const debugWrap = document.createElement('div'); debugWrap.className = 'docs-debug-wrap'; debugWrap.innerHTML = `
`; sidebarHeader.appendChild(debugWrap); const debugBtn = debugWrap.querySelector('.docs-debug-button'); debugBtn.onclick = async () => { const logLines = document.getElementById('debug-log-lines').value; const logSource = document.getElementById('debug-log-source').value; try { debugBtn.textContent = 'Collecting...'; const resp = await fetch(`/api/debug-info?lines=${logLines}&log=${logSource}`); const data = await resp.json(); const ck = '\u2713'; const ex = '\u2717'; let text = 'SoulSync Debug Info\n'; text += '═══════════════════════════════════\n\n'; text += '── System ──\n'; text += `Version: ${data.version}\n`; text += `OS: ${data.os}${data.docker ? ' (Docker)' : ''}\n`; text += `Python: ${data.python}\n`; text += `Uptime: ${data.uptime || 'unknown'}\n`; text += `Memory: ${data.memory_usage || '?'} (system: ${data.system_memory || '?'})\n`; text += `CPU: ${data.cpu_percent || '?'}\n`; text += `Threads: ${data.thread_count || '?'}\n\n`; text += '── Services ──\n'; text += `Music Source: ${data.services?.music_source || 'unknown'}\n`; text += `Spotify: ${data.services?.spotify_connected ? ck + ' Connected' : ex + ' Disconnected'}${data.services?.spotify_rate_limited ? ' (RATE LIMITED)' : ''}\n`; text += `Media Server: ${data.services?.media_server_type || 'none'} ${data.services?.media_server_connected ? ck + ' Connected' : ex + ' Disconnected'}\n`; text += `Soulseek: ${data.services?.soulseek_connected ? ck + ' Connected' : ex + ' Disconnected'}\n`; text += `Tidal: ${data.services?.tidal_connected ? ck + ' Connected' : ex + ' Disconnected'}\n`; text += `Qobuz: ${data.services?.qobuz_connected ? ck + ' Connected' : ex + ' Disconnected'}\n`; text += `Download Mode: ${data.services?.download_source || 'unknown'}\n\n`; text += '── Library ──\n'; text += `Artists: ${data.library?.artists?.toLocaleString() || '0'}\n`; text += `Albums: ${data.library?.albums?.toLocaleString() || '0'}\n`; text += `Tracks: ${data.library?.tracks?.toLocaleString() || '0'}\n`; text += `Database: ${data.database_size || 'unknown'}\n`; text += `Watchlist: ${data.watchlist_count || 0} artists\n`; text += `Automations: ${data.automations?.enabled || 0} enabled / ${data.automations?.total || 0} total\n\n`; text += '── Active ──\n'; text += `Downloads: ${data.active_downloads || 0}\n`; text += `Syncs: ${data.active_syncs || 0}\n\n`; text += '── Paths ──\n'; const pathStatus = (exists, writable) => exists ? (writable ? ck + ' ok' : ck + ' exists ' + ex + ' not writable') : ex + ' missing'; text += `Download: ${data.paths?.download_path || '(not set)'} [${pathStatus(data.paths?.download_path_exists, data.paths?.download_path_writable)}]\n`; text += `Transfer: ${data.paths?.transfer_folder || '(not set)'} [${pathStatus(data.paths?.transfer_folder_exists, data.paths?.transfer_folder_writable)}]\n`; text += `Staging: ${data.paths?.staging_folder || '(not set)'} [${data.paths?.staging_folder_exists ? ck + ' ok' : ex + ' missing'}]\n\n`; text += '── Config ──\n'; if (data.config) { text += `Source Mode: ${data.config.source_mode || 'unknown'}\n`; text += `Quality Profile: ${data.config.quality_profile || 'default'}\n`; text += `Folder Template: ${data.config.organization_template || '(default)'}\n`; text += `Post-Processing: ${data.config.post_processing_enabled ? 'enabled' : 'disabled'}\n`; text += `AcoustID: ${data.config.acoustid_enabled ? 'enabled' : 'disabled'}\n`; text += `Auto Scan: ${data.config.auto_scan_enabled ? 'enabled' : 'disabled'}\n`; text += `M3U Export: ${data.config.m3u_export_enabled ? 'enabled' : 'disabled'}\n`; } text += '\n'; text += '── Enrichment Workers ──\n'; if (data.enrichment_workers) { const active = [], paused = []; Object.entries(data.enrichment_workers).forEach(([name, status]) => { (status === 'active' ? active : paused).push(name); }); text += `Active: ${active.length > 0 ? active.join(', ') : 'none'}\n`; text += `Paused: ${paused.length > 0 ? paused.join(', ') : 'none'}\n`; } text += '\n'; text += `── Logs: ${data.log_source || 'app'}.log (last ${data.recent_logs?.length || 0} lines) ──\n`; if (data.recent_logs?.length) { data.recent_logs.forEach(line => { text += line + '\n'; }); } else { text += '(no log lines)\n'; } // Copy to clipboard — navigator.clipboard requires HTTPS/localhost, // so fall back to execCommand for Docker/LAN HTTP access let copied = false; if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text); copied = true; } catch (_) {} } if (!copied) { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px'; document.body.appendChild(ta); ta.select(); try { copied = document.execCommand('copy'); } catch (_) {} document.body.removeChild(ta); } if (copied) { debugBtn.innerHTML = '✅ Copied!'; debugBtn.classList.add('copied'); setTimeout(() => { debugBtn.innerHTML = '📋 Copy Debug Info'; debugBtn.classList.remove('copied'); }, 2000); } else { // Clipboard APIs blocked (HTTP over LAN) — show selectable text modal _showDebugTextModal(text); debugBtn.innerHTML = '📋 Copy Debug Info'; } } catch (err) { debugBtn.innerHTML = '❌ Failed'; console.error('Debug info error:', err); setTimeout(() => { debugBtn.innerHTML = '📋 Copy Debug Info'; }, 2000); } }; } // Build content let contentHTML = ''; DOCS_SECTIONS.forEach(section => { contentHTML += `
`; contentHTML += `

`; contentHTML += ``; contentHTML += `${section.title}`; contentHTML += `

`; contentHTML += section.content(); contentHTML += `
`; }); content.innerHTML = contentHTML; // Suppress scroll spy during click-initiated scrolls let _scrollSpySuppressed = false; function suppressScrollSpy() { _scrollSpySuppressed = true; clearTimeout(suppressScrollSpy._timer); suppressScrollSpy._timer = setTimeout(() => { _scrollSpySuppressed = false; }, 800); } // Scroll a target element into view within the docs-content container. // Uses manual offsetTop calculation instead of scrollIntoView to avoid // misalignment caused by lazy-loaded images that haven't reserved height yet. // Does an initial scroll, then a correction after images near the target load. function scrollDocTarget(target) { if (!target || !docsContent) return; suppressScrollSpy(); function calcOffset(el) { let offset = 0; let current = el; while (current && current !== docsContent) { offset += current.offsetTop; current = current.offsetParent; } return offset; } // Initial scroll docsContent.scrollTop = calcOffset(target); // Correction pass after lazy images near the target have had time to load // and shift layout. Two passes cover most reflow scenarios. setTimeout(() => { docsContent.scrollTop = calcOffset(target); }, 150); setTimeout(() => { docsContent.scrollTop = calcOffset(target); }, 500); } // Section title click → expand/collapse children + scroll nav.querySelectorAll('.docs-nav-section-title').forEach(title => { title.addEventListener('click', () => { const sectionId = title.dataset.target; const children = nav.querySelector(`.docs-nav-children[data-parent="${sectionId}"]`); // Toggle expanded const isExpanded = title.classList.contains('expanded'); // Collapse all nav.querySelectorAll('.docs-nav-section-title').forEach(t => t.classList.remove('expanded', 'active')); nav.querySelectorAll('.docs-nav-children').forEach(c => c.classList.remove('expanded')); if (!isExpanded) { title.classList.add('expanded', 'active'); if (children) children.classList.add('expanded'); } // Scroll to section const target = document.getElementById('docs-' + sectionId); scrollDocTarget(target); }); }); // Child click → scroll to subsection nav.querySelectorAll('.docs-nav-child').forEach(child => { child.addEventListener('click', (e) => { e.stopPropagation(); nav.querySelectorAll('.docs-nav-child').forEach(c => c.classList.remove('active')); child.classList.add('active'); // Keep parent section expanded const target = document.getElementById(child.dataset.target); scrollDocTarget(target); }); }); // Search filter const searchInput = document.getElementById('docs-search-input'); if (searchInput) { searchInput.addEventListener('input', () => { const q = searchInput.value.toLowerCase().trim(); document.querySelectorAll('.docs-section').forEach(sec => { if (!q) { sec.style.display = ''; return; } sec.style.display = sec.textContent.toLowerCase().includes(q) ? '' : 'none'; }); // Also filter nav nav.querySelectorAll('.docs-nav-section').forEach(navSec => { const sectionId = navSec.dataset.section; const docSection = document.getElementById('docs-' + sectionId); navSec.style.display = (!q || (docSection && docSection.style.display !== 'none')) ? '' : 'none'; }); }); } // Scroll spy — highlight active section in nav const docsContent = document.getElementById('docs-content'); if (docsContent) { docsContent.addEventListener('scroll', () => { if (_scrollSpySuppressed) return; const containerRect = docsContent.getBoundingClientRect(); const threshold = containerRect.top + 120; let activeSection = null; let activeChild = null; // Find which section is currently in view using getBoundingClientRect DOCS_SECTIONS.forEach(section => { const el = document.getElementById('docs-' + section.id); if (el) { const rect = el.getBoundingClientRect(); if (rect.top <= threshold) { activeSection = section.id; } } if (section.children) { section.children.forEach(child => { const childEl = document.getElementById(child.id); if (childEl) { const childRect = childEl.getBoundingClientRect(); if (childRect.top <= threshold) { activeChild = child.id; } } }); } }); // Default to first section if nothing scrolled past threshold yet if (!activeSection && DOCS_SECTIONS.length) { activeSection = DOCS_SECTIONS[0].id; if (DOCS_SECTIONS[0].children && DOCS_SECTIONS[0].children.length) { activeChild = DOCS_SECTIONS[0].children[0].id; } } // Update nav highlighting nav.querySelectorAll('.docs-nav-section-title').forEach(t => { const isActive = t.dataset.target === activeSection; t.classList.toggle('active', isActive); t.classList.toggle('expanded', isActive); }); nav.querySelectorAll('.docs-nav-children').forEach(c => { c.classList.toggle('expanded', c.dataset.parent === activeSection); }); nav.querySelectorAll('.docs-nav-child').forEach(c => { c.classList.toggle('active', c.dataset.target === activeChild); }); }); } // Reset scroll position and auto-expand first section if (docsContent) docsContent.scrollTop = 0; const firstTitle = nav.querySelector('.docs-nav-section-title'); if (firstTitle) { firstTitle.classList.add('expanded', 'active'); const firstChildren = nav.querySelector('.docs-nav-children'); if (firstChildren) firstChildren.classList.add('expanded'); } } function navigateToDocsSection(sectionId) { // Switch to help page if (typeof navigateToPage === 'function') navigateToPage('help'); // Wait for docs to initialize, then use manual scroll with correction passes setTimeout(() => { const target = document.getElementById(sectionId); const docsContent = document.getElementById('docs-content'); if (target && docsContent) { function calcOffset(el) { let offset = 0; let current = el; while (current && current !== docsContent) { offset += current.offsetTop; current = current.offsetParent; } return offset; } docsContent.scrollTop = calcOffset(target); setTimeout(() => { docsContent.scrollTop = calcOffset(target); }, 150); setTimeout(() => { docsContent.scrollTop = calcOffset(target); }, 500); } }, 300); }