Where artist, album, and track metadata comes from:
+ i
+
Where artist, album, and track metadata comes from:
• Spotify — official Spotify; connect your account in the Spotify section below.
• Spotify (no auth) — the same Spotify catalog with no account needed. It's unofficial and best-effort (may break if Spotify changes its site) and can't read your personal library or playlists.
• Deezer / iTunes — free, no account.
@@ -3993,13 +4006,15 @@
Disconnect
-
-
-
Runs the background metadata enrichment worker on the no-auth Spotify source instead of this connected account — keeping bulk enrichment off your official API quota (and dodging rate-limit bans), so your account is reserved for interactive search and playlist sync. On by default. Turn off to enrich through your connected account instead. Works even with no account connected. Note: the no-auth source can't supply artist genres.
+
+
+ i
+
Runs the background metadata enrichment worker on the no-auth Spotify source instead of this connected account — keeping bulk enrichment off your official API quota (and dodging rate-limit bans), so your account is reserved for interactive search and playlist sync. On by default. Turn off to enrich through your connected account instead. Works even with no account connected. Note: the no-auth source can't supply artist genres.
@@ -4565,81 +4580,17 @@
-
-
Download Settings
-
- These are container-internal paths. Only modify them if you know what you're doing — incorrect values will break downloads.
-
+
+
+ ▼
+
Source Settings
+ Source, stream & concurrency
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Where Organize by playlist builds playlist folders — as shortcuts into your library, so songs are never stored twice. Keep this outside your music library folder so your media server doesn't scan the same tracks twice.
-
-
-
-
-
-
-
- Symlinks use almost no space. Copies are self-contained. Symlinks fall back to copies automatically where the filesystem can't link (Windows without privileges, some network shares, FAT/exFAT drives).
-
-
-
-
-
-
-
-
-
-
-
-
- Optional prefix added to every track path in exported M3U files. Use this when your media server needs absolute paths (e.g. /mnt/music).
-
-
+
-
+ i
+
Choose where to download music from. Hybrid mode tries primary source first,
then falls back to secondary if it fails.
@@ -4669,7 +4622,9 @@
-
+ i
+
Where to stream audio when previewing tracks. YouTube is instant and requires no setup.
"Active Download Source" uses your configured source (Tidal, Qobuz, etc.) — if that's Soulseek, YouTube is used automatically.
@@ -4687,17 +4642,23 @@
-
+ i
+
Maximum simultaneous downloads per batch. Soulseek album downloads always use 1 worker
due to per-user upload limits. Higher values speed up large playlists and wishlists.
+
+
+ Checks every selected source & configured connection.
+
+
- Drag to reorder source priority. Enable sources you want to use. Downloads try each enabled source in order.
- The first source can use album-level downloads when supported; later sources stay track-level fallback.
+ Drag to reorder source priority. Toggle the sources you want to use — downloads try each enabled source in order. Click a source (or its ⚙) to configure it below. The first source can use album-level downloads when supported; later sources stay track-level fallback.
@@ -4706,6 +4667,7 @@
+
@@ -4975,7 +4937,7 @@
- 📡 Torrent and Usenet downloads run through your Prowlarr indexers and your configured downloader. Set up Prowlarr + the torrent / usenet client on the Indexers & Downloaders tab. Once Test Connection is green on both, this source is ready to use.
+ 📡 Torrent / Usenet downloads use Prowlarr plus a torrent or usenet client — configure them in the Indexers & Downloaders tiles below.
A single source often returns several copies of the same track.
@@ -5198,7 +5161,7 @@
Accept off-list quality when nothing in the list is available
ⓘ
+ onclick="toggleSettingHelp(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleSettingHelp(this);}">i
This does not mean "walk down my list" — the list already
@@ -5211,22 +5174,144 @@
-
-
+
+
+
+
+
+
+
+ ▼
+
Quality on Import
+ Apply the profile to manually imported files
+
+
+
+
-
-
- When on, a track that AcoustID runs but cannot confirm
+
+ On by default. SoulSync probes each imported file's real audio quality and compares it
+ with your Quality Profile. On — files that don't meet your profile are
+ quarantined instead of imported (fallback/downsample settings still apply).
+ Off — files are imported regardless of format, bitrate, bit depth, or
+ sample rate. This does not skip AcoustID; fingerprint verification is a
+ separate setting below. Either way, the library Quality Upgrade Scanner still
+ flags below-profile tracks so you can act on them later.
+
+
+
+
+
+
+ Replace lower quality files on import
+
+ i
+
+
+ When importing from Staging, if a track already exists in your library at a lower quality
+ (e.g. MP3), it will be replaced with the higher quality version (e.g. FLAC).
+ If disabled, existing tracks are always kept and the import file is skipped.
+
+
+
+
+
+
+
+ ▼
+
Post-Download Conversion
+ Downsample hi-res, make lossy copies
+
+
+
+
+
+
+
+ Downsample Hi-Res FLAC to CD quality (16-bit / 44.1kHz)
+
+ i
+
+
When a 24-bit or high sample-rate FLAC is downloaded, it is automatically converted to 16-bit/44.1kHz. The hi-res original is replaced, not kept alongside. Requires ffmpeg.
+
+
+
+
+
+ Create lossy copy of downloaded FLAC files
+
+ i
+
+
After downloading a FLAC, a lossy copy at the selected codec and bitrate is created in the same folder. Requires ffmpeg.
+
+
+
+ Codec:
+
+
+
+ Bitrate:
+
+
+
+
+
+ Blasphemy Mode — Delete original FLAC after conversion
+
+
+
+ Warning: The original high-quality file will be permanently deleted.
+ Only the lossy copy will remain.
+
+
+
+
+
+
+
+ ▼
+
Audio Verification
+ AcoustID identity match + truncated/silent detection
+
+
+
+
+
+
+
+
+ Only allow AcoustID-verified tracks for imports and downloads
+
+ i
+
+
+ Applies to both imports and downloads. When
+ on, a track that AcoustID runs but cannot confirm
(no fingerprint match / cross-script metadata — the ⚠ "unverified" case) is
- quarantined instead of imported. Downloads then try the next
- candidate; imports just quarantine (no candidate to retry). Only a clean AcoustID
- pass is kept.
+ quarantined instead of being allowed into the library. Downloads then
+ try the next candidate; imports just quarantine (no candidate to retry). Only a clean
+ AcoustID pass is kept.
⚠️ Use with care. AcoustID "could not confirm" is common
for perfectly good tracks — anything not in its fingerprint database: new/obscure
@@ -5239,96 +5324,51 @@
outage) always import, so an AcoustID outage never stalls you. Requires AcoustID enabled.
- Controls what happens when a downloaded file is rejected (AcoustID mismatch, wrong version, or duration/integrity failure). Independent of post-processing.
-
-
- Retry next-best candidate on mismatch
-
- When a download is quarantined (AcoustID mismatch or duration/integrity failure), automatically try the next-best candidate instead of failing the track. Off = quarantine and fail immediately.
-
-
-
-
- Exhaustive retry (separate budget per source)
-
- Give every source (Soulseek, then HiFi/Tidal/…) its own retry budget. Each source spends queries × retries-per-query attempts before the track moves on. Worst case across two sources can mean many downloads — use for hard-to-match tracks (e.g. CJK artist names). Requires the option above.
-
-
- Retries per query (per source)
-
- In exhaustive mode, how many candidates to try per search query per source. The per-source budget is number of search queries × this value. Only used when Exhaustive retry is on.
-
-
-
-
- Accept best version mismatch as last resort
-
- When retries are fully exhausted and every candidate for a track failed the same version mismatch (e.g. only an instrumental exists), accept the best (first-tried) one instead of leaving the track missing. Only AcoustID is bypassed — integrity/duration/bit-depth checks still run, so truncated or genuinely wrong files are never let through. Off = leave such tracks failed.
-
-
- Minimum matching mismatches before accepting
-
- How many quarantined candidates must have failed the same version mismatch before the last-resort acceptance kicks in. Higher = more confirmation that no correct version exists. Only used when the option above is on.
-
- Configure your Prowlarr instance to feed search results, and pick a torrent client and/or usenet client to handle the actual downloads. You only need to set up the protocols you actually use.
-
-
-
💡 Filesystem access — easiest setup
-
- Torrent and usenet clients write files to their own download folders, not Soulseek's. SoulSync needs read access to those folders to import the files. Simplest fix: point all your downloaders (Soulseek, qBittorrent, SABnzbd / NZBGet) at the same download folder. One folder, one path to worry about, everything just works.
-
-
Bare-metal: just set every client's download path to the same folder (e.g. your existing slskd folder). Done.
-
Docker: easiest is to reuse the ./downloads mount you already have for slskd — point qBit and SAB at /downloads inside their containers too. Or use the commented placeholders in docker-compose.yml if you want separate folders.
-
Remote client (NAS / different host): mount the remote folder locally (SMB / NFS / rclone) before SoulSync can see it.
-
+
+
+
+ Verify real audio with ffmpeg
+
+ i
+
Decodes every downloaded file with ffmpeg to catch fake 30s previews padded to full length and mostly-silent files — the kind whose header lies about the length so mutagen can't catch them. Off by default: this fully decodes each file, the most CPU-heavy post-processing step. Most sources (HiFi, Qobuz) already catch previews on their own, so only turn this on if you see padded/silent files slip through. Applies to all download sources.
+
+
+
+
+
+
+
+
+
💡 Filesystem access — easiest setup ▾
+
+ Torrent and usenet clients write files to their own download folders, not Soulseek's. SoulSync needs read access to those folders to import the files. Simplest fix: point all your downloaders (Soulseek, qBittorrent, SABnzbd / NZBGet) at the same download folder. One folder, one path to worry about, everything just works.
+
+
Bare-metal: just set every client's download path to the same folder (e.g. your existing slskd folder). Done.
+
Docker: easiest is to reuse the ./downloads mount you already have for slskd — point qBit and SAB at /downloads inside their containers too. Or use the commented placeholders in docker-compose.yml if you want separate folders.
+
Remote client (NAS / different host): mount the remote folder locally (SMB / NFS / rclone) before SoulSync can see it.
+
-
+
▼
-
🔎 Indexers
+
Indexers
Prowlarr — search aggregator
-
-
+
+
-
⬢ Prowlarr
-
+
+
⬢ Prowlarr
+ i
+
+
Prowlarr manages your Usenet and torrent indexers and exposes them through one API. SoulSync uses Prowlarr to search across every indexer at once for the Torrent and Usenet download sources.
Don't have it? Grab Prowlarr from prowlarr.com (or your *arr stack). You point Prowlarr at your indexers, then point SoulSync at Prowlarr.
@@ -5376,18 +5416,23 @@
-
-
+
+
+
▼
-
🧲 Torrent Client
+
Torrent Client
qBittorrent · Transmission · Deluge
-
-
+
+
-
⬢ Torrent Downloader
-
+
+
⬢ Torrent Downloader
+ i
+
+
Where SoulSync sends torrents once Prowlarr finds them. Pick the client you already use — only one can be active at a time. Add the URL of the client's WebUI, log in if it asks for credentials, and hit Test Connection.
@@ -5468,18 +5513,25 @@
-
-
+
+
+
+
+
▼
-
📰 Usenet Client
+
Usenet Client
SABnzbd · NZBGet
-
-
+
+
-
⬢ Usenet Downloader
-
+
+
⬢ Usenet Downloader
+ i
+
+
Where SoulSync sends NZBs once Prowlarr finds them. Pick one usenet downloader. SABnzbd uses an API key for auth; NZBGet uses a username + password.
@@ -5537,18 +5589,164 @@
+
+
+
+
+
+ ▼
+
Retry Logic
+ What happens when a download is rejected
+
+
+
+
+
+
+
+ Retry next-best candidate on mismatch
+
+ i
+
+
Controls what happens when a downloaded file is rejected (AcoustID mismatch, wrong version, or duration/integrity failure) — independent of post-processing. When on, SoulSync automatically tries the next-best candidate instead of failing the track. Off = quarantine and fail immediately.
+
+
+
+
+
+ Exhaustive retry (separate budget per source)
+
+ i
+
+
Give every source (Soulseek, then HiFi/Tidal/…) its own retry budget. Each source spends queries × retries-per-query attempts before the track moves on. Worst case across two sources can mean many downloads — use for hard-to-match tracks (e.g. CJK artist names). Requires the option above.
+
+
+
+ Retries per query (per source)
+
+ i
+
+
In exhaustive mode, how many candidates to try per search query per source. The per-source budget is number of search queries × this value. Only used when Exhaustive retry is on.
+
+
+
+
+
+ Accept best version mismatch as last resort
+
+ i
+
+
When retries are fully exhausted and every candidate for a track failed the same version mismatch (e.g. only an instrumental exists), accept the best (first-tried) one instead of leaving the track missing. Only AcoustID is bypassed — integrity/duration/bit-depth checks still run, so truncated or genuinely wrong files are never let through. Off = leave such tracks failed.
+
+
+
+ Minimum matching mismatches before accepting
+
+ i
+
+
How many quarantined candidates must have failed the same version mismatch before the last-resort acceptance kicks in. Higher = more confirmation that no correct version exists. Only used when the option above is on.
+ Container-internal paths. Only change them if you know what you're doing — wrong values break downloads.
+
+
+
+ Input Folder (Download Dir):
+
+
+
+
+
+
+
+ Output Folder (Music Library):
+
+
+
+
+
+
+
+ Import Folder:
+
+
+
+
+
+
+
+ Music Videos Dir:
+
+
+
+
+
+
+
+ Playlists Folder:
+
+
+
+
+ i
+
+ Where Organize by playlist builds playlist folders — as shortcuts into your library, so songs are never stored twice. Keep this outside your music library folder so your media server doesn't scan the same tracks twice.
+
+
+
+
+ Playlist Folder Style:
+
+ i
+
+ Symlinks use almost no space. Copies are self-contained. Symlinks fall back to copies automatically where the filesystem can't link (Windows without privileges, some network shares, FAT/exFAT drives).
+
+
+
+
+
+
+
+
+ M3U Entry Base Path:
+
+
+
+
+ i
+
+ Optional prefix added to every track path in exported M3U files. Use this when your media server needs absolute paths (e.g. /mnt/music).
+
+
+
+
-
📁 File Organization
+
File Organization
@@ -5561,15 +5759,16 @@
Album Path Template:
- Variables: $albumartist, $artist, $artistletter, $album, $albumtype,
- $title, $track, $disc (01), $discnum (1), $cdnum (CD01 on multi-disc, empty on single), $year, $quality (filename only). Use ${var} to append text: ${albumtype}s → Albums
+ i
+ Variables: $albumartist, $artist, $artistletter, $album, $albumtype, $title, $track, $disc (01), $discnum (1), $cdnum (CD01 on multi-disc, empty on single), $year, $quality (filename only). Use ${var} to append text: ${albumtype}s → Albums
Single Path Template:
- Variables: $albumartist, $artist, $artistletter, $title, $track (01), $album, $albumtype, $year, $quality (filename only). Use ${var} to append text: ${albumtype}s → Singles
+ i
+ Variables: $albumartist, $artist, $artistletter, $title, $track (01), $album, $albumtype, $year, $quality (filename only). Use ${var} to append text: ${albumtype}s → Singles
-
📂 Music Library Paths
+
Music Library Paths
Tell SoulSync where your music files live. Required for tag writing, streaming, and file detection
@@ -5694,7 +5897,6 @@
-
🎵 Post-Processing
@@ -5702,7 +5904,8 @@
onchange="document.getElementById('post-processing-options').style.display = this.checked ? 'block' : 'none'">
Enable post-processing on downloaded files
- Master toggle — disabling this skips all metadata, artwork, and lyrics processing
+ i
+ Master toggle — disabling this skips all metadata, artwork, and lyrics processing.
@@ -5726,11 +5929,13 @@
Use MusicBrainz Cover Art Archive for album art
- Higher resolution but quality may vary. When off, uses Spotify/iTunes/Deezer art (consistent 640x640). Ignored when a preferred album art source list is set below — add Cover Art Archive to that list instead.
+ i
+ Higher resolution but quality may vary. When off, uses Spotify/iTunes/Deezer art (consistent 640x640). Ignored when a preferred album art source list is set below — add Cover Art Archive to that list instead.
Preferred album art sources
- Order the sources to choose whose cover art is used. The first source that has a cover wins; misses fall through to the next, and if none match, your download's own art is kept. Only sources you're connected to are shown — leave all off to keep current behavior.
+ i
+ Order the sources to choose whose cover art is used. The first source that has a cover wins; misses fall through to the next, and if none match, your download's own art is kept. Only sources you're connected to are shown — leave all off to keep current behavior.
@@ -5738,7 +5943,8 @@
Match singles to their parent album
- When a track matches a single release, look up the album that contains it and tag it as that album — so every song in an album gets the same (album) cover instead of some getting the single's art. Off by default: adds an extra metadata lookup per single-matched track.
+ i
+ When a track matches a single release, look up the album that contains it and tag it as that album — so every song in an album gets the same (album) cover instead of some getting the single's art. Off by default: adds an extra metadata lookup per single-matched track.
@@ -5751,19 +5957,14 @@
Apply ReplayGain tags after download
- Analyzes loudness and writes ReplayGain track gain/peak tags. Requires ffmpeg. Adds a few seconds per track.
+ i
+ Analyzes loudness and writes ReplayGain track gain/peak tags. Requires ffmpeg. Adds a few seconds per track.
Duration tolerance (seconds)
- Maximum drift between the file's actual length and the metadata source's expected length before the file is quarantined. 0 = auto (3s normal, 5s for tracks >10min). Raise this if tracks routinely quarantine for being a few seconds off (live recordings, alternate masterings, etc). Capped at 60s.
-
-
-
-
- Verify real audio with ffmpeg (detect truncated / silent files)
-
- Decodes every downloaded file with ffmpeg to catch fake 30s previews padded to full length and mostly-silent files — the kind whose header lies about the length so mutagen can't catch them. Off by default: this fully decodes each file, the most CPU-heavy post-processing step. Most sources (HiFi, Qobuz) already catch previews on their own, so only turn this on if you see padded/silent files slip through. Applies to all download sources.
+ i
+ Maximum drift between the file's actual length and the metadata source's expected length before the file is quarantined. 0 = auto (3s normal, 5s for tracks >10min). Raise this if tracks routinely quarantine for being a few seconds off (live recordings, alternate masterings, etc). Capped at 60s.
@@ -5978,99 +6179,42 @@
-
-
-
📀 Post-Download Conversion
-
-
-
-
- Downsample Hi-Res FLAC to CD quality (16-bit / 44.1kHz)
-
-
-
- When a 24-bit or high sample rate FLAC is downloaded, it will be
- automatically converted to 16-bit/44.1kHz. The hi-res original is
- replaced — not kept alongside. Requires ffmpeg.
-
- After downloading a FLAC, a lossy copy at the selected codec and bitrate
- will be created in the same folder. Requires ffmpeg.
-
-
-
-
- Blasphemy Mode — Delete original FLAC after conversion
-
-
-
- Warning: The original high-quality file will be permanently deleted.
- Only the lossy copy will remain.
-
-
-
-
-
+
▼
-
Library Preferences
- Import, content filter, playlists, stats
+
Listening Stats
+ Play history from your media server
-
-
-
📊 Listening Stats
Enable listening stats collection from media server
+ i
+
+
+ Polls your active media server (Plex, Jellyfin, or Navidrome) for play history
+ and displays stats on the Stats page.
Poll Interval (minutes):
-
- Polls your active media server (Plex, Jellyfin, or Navidrome) for play history
- and displays stats on the Stats page.
-
+
-
-
-
-
-
🔒 Security & Access
+
+
+ ▼
+
Security & Access
+ PIN · user accounts · reverse proxy
+
+
+
By default, anyone who can reach SoulSync on your network can use it. Protect access below with a simple shared PIN, or full user accounts with passwords — you only need one. The last group is just for exposing SoulSync over the internet.
@@ -6187,10 +6331,18 @@
+
-
-
🔍 Discovery Pool Settings
+
+
+ ▼
+
Discovery Pool Settings
+ Lookback & hemisphere
+
+
+
+
Lookback Period:
@@ -6201,7 +6353,9 @@
-
+ i
+
Controls how far back to scan when adding new artists or refreshing discovery
pool.
Once scanned, artists are kept updated with new releases only.
@@ -6214,13 +6368,16 @@
-
+ i
+
Adjusts seasonal playlists on the Discover page to match your region.
Southern hemisphere flips seasons (e.g. December = Summer, June = Winter).
Holidays like Christmas and Halloween stay calendar-fixed.
+
@@ -6286,8 +6443,15 @@
-
+
+
+ ▼
Database Settings
+ Concurrency
+
+
+
+
Concurrent Workers:
@@ -6301,15 +6465,23 @@
+ i
-
Number of parallel threads for database updates. Higher values =
- faster updates but more server load.
+
Number of parallel threads for database updates. Higher values = faster updates but more server load.
+
-
+
+
+ ▼
Database Maintenance
+ Size, vacuum & cleanup
+
+
+
+
Database Size:
@@ -6334,39 +6506,87 @@
+
-
+
+
+ ▼
+
Import
+ Folder-artist override, permanent library
+
+
-
🔞 Content Filter
+
+
+
+
+ Use the top Staging folder as the artist
+
+ i
+
+
+ On by default (legacy behaviour). When enabled, files staged as
+ Artist/Album/… (or Artist/Albums/Album/…) take the top
+ folder as the album artist — handy for mixtapes/compilations whose embedded tags
+ carry DJ names. Turn this off if you drop a mixed pile of songs
+ under one container folder: otherwise every file's artist gets overwritten with
+ that folder's name (the cause of the "soulsync" mass-mislabel). With it off, the
+ metadata-identified artist is always kept.
+
+
+
+
+
+
+ Transfer is my permanent library — never move files out
+
+ i
+
+
+ Off by default. Turn this on if your Transfer folder is your
+ real, permanent library (served by Navidrome/Plex/etc.) rather than a scratch
+ drop area. A Deep Scan then never relocates files it doesn't recognize out to
+ Staging — it leaves them in place and just reports them. Even with this off, a
+ Deep Scan now refuses to move files when the database looks badly out of sync
+ with disk, as a safety guard against wiping a library (issue #904).
+
+
+
+
+
+
+ ▼
+
Filtering
+ Explicit content & genre whitelist
+
+
+
Allow explicit content in downloads
+ i
-
+
When disabled, tracks marked as explicit on Spotify will be skipped
during matched downloads. Does not affect manual Soulseek searches.
-
-
-
-
-
-
🎵 Genre Whitelist
Enable strict genre filtering
+ i
-
+
When enabled, only genres on the whitelist below are kept during enrichment. Junk tags (artist names, radio shows, playlist names) are silently dropped. When disabled, all genres pass through unchanged.
-
@@ -6378,98 +6598,26 @@
+
-
+
▼
-
Import
- Quality filter, replace rules, folder-artist
+
Playlists
+ M3U export & server playlist sync
-
-
📥 Import Settings
-
-
-
-
- Only import tracks that meet your quality profile
-
-
-
- On by default. SoulSync always probes each downloaded file's real audio quality; this
- toggle decides what to do with the result. On — files that don't meet your
- quality profile are quarantined instead of imported (the same gate the download pipeline
- uses; fallback/downsample settings still apply). Off — everything is
- imported regardless of quality. Either way, the library Quality Upgrade Scanner
- still flags below-profile tracks so you can act on them later.
-
-
-
-
-
- Replace lower quality files on import
-
-
-
- When importing from Staging, if a track already exists in your library at a lower quality
- (e.g. MP3), it will be replaced with the higher quality version (e.g. FLAC).
- If disabled, existing tracks are always kept and the import file is skipped.
-
-
-
-
-
- Use the top Staging folder as the artist
-
-
-
- On by default (legacy behaviour). When enabled, files staged as
- Artist/Album/… (or Artist/Albums/Album/…) take the top
- folder as the album artist — handy for mixtapes/compilations whose embedded tags
- carry DJ names. Turn this off if you drop a mixed pile of songs
- under one container folder: otherwise every file's artist gets overwritten with
- that folder's name (the cause of the "soulsync" mass-mislabel). With it off, the
- metadata-identified artist is always kept.
-
-
-
-
-
- Transfer is my permanent library — never move files out
-
-
-
- Off by default. Turn this on if your Transfer folder is your
- real, permanent library (served by Navidrome/Plex/etc.) rather than a scratch
- drop area. A Deep Scan then never relocates files it doesn't recognize out to
- Staging — it leaves them in place and just reports them. Even with this off, a
- Deep Scan now refuses to move files when the database looks badly out of sync
- with disk, as a safety guard against wiping a library (issue #904).
-
-
-
-
-
-
-
-
-
📋 M3U Playlist Export
-
Auto-save M3U file when downloading playlists
+ i
-
+
Saves an M3U playlist file alongside your music. Albums are skipped — they're already grouped by your media server.
-
-
-
-
-
Playlist Sync Settings
Sync mode:
@@ -6478,7 +6626,8 @@
-
+ i
+
"Replace" deletes and recreates the server playlist every sync, which wipes its custom image/description. "Reconcile" updates the same playlist in place (adds new tracks, removes ones no longer in the source) so your custom image and description survive. "Append" only ever adds.
@@ -6490,10 +6639,18 @@
+
-
+
+
+ ▼
Logging
+ Log level & path
+
+
+
+
Log Level:
@@ -6503,7 +6660,8 @@
-
+ i
+
Controls the level of detail in application logs. DEBUG shows all details, INFO
shows general operations, WARNING and ERROR show only issues.
@@ -6514,10 +6672,18 @@
logs/app.log
+
-
+
+
+ ▼
SoulSync API
+ REST keys & inbound endpoint
+
+
+
+
REST API Keys
@@ -6570,10 +6736,18 @@
+
-
-
🔧 Developer Mode
+
+
+ ▼
+
Developer Mode
+ Advanced log viewer
+
+
+
+
Password:
@@ -6583,6 +6757,7 @@
Inactive
+
@@ -6625,10 +6800,6 @@
-
-
-
-
diff --git a/webui/src/routes/import/-ui/import-page.tsx b/webui/src/routes/import/-ui/import-page.tsx
index e4204c6c..840c8d5e 100644
--- a/webui/src/routes/import/-ui/import-page.tsx
+++ b/webui/src/routes/import/-ui/import-page.tsx
@@ -102,9 +102,9 @@ function ImportOptions() {
/>
- Quality check on import
+ Quality profile check on import
diff --git a/webui/static/init.js b/webui/static/init.js
index b71a9699..0b49db46 100644
--- a/webui/static/init.js
+++ b/webui/static/init.js
@@ -254,16 +254,28 @@ function applyReduceEffects(enabled) {
}
}
- if (localStorage.getItem('soulsync-reduce-effects') === '1') {
+ const reduceEffectsSaved = localStorage.getItem('soulsync-reduce-effects');
+ if (reduceEffectsSaved === '1') {
document.body.classList.add('reduce-effects');
window._reduceEffectsActive = true;
+ } else if (reduceEffectsSaved === '0') {
+ document.body.classList.remove('reduce-effects');
+ window._reduceEffectsActive = false;
+ } else if (window._reduceEffectsActive) {
+ document.body.classList.add('reduce-effects');
}
const saved = localStorage.getItem('soulsync-accent');
if (saved) applyAccentColor(saved);
// Bootstrap particles setting from localStorage — OFF by default (continuous
// full-page canvas = real GPU cost); only on when the user explicitly enabled it.
const particlesSaved = localStorage.getItem('soulsync-particles');
- window._particlesEnabled = (particlesSaved === 'true');
+ if (particlesSaved === 'true') {
+ window._particlesEnabled = true;
+ } else if (particlesSaved === 'false') {
+ window._particlesEnabled = false;
+ } else if (typeof window._particlesEnabled !== 'boolean') {
+ window._particlesEnabled = false;
+ }
if (!window._particlesEnabled) {
const canvas = document.getElementById('page-particles-canvas');
if (canvas) canvas.style.display = 'none';
@@ -272,9 +284,41 @@ function applyReduceEffects(enabled) {
const workerOrbsSaved = localStorage.getItem('soulsync-worker-orbs');
if (workerOrbsSaved === 'false') {
window._workerOrbsEnabled = false;
+ } else if (workerOrbsSaved === 'true') {
+ window._workerOrbsEnabled = true;
+ } else if (typeof window._workerOrbsEnabled !== 'boolean') {
+ window._workerOrbsEnabled = true;
}
})();
+async function bootstrapServerAppearanceSettings() {
+ try {
+ const response = await fetch('/api/settings', { credentials: 'same-origin' });
+ const settings = await response.json();
+ if (!response.ok || !settings || typeof settings !== 'object' || settings.error) return;
+
+ const appearance = settings.ui_appearance || {};
+ const preset = appearance.accent_preset || '#1db954';
+ const custom = appearance.accent_color || '#1db954';
+ const accent = preset === 'custom' ? custom : preset;
+ applyAccentColor(accent);
+
+ if (Object.prototype.hasOwnProperty.call(appearance, 'particles_enabled')) {
+ applyParticlesSetting(appearance.particles_enabled !== false);
+ }
+ if (Object.prototype.hasOwnProperty.call(appearance, 'worker_orbs_enabled')) {
+ applyWorkerOrbsSetting(appearance.worker_orbs_enabled !== false);
+ }
+ if (localStorage.getItem('soulsync-reduce-effects') === null) {
+ applyReduceEffects(appearance.reduce_effects === true);
+ }
+ } catch (error) {
+ console.warn('Could not bootstrap appearance settings:', error);
+ }
+}
+
+bootstrapServerAppearanceSettings();
+
// ── Profile System ─────────────────────────────────────────────
let currentProfile = null;
const PROFILE_CONTEXT_CHANGED_EVENT = 'ss:webui-profile-context-changed';
diff --git a/webui/static/settings.js b/webui/static/settings.js
index 3d91f4b5..36a5a803 100644
--- a/webui/static/settings.js
+++ b/webui/static/settings.js
@@ -40,15 +40,31 @@ async function copyAddress(address, cryptoName) {
let settingsAutoSaveTimer = null;
-// The "Only import AcoustID-verified tracks" toggle (under Quality Profile) is
-// meaningless when AcoustID itself is off — only show it when verification is on.
+// The "Only allow AcoustID-verified tracks" toggle lives in Audio Verification
+// and is always shown (its help notes it needs AcoustID enabled), so users can
+// always find it. Kept as a no-op for any existing callers.
function syncAcoustidRequireVerifiedVisibility() {
const group = document.getElementById('acoustid-require-verified-group');
- const enabled = document.getElementById('acoustid-enabled');
- if (group) group.style.display = (enabled && enabled.checked) ? '' : 'none';
+ if (group) group.style.display = '';
}
window.syncAcoustidRequireVerifiedVisibility = syncAcoustidRequireVerifiedVisibility;
+// Retry Logic: the two numeric rows are only meaningful when their parent toggle
+// is on — hide them otherwise. "Retries per query" needs Exhaustive retry;
+// "Minimum matching mismatches" needs the version-mismatch fallback.
+function syncRetryConditionalRows() {
+ const pairs = [
+ ['retry-exhaustive', 'retries-per-query-row'],
+ ['accept-version-mismatch-fallback', 'version-mismatch-min-count-row'],
+ ];
+ for (const [toggleId, rowId] of pairs) {
+ const toggle = document.getElementById(toggleId);
+ const row = document.getElementById(rowId);
+ if (row) row.style.display = (toggle && toggle.checked) ? '' : 'none';
+ }
+}
+window.syncRetryConditionalRows = syncRetryConditionalRows;
+
function debouncedAutoSaveSettings() {
// Ignore changes made while the page is programmatically populating its
// fields on load — those aren't user edits and must not trigger a full
@@ -417,6 +433,11 @@ function switchSettingsTab(tab) {
if (tab === 'advanced' && typeof loadDbMaintenanceInfo === 'function') {
try { loadDbMaintenanceInfo(); } catch (e) { }
}
+ // First time the Downloads tab is shown, auto-probe source status so the
+ // dots reflect real connection state without a manual "Test all sources".
+ if (tab === 'downloads' && typeof autoTestSourcesOnce === 'function') {
+ autoTestSourcesOnce();
+ }
// Initialize live log viewer when switching to Logs tab
if (tab === 'logs') {
_logViewerInit();
@@ -659,6 +680,126 @@ const ALBUM_LEVEL_HYBRID_SOURCES = new Set(['soulseek', 'torrent', 'usenet']);
let _hybridSourceOrder = ['soulseek', 'youtube'];
let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false, deezer_dl: false, amazon: false, lidarr: false, soundcloud: false, torrent: false, usenet: false };
let _hybridVisualOrder = null; // Full visual order including disabled sources
+// In hybrid mode, only one source's config panel is shown at a time (clicked
+// open from its row), so the long per-source config blocks don't all stack up.
+let _expandedHybridSource = null;
+
+function toggleHybridSourceConfig(srcId) {
+ _expandedHybridSource = (_expandedHybridSource === srcId) ? null : srcId;
+ buildHybridSourceList();
+ updateDownloadSourceUI();
+ // Bring the freshly opened config panel into view.
+ if (_expandedHybridSource) {
+ const map = {
+ soulseek: 'soulseek-settings-container', youtube: 'youtube-settings-container',
+ tidal: 'tidal-download-settings-container', qobuz: 'qobuz-settings-container',
+ hifi: 'hifi-download-settings-container', deezer_dl: 'deezer-download-settings-container',
+ amazon: 'amazon-download-settings-container', lidarr: 'lidarr-download-settings-container',
+ soundcloud: 'soundcloud-download-settings-container', torrent: 'prowlarr-source-redirect',
+ usenet: 'prowlarr-source-redirect',
+ };
+ const el = document.getElementById(map[_expandedHybridSource]);
+ if (el) setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 60);
+ }
+}
+
+// ── Per-source live connection status (shown as a dot in the hybrid list and
+// driven by the "Test all sources" button). srcId -> 'unknown'|'testing'|'ok'|'fail'|'na'
+let _hybridSourceStatus = {};
+
+async function _ssJson(url, opts) {
+ const r = await fetch(url, opts);
+ return await r.json();
+}
+function _ssTestConn(service) {
+ return _ssJson(API.testConnection || '/api/test-connection', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ service })
+ }).then(j => !!j.success);
+}
+// Each probe returns a boolean (connected/ok). Endpoints mirror the per-source
+// "Test Connection" buttons so the results match what those buttons would show.
+const HYBRID_SOURCE_PROBE = {
+ soulseek: () => _ssTestConn('soulseek'),
+ tidal: () => _ssTestConn('tidal'),
+ qobuz: () => _ssJson('/api/qobuz/auth/status').then(j => j.authenticated === true),
+ hifi: () => _ssJson('/api/hifi/status').then(j => j.available === true),
+ deezer_dl: () => _ssJson('/api/deezer-download/test').then(j => j.success === true),
+ amazon: () => _ssJson('/api/amazon/test-connection').then(j => j.connected === true),
+ lidarr: () => _ssTestConn('lidarr'),
+ soundcloud: () => _ssJson('/api/soundcloud/status').then(j => j.available === true && j.reachable === true),
+ torrent: () => _ssTestConn('torrent_client'),
+ usenet: () => _ssTestConn('usenet_client'),
+ youtube: () => Promise.resolve(true), // no auth required
+};
+// Configured metadata / server connections that support a generic test.
+const CONNECTION_TEST_SERVICES = ['spotify', 'server', 'tidal', 'qobuz', 'lastfm', 'genius', 'listenbrainz', 'acoustid', 'discogs'];
+
+async function testAllSources(opts = {}) {
+ const silent = opts.silent === true; // no toast / no connection sweep (used for auto-run on load)
+ const btn = document.getElementById('test-all-sources-btn');
+ if (btn && !silent) { btn.disabled = true; btn.dataset._label = btn.textContent; btn.textContent = 'Testing…'; }
+
+ // Which download sources to test: the enabled hybrid sources, or the single
+ // selected source in non-hybrid mode.
+ const mode = document.getElementById('download-source-mode')?.value;
+ const sources = new Set();
+ if (mode === 'hybrid') {
+ (typeof getHybridOrder === 'function' ? getHybridOrder() : []).forEach(s => sources.add(s));
+ } else if (mode) {
+ sources.add(mode);
+ }
+ if (sources.size === 0) sources.add('soulseek');
+
+ // Torrent/Usenet downloads go through Prowlarr — its connection must be
+ // established first or those source tests fail. Probe Prowlarr up front.
+ if (sources.has('torrent') || sources.has('usenet')) {
+ try { await _ssTestConn('prowlarr'); } catch (e) { /* surfaced via the per-source test below */ }
+ }
+
+ for (const id of sources) _hybridSourceStatus[id] = 'testing';
+ buildHybridSourceList();
+
+ let ok = 0, fail = 0;
+ for (const id of sources) {
+ const probe = HYBRID_SOURCE_PROBE[id];
+ if (!probe) { _hybridSourceStatus[id] = 'na'; continue; }
+ try { const good = await probe(); _hybridSourceStatus[id] = good ? 'ok' : 'fail'; good ? ok++ : fail++; }
+ catch (e) { _hybridSourceStatus[id] = 'fail'; fail++; }
+ buildHybridSourceList();
+ }
+
+ // Also test the metadata / server connections the user has configured
+ // (skipped on the silent auto-run to keep page load light).
+ let connOk = 0, connFail = 0;
+ if (!silent) {
+ try {
+ const cfg = await _ssJson('/api/settings/config-status');
+ for (const svc of CONNECTION_TEST_SERVICES) {
+ const configured = svc === 'server' ? true : (cfg && cfg[svc] && cfg[svc].configured);
+ if (!configured) continue;
+ try { const good = await _ssTestConn(svc); good ? connOk++ : connFail++; } catch (e) { connFail++; }
+ }
+ } catch (e) { /* config-status unavailable — skip connection sweep */ }
+ }
+
+ if (btn && !silent) { btn.disabled = false; btn.textContent = btn.dataset._label || 'Test all sources'; }
+ if (!silent) {
+ const parts = [`sources ${ok}✓${fail ? ' / ' + fail + '✗' : ''}`];
+ if (connOk || connFail) parts.push(`connections ${connOk}✓${connFail ? ' / ' + connFail + '✗' : ''}`);
+ showToast('Tested ' + parts.join(', '), (fail || connFail) ? 'error' : 'success');
+ }
+}
+window.testAllSources = testAllSources;
+
+// Auto-populate the source status dots once after the page settles, so they
+// reflect real state after a restart without the user having to click Test.
+let _sourcesAutoTested = false;
+function autoTestSourcesOnce() {
+ if (_sourcesAutoTested) return;
+ _sourcesAutoTested = true;
+ setTimeout(() => { try { testAllSources({ silent: true }); } catch (e) { } }, 1200);
+}
function buildHybridSourceList() {
const container = document.getElementById('hybrid-source-list');
@@ -689,11 +830,16 @@ function buildHybridSourceList() {
const sourceLevelBadge = `${sourceLevel}`;
const item = document.createElement('div');
- item.className = `hybrid-source-item${enabled ? '' : ' disabled'}`;
+ const isExpanded = enabled && _expandedHybridSource === srcId;
+ item.className = `hybrid-source-item${enabled ? '' : ' disabled'}${isExpanded ? ' config-open' : ''}`;
item.draggable = true;
item.dataset.sourceId = srcId;
+ // The name + a config chevron open this source's settings panel inline
+ // (only one at a time), so the long config blocks don't all stack up.
+ const clickConfig = enabled ? `onclick="toggleHybridSourceConfig('${srcId}')"` : '';
item.innerHTML = `
+ ⠿
@@ -702,9 +848,11 @@ function buildHybridSourceList() {
? ``
: `${src.emoji}`
}
- ${src.name}
+ ${src.name}
${sourceLevelBadge}
${priorityNum}
+
+ ${enabled ? `` : ''}
@@ -718,10 +866,15 @@ function buildHybridSourceList() {
e.dataTransfer.setData('text/plain', srcId);
item.classList.add('dragging');
});
- item.addEventListener('dragend', () => item.classList.remove('dragging'));
- item.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; });
+ item.addEventListener('dragend', () => {
+ item.classList.remove('dragging');
+ container.querySelectorAll('.hybrid-source-item').forEach(el => el.classList.remove('drag-over'));
+ });
+ item.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; item.classList.add('drag-over'); });
+ item.addEventListener('dragleave', () => item.classList.remove('drag-over'));
item.addEventListener('drop', (e) => {
e.preventDefault();
+ item.classList.remove('drag-over');
const draggedId = e.dataTransfer.getData('text/plain');
if (draggedId && draggedId !== srcId) _reorderHybridSource(draggedId, srcId);
});
@@ -768,6 +921,8 @@ function _reorderHybridSource(draggedId, targetId) {
function toggleHybridSource(srcId, enabled) {
_hybridSourceEnabled[srcId] = enabled;
+ // If the source we just disabled had its config panel open, close it.
+ if (!enabled && _expandedHybridSource === srcId) _expandedHybridSource = null;
// Rebuild enabled order from visual order so priority matches position
if (_hybridVisualOrder) {
_hybridSourceOrder = _hybridVisualOrder.filter(id => _hybridSourceEnabled[id] !== false);
@@ -1269,6 +1424,7 @@ async function loadSettingsData() {
document.getElementById('retries-per-query').value = settings.post_processing?.retries_per_query ?? 5;
document.getElementById('accept-version-mismatch-fallback').checked = settings.post_processing?.accept_version_mismatch_fallback === true;
document.getElementById('version-mismatch-min-count').value = settings.post_processing?.version_mismatch_min_count ?? 2;
+ if (typeof syncRetryConditionalRows === 'function') syncRetryConditionalRows();
// Load service master toggles
document.getElementById('embed-spotify').checked = settings.spotify?.embed_tags !== false;
document.getElementById('embed-itunes').checked = settings.itunes?.embed_tags !== false;
@@ -1861,21 +2017,43 @@ function updateDownloadSourceUI() {
activeSources.add(mode);
}
- soulseekContainer.style.display = activeSources.has('soulseek') ? 'block' : 'none';
- tidalContainer.style.display = activeSources.has('tidal') ? 'block' : 'none';
- qobuzContainer.style.display = activeSources.has('qobuz') ? 'block' : 'none';
- youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
- hifiContainer.style.display = activeSources.has('hifi') ? 'block' : 'none';
- if (deezerDlContainer) deezerDlContainer.style.display = activeSources.has('deezer_dl') ? 'block' : 'none';
- if (amazonContainer) amazonContainer.style.display = activeSources.has('amazon') ? 'block' : 'none';
- if (lidarrContainer) lidarrContainer.style.display = activeSources.has('lidarr') ? 'block' : 'none';
- if (soundcloudContainer) soundcloudContainer.style.display = activeSources.has('soundcloud') ? 'block' : 'none';
+ // In single-source mode the one config block is shown directly. In hybrid
+ // mode there can be many active sources, so we only reveal the one the user
+ // clicked open in the priority list (accordion-style) — no endless stack.
+ const isHybrid = mode === 'hybrid';
+ const showCfg = (src) => activeSources.has(src) && (!isHybrid || _expandedHybridSource === src);
+
+ soulseekContainer.style.display = showCfg('soulseek') ? 'block' : 'none';
+ tidalContainer.style.display = showCfg('tidal') ? 'block' : 'none';
+ qobuzContainer.style.display = showCfg('qobuz') ? 'block' : 'none';
+ youtubeContainer.style.display = showCfg('youtube') ? 'block' : 'none';
+ hifiContainer.style.display = showCfg('hifi') ? 'block' : 'none';
+ if (deezerDlContainer) deezerDlContainer.style.display = showCfg('deezer_dl') ? 'block' : 'none';
+ if (amazonContainer) amazonContainer.style.display = showCfg('amazon') ? 'block' : 'none';
+ if (lidarrContainer) lidarrContainer.style.display = showCfg('lidarr') ? 'block' : 'none';
+ if (soundcloudContainer) soundcloudContainer.style.display = showCfg('soundcloud') ? 'block' : 'none';
const prowlarrRedirect = document.getElementById('prowlarr-source-redirect');
if (prowlarrRedirect) {
- const showProwlarr = activeSources.has('torrent') || activeSources.has('usenet');
+ const showProwlarr = showCfg('torrent') || showCfg('usenet');
prowlarrRedirect.style.display = showProwlarr ? 'block' : 'none';
}
+ // Indexers & Downloaders section: only relevant when a torrent or usenet
+ // source is actually selected. Hide the whole intro + Prowlarr/Torrent/Usenet
+ // tiles otherwise (Soulseek/HiFi-only users never see them). The two client
+ // tiles are gated individually on their own source. Selection-based (not the
+ // hybrid expand state) — the tiles are full config sections, not accordion
+ // panels. Tab-gated so it never leaks onto another tab.
+ const onDownloadsTab = document.querySelector('.stg-tab.active')?.dataset.tab === 'downloads';
+ const torrentActive = activeSources.has('torrent');
+ const usenetActive = activeSources.has('usenet');
+ const indSection = document.getElementById('indexers-downloaders-section');
+ if (indSection) indSection.style.display = (onDownloadsTab && (torrentActive || usenetActive)) ? '' : 'none';
+ const torrentTile = document.getElementById('torrent-tile');
+ if (torrentTile) torrentTile.style.display = torrentActive ? '' : 'none';
+ const usenetTile = document.getElementById('usenet-tile');
+ if (usenetTile) usenetTile.style.display = usenetActive ? '' : 'none';
+
// Quality profile is now a GLOBAL system — the same ranked-target list
// drives every source (Soulseek, Tidal, Qobuz, HiFi, Deezer, …), so it is
// no longer Soulseek-gated. Show the whole collapsible tile whenever the
@@ -1884,23 +2062,25 @@ function updateDownloadSourceUI() {
const qualityProfileTile = document.getElementById('quality-profile-tile');
if (qualityProfileTile) {
const activeTab = document.querySelector('.stg-tab.active');
- const onDownloadsTab = activeTab && activeTab.dataset.tab === 'downloads';
- qualityProfileTile.style.display = onDownloadsTab ? '' : 'none';
+ const onQualityTab = activeTab && activeTab.dataset.tab === 'quality';
+ qualityProfileTile.style.display = onQualityTab ? '' : 'none';
}
- if (activeSources.has('tidal')) {
+ // Only auto-probe a source's live status when its config panel is visible
+ // (always in single-source mode; only the opened one in hybrid mode).
+ if (showCfg('tidal')) {
checkTidalDownloadAuthStatus();
}
- if (activeSources.has('qobuz')) {
+ if (showCfg('qobuz')) {
checkQobuzAuthStatus();
}
- if (activeSources.has('hifi')) {
+ if (showCfg('hifi')) {
testHiFiConnection();
}
- if (activeSources.has('amazon')) {
+ if (showCfg('amazon')) {
testAmazonConnection();
}
- if (activeSources.has('soundcloud')) {
+ if (showCfg('soundcloud')) {
testSoundcloudConnection();
}
}
@@ -2021,12 +2201,30 @@ function onSearchModeChange() {
// body is the immediate next element or sits after a control (e.g. a