Music Management
This document is based on the following source files:
internal/services/scanner.go-- File scanner (Walker, exclusion rules, symlink loop breaking)internal/services/metadata.go-- Audio metadata extraction (dual channel: tag + ffprobe)internal/services/song_service.go-- Song service (ScanAndImport, remote songs, song organization)internal/services/fingerprint.go-- Chromaprint audio fingerprint computationinternal/services/auto_scan.go-- Auto scan schedulinginternal/services/scan_progress.go-- Scan progress tracking and state machineinternal/services/song_file_writer.go-- Tag writing (atomic write-back to audio files)internal/handlers/scan.go-- Scan-related HTTP handlersinternal/handlers/music.go-- Song management HTTP handlersinternal/app/app.go-- Supported-format whitelist initialization
Table of Contents
- File Scanning Flow
- Metadata Extraction
- Song Import (ScanAndImport)
- Remote Songs and Radio Stations
- Fingerprint Computation
- Auto Scan
- Tag Writing
- Song File Organization
- Scan Progress Tracking
- Song Download (remote → local)
1. File Scanning Flow
Section sources: internal/services/scanner.go, internal/app/app.go
1.1 Scanner Architecture
Scanner is a stateless file walker driven by ScanConfig:
| Config field | Description | Default |
|---|---|---|
MusicPath | Music root directory | music (relative to the data directory) |
ExcludeDirs | Directories excluded by name match | ["@eaDir", "tmp"] |
ExcludePaths | Paths excluded by exact full-path match | [] |
SupportedFormats | Allowed audio extensions | ["mp3", "flac", "wav", "ape", "ogg", "m4a", "mp4", "mov", "wma", "aif", "aiff", "mka", "mkv", "webm", "avi", "ts", "mpg", "opus", "m4b", "oga", "mpeg", "m4v", "flv", "wmv", "rm", "rmvb", "3gp"] |
The config is stored under the music_path key in the config table and read/written through the /api/v1/settings/music-path business endpoint. When the config changes, the onMusicPathChanged callback fires, rebuilding the Scanner instance and cleaning up existing songs within the excluded areas.
1.2 Recursive Traversal (Walker)
ScanFiles starts from MusicPath and recursively traverses the directory tree; the core logic lives in the scanDir method:
- Symlink support: Uses
filepath.EvalSymlinksto resolve the real path, andos.Statfollows symlinks to obtain file info; symlinked directories are also traversed recursively. - Loop detection: Maintains a
visited map[string]boolrecording already-visited real paths; duplicate paths are skipped directly, preventing infinite recursion caused by loops formed by symbolic links. - Cancelable: Checks
ctx.Done()when entering each new directory and processing each entry, supporting cancellation of the scan via context. - Format filtering:
IsAudioFileextracts the file extension (normalized to lowercase with the dot stripped) and compares it one by one against theSupportedFormatslist.
1.3 Exclusion Rules
ShouldExcludeDir implements two levels of exclusion:
- Exact path exclusion (
ExcludePaths): excluded when the target path equals an excluded path or is a subdirectory of it. The typical use case is excluding a specific subdirectory. - Name pattern exclusion (
ExcludeDirs): the path is split by the path separator and each segment is checked; the path is excluded if any level contains an excluded name. The typical use case is excluding NAS-generated@eaDirthumbnail directories.
IsFileInExcludedArea determines exclusion for the file paths of already-imported songs, used by CleanInvalidSongs.
1.4 Helper Functions
ListSubDirs: Returns the first-level subdirectories under a given directory (including a flag for whether they have deeper subdirectories), used for lazy loading of the frontend directory tree.CollectAllDirNames: Recursively collects and sorts all directory names, used for autocompletion of excluded directory names.GetFileInfo: Returns basic file info (path, name, size, modification time, format).
2. Metadata Extraction
Section sources: internal/services/metadata.go
2.1 Dual-Channel Extraction Strategy
MetadataExtractor.Extract adopts a layered strategy of tag library first, ffprobe as fallback:
tag library (github.com/hanxi/tag)
|
+-- success --> extract Title/Artist/Album/Duration/Cover/Lyrics/ISRC
| |
| +-- Duration > 0 --> done (ffprobe not called)
| +-- Duration = 0 --> fall back to ffprobe to supplement duration and technical parameters
|
+-- failure --> fall back to ffprobe to extract all informationThis design lets most MP3/FLAC/M4A files avoid launching an ffprobe subprocess, significantly improving batch scan performance. ffprobe is only called for formats the tag library cannot parse (such as WMA or certain APE variants) or when VBR duration is inaccurate.
2.2 ffprobe Invocation
runFFProbe runs ffprobe in JSON output format:
ffprobe -v quiet -print_format json -show_format -show_streams <filePath>It parses the output FFProbeOutput structure, obtaining duration, bitrate, and format name from the format section, and sample rate from the streams section. When the tag library does not parse out tags, mergeFFProbeTags merges format.tags with the audio stream tags (format takes precedence), and pickTag extracts title/artist/album/lyrics following the candidate key order.
2.3 Title Source Configuration
MetadataConfig.TitleSource supports two modes:
| Value | Behavior |
|---|---|
"tag" (default) | Use the tag title if present, otherwise use the filename (with extension removed) |
"filename" | Always use the filename (with extension removed) as the title |
Configured via the /api/v1/settings/scan-title-source endpoint. The mergeTitle function implements the simple rule of "use the tag if it has one, otherwise use the filename." An earlier version performed "longest common substring deduplication + concatenation," but this was removed because it caused the artist to be redundantly copied into the title field.
2.4 Cover Art Extraction and Deduplicated Storage
Cover art is obtained from the tag library's Picture() method as raw byte data plus an extension. SaveCover / SaveCoverData generates a layered directory path from the content's SHA-256 hash:
{CoverStoragePath}/{hash[0:2]}/{hash[2:4]}/{fullhash}.{ext}Cover art with identical content is automatically deduplicated (written to the same path), avoiding an excessive number of files in a single directory. When deleting a song, removeCoverIfUnreferenced uses reference counting to decide whether the physical file can be safely deleted.
2.5 Lyrics Extraction
Priority: external .lrc file > embedded lyrics (tag). FindLyricFile looks for a .lrc file with the same name in the same directory as the audio file; on a hit, ReadLyricFile reads the content and tag.FixEncoding corrects the encoding.
2.6 ISRC Extraction
extractISRC searches the tag library's Raw() raw tag data following the candidate key order: TSRC for ID3v2.3/2.4, TRC for ID3v2.2, and isrc for Vorbis/FLAC.
2.7 Video Stream Detection (is_video)
Metadata.IsVideo marks whether an audio file contains a real video picture (written to models.Song.IsVideo; the client uses it to render the picture / choose the casting mime). Detection logic:
isVideoContainerCandidatefirst filters by extension for containers that may contain video (mp4/mov/m4v/mkv/webm/avi/ts/mpg/mpeg/flv/wmv/rm/rmvb/3gp).- On a candidate hit with ffprobe configured,
hasRealVideoStreamis called to determine whether the ffprobe result contains a real video stream. - Excluding fake cover-art video streams: an embedded cover appears as
codec_type=video+disposition.attached_pic=1and is skipped; static-image codecs such asmjpeg/png/bmp/gifare also excluded by name as a safety net. - Reuse optimization:
mp4/movfiles containing a video stream are successfully read by the tag library (yielding a duration) and do not enter the duration-fallback ffprobe branch, so video container candidates are probed independently here; already-probed files (such as taglessmkv) reuseprobeto avoid a duplicate call.
3. Song Import (ScanAndImport)
Section sources: internal/services/song_service.go
3.1 Overall Flow
ScanAndImportAsync is the async entry point; through ScanProgressManager it guarantees that only one scan task runs at any given moment. The core logic lives in doScanAndImport:
ScanAndImportAsync
|
+-- scanProgressManager.Start() -- acquire the scan lock
|
+-- go doScanAndImport(ctx, reimport)
|
(1) scanner.ScanFiles() -- traverse files
|
(2) pre-filter -- compare against ListLocalPaths, skip files that already exist and have duration>0
| -- file stability check (skip if modification time < 10s)
|
(3) cleanStaleRecords -- clean up stale records whose files no longer exist on disk
|
(4) concurrent metadata extraction -- 4-worker pool
|
(5) fixSpamTags -- spam tag detection
|
(6) batch import -- one transaction per 50 records
|
(7) runAutoCreatePlaylists -- automatically create playlists by directory structure
|
(8) runAutoFingerprint -- automatically compute missing fingerprints3.2 Pre-Filtering and Deduplication
The {path -> (songID, duration)} mapping of all local songs in the database is obtained from ListLocalPaths. For each scanned file:
- Already exists and duration > 0 and not a re-import --> skip (marked as skipped)
- Already exists but duration = 0, or a re-import --> re-extract metadata, recording
existingSongIDfor the UPDATE - Does not exist --> new file, marked as pending
File stability check: files whose modification time is < 10 seconds from the current moment are treated as "currently being copied" and skipped, to avoid importing incomplete files.
3.3 Concurrent Metadata Extraction
A worker pool of metadataWorkers = 4 goroutines is used, dispatching tasks via inputCh and collecting results via resultCh. Each worker:
- Calls
safeExtractMetadatato extract metadata (recovering from panic so a single-file error does not crash the entire scan) - Saves the cover art after successful extraction and releases the
CoverDatamemory - Obtains file size info
After all workers finish, resultCh is closed and the main goroutine collects all results into the allResults slice.
3.4 Spam Tag Detection
fixSpamTags detects, before import, the case where many files in the same directory have completely identical (title, artist) (such as advertising tags in some pirated audio):
- Groups by directory and counts the most frequent
(title, artist)pair - Judged as spam tags when the frequency >= 3 and it accounts for > 50% of the total files in that directory
- Falls the title of these files back to the filename and clears the artist
3.5 Batch Transactional Import
flushScanBatch processes in batches of dbBatchSize = 50 records, completing each in a single transaction via Transactor.RunInTx:
- Re-import (
existingSongID > 0): reads the existing song object and updates all metadata fields. Lyrics withlyric_source=manualare not overwritten, protecting manual user adjustments. - New import: creates a
models.Songobject of typeTypeLocaland callsCreateto import it.
3.6 Stale Record Cleanup
cleanStaleRecords compares the scanned file set with the database records to find file paths that exist in the database but no longer exist on disk, and batch-deletes them after a second confirmation via os.Stat.
3.7 Post Actions
After the scan completes, the following are executed in order:
- Auto-create playlists (if
scan_auto_create_playlistsis configured to true): callsPlaylistAutoCreator.AutoCreateto rebuild theauto_createdplaylists by directory structure. - Auto fingerprint computation (if chromaprint is available and a
FingerprintServicehas been injected): callsComputeMissingto asynchronously compute fingerprints for songs missing them.
4. Remote Songs and Radio Stations
Section sources: internal/services/song_service.go, internal/handlers/music.go
4.1 AddRemoteSongs
Batch-adds network songs, supporting two audio-source identifiers:
| Mode | Required fields | Description |
|---|---|---|
| Plain external link | url + title | Direct HTTP(S) URL |
| Plugin source | plugin_entry_path + source_data + title | Audio source resolved by a plugin |
Deduplication is achieved via UpsertRemote: when dedup_key is non-empty, an UPSERT is performed on (dedup_key) (a plugin-defined deduplication key, typically of the form <platform>:<platform_id>); when dedup_key is empty, a direct INSERT is performed.
The lyrics fields lyric / lyric_source are supported and handled uniformly via models.ApplyLyricToSong.
is_video field: remote songs do not go through scan-time ffprobe detection; instead the caller (plugin/client) declares IsVideo directly in the input, marking whether the network song contains a video picture.
4.2 AddRadios
Batch-adds radio/broadcast stations; the song type is TypeRadio and is_live = true. They are batch-imported via BatchCreate without deduplication. A radio station must provide url and title. Radio stations can likewise declare is_video (marking whether it is a video radio station containing a live picture).
5. Fingerprint Computation
Section sources: internal/services/fingerprint.go, internal/handlers/scan.go
5.1 Chromaprint Availability Detection
IsChromaprintAvailable detects whether the output of ffmpeg -hide_banner -muxers contains chromaprint; the result is cached via sync.Once and detected only once globally.
5.2 Fingerprint Extraction
ExtractFingerprint invokes the ffmpeg chromaprint muxer:
ffmpeg -i <filePath> -map 0:a:0 -map_metadata -1 -f chromaprint -fp_format base64 -- Timeout: 15-second context timeout
- Output: a base64-encoded fingerprint string (read from stdout, taking the first line)
- Duration: parsed from stderr in the
Duration: HH:MM:SS.XXformat
5.3 Asynchronous Batch Computation
FingerprintService manages the async lifecycle of fingerprint computation:
| Method | Behavior |
|---|---|
ComputeMissing | Compute fingerprints for all local songs missing them |
RecomputeAll | Clear all existing fingerprints and recompute all of them |
If a task is already running, the old task is canceled and awaited before the new one starts.
doCompute uses fpWorkers = 4 concurrent workers, dispatching tasks via a channel. After calling ExtractFingerprint, each worker writes the fingerprint and duration to the database via UpdateFingerprint. Progress is queryable in real time via FingerprintProgress (status/computed/total/failed).
5.4 Duplicate Detection
After fingerprints are imported, ListDuplicateGroups can query all groups of local songs sharing the same fingerprint. The frontend displays duplicate songs via GET /songs/duplicates and supports deleting duplicate files via POST /songs/batch-delete (with delete_files=true).
6. Auto Scan
Section sources: internal/services/auto_scan.go, internal/handlers/scan.go
6.1 AutoScanner Scheduler
AutoScanner implements periodic scanning based on time.Ticker:
ApplyConfig(cfg)
|
+-- stopLocked() -- stop the old timer
|
+-- cfg.Enabled == false --> return (do not start)
|
+-- interval clamping --> [60s, 86400s]
|
+-- go run(ctx, interval)
|
+-- ticker.C --> ScanAndImportAsync(reimport=false)
| (silently skipped when a scan is already in progress)
|
+-- ctx.Done() --> exit6.2 Config Endpoint
Read/written via /api/v1/settings/auto-scan:
{
"enabled": false,
"interval_seconds": 3600
}interval_secondshas a valid range of [60, 86400]; out-of-range values are clamped to the boundary- Takes effect immediately after PUT: the scheduler is restarted via the
onAutoScanChangedcallback, no service restart needed - Disabled by default, with a 1-hour interval
7. Tag Writing
Section sources: internal/services/song_file_writer.go
7.1 WriteSongTags Function
WriteSongTags writes a song's complete metadata back to the audio file and is the unified entry point for all tag writing.
Key constraint: pkg/tag.WriteTag uses a "rebuild the tag block" mode (not incremental); unfilled fields are cleared. Therefore a complete *models.Song must be passed in, writing back all fields you want to retain (Title/Artist/Album/Year/Genre/Lyrics/Picture) at once.
7.2 Write Flow
WriteSongTags(filePath, song)
|
(1) build tag.WriteOptions -- fill in all fields
| |
| +-- Lyrics: unpack the main lyrics from the LyricPayload JSON
| +-- Picture: read the cover art file from song.CoverPath
| +-- clear Lyrics when lyric_source=url (do not write back remote lyrics)
|
(2) tagsUnchanged comparison -- read the file's existing tags and compare field by field
| |
| +-- all identical --> return FileWriteSkipped
| +-- any difference --> continue writing
|
(3) tag.WriteTag(filePath, opts) -- atomic write
|
+-- success --> FileWriteWritten
+-- ErrUnsupportedWrite --> FileWriteUnchanged (format whose extension is not in the support matrix)
+-- other errors --> FileWriteFailed7.3 Format Support
pkg/tag.WriteTag dispatches by extension; all supported formats use "temporary file + os.Rename" atomic writes. Write matrix:
| Format | Text fields | Lyrics | Cover art |
|---|---|---|---|
| MP3 | ID3v2.3 text frames | USLT | APIC |
| FLAC | Vorbis Comment | LYRICS | PICTURE block |
| M4A/MP4/M4B/MOV | iTunes atoms (©nam, etc.) | ©lyr | covr |
| OGG(.ogg/.oga) | Vorbis Comment | LYRICS | METADATA_BLOCK_PICTURE (base64) |
| APE | APEv2 text items | Lyrics | Cover Art (Front) (binary item) |
| WAV | RIFF LIST INFO | ICMT | not supported (format limitation) |
| AIFF/AIF | ID3v2.3 (ID3 chunk) + NAME/AUTH | USLT (ID3 chunk) | APIC (ID3 chunk) |
Only formats outside the extensions above (such as WMA and other video containers) return ErrUnsupportedWrite. WAV supports writing text and lyrics but, due to format limitations, does not support cover art. Formats that do not support writing do not block the main flow; they simply return the FileWriteUnchanged status, and the caller decides how to handle it.
7.4 Status Semantics
| Status | Meaning |
|---|---|
written | File written back successfully |
unchanged | No write-back attempted (non-local song / no file path / unsupported format / url-sourced lyrics) |
skipped | The file tags match the content to be written; write skipped |
failed | Write attempted but failed (IO / parse error); the DB already succeeded |
7.5 Atomic Write Guarantee
pkg/tag.WriteTag internally uses a temporary file + os.Rename to achieve atomic writes. The temporary file is created in the same directory as the source file (os.CreateTemp(dir, ...)), guaranteeing that the rename operation stays within the same file system and does not trigger a cross-device error.
8. Song File Organization
Section sources: internal/services/song_service.go, internal/handlers/music.go
8.1 OrganizeSongs
OrganizeSongs batch-moves/renames local song files; the frontend calls it when organizing files into an Artist/Album structure.
Each operation is handled independently by organizeOne:
- Validation: the song must be of local type and have a file path
- Path safety:
target_pathmust not have a..prefix (to prevent directory traversal), an absolute path must be undermusicPath, and the extension must match the original file - Directory creation:
os.MkdirAllautomatically creates the target directory structure - File move: uses
moveFilerather than a bareos.Rename— it tries rename first, and automatically falls back to copy + remove across devices - Database update: updates
song.FilePathto the new path. If the database update fails, the file move is rolled back - Cleanup: attempts to delete the now-empty original directory of the source file
Diagram sources: internal/services/song_service.go -- organizeOne method
organizeOne(musicPath, item)
|
+-- GetByID --> validate type=local
|
+-- path safety validation --> prevent traversal, prevent out-of-bounds, match extension
|
+-- moveFile(absSource, absTarget)
| |
| +-- failure --> return error
|
+-- songs.Update(song with new FilePath)
| |
| +-- failure --> moveFile(absTarget, absSource) rollback
|
+-- os.Remove(original directory) -- clean up the empty directory
|
+-- return ok + new path9. Scan Progress Tracking
Section sources: internal/services/scan_progress.go, internal/handlers/scan.go
9.1 State Machine
ScanProgressManager manages the full scan lifecycle through a state machine protected by sync.RWMutex:
idle --> scanning --> importing --> creating_playlists --> completed
| | | |
| +-----> cancelling --> cancelled |
| | | |
| +----------> failed |
| |
+-----------------------------------------------------------+| Status | Meaning |
|---|---|
idle | Idle; a new scan can be started |
scanning | Traversing the file system |
importing | File count determined; extracting metadata and importing |
creating_playlists | Auto-create-playlists phase (not cancelable) |
completed | Scan complete |
failed | Scan failed |
cancelling | User requested cancellation; waiting for workers to exit |
cancelled | Cancelled |
9.2 Progress Data
The ScanProgress struct contains:
| Field | Description |
|---|---|
TotalFiles | Total number of files discovered by the scan |
ScannedFiles | Number of files processed (including skipped and failed) |
ImportedFiles | Number of files imported successfully |
SkippedFiles | Number of files skipped (already exist) |
FailedFiles | Number of files that failed processing |
CleanedFiles | Number of stale records cleaned up |
CurrentFile | Path of the file currently being processed |
StartTime / EndTime | Scan start/end time |
Error | Error message (only in the failed state) |
9.3 Cancellation Mechanism
The Cancel method closes the cancel chan struct{}; all workers check the cancellation signal on each loop iteration and channel send, ensuring a fast response to cancellation requests. The creating_playlists phase does not allow cancellation (the transaction is already being committed).
9.4 HTTP API
| Endpoint | Method | Description |
|---|---|---|
/api/v1/scan | POST | Start a scan (reimport=true forces re-import) |
/api/v1/scan/progress | GET | Get current progress (polling) |
/api/v1/scan/cancel | POST | Cancel an in-progress scan |
/api/v1/settings/music-path | GET/PUT | Music path and exclusion config |
/api/v1/settings/auto-scan | GET/PUT | Auto-scan toggle and interval |
/api/v1/settings/scan-title-source | GET/PUT | Title source (tag / filename) |
/api/v1/settings/scan-auto-create-playlists | GET/PUT | Whether to auto-create playlists after a scan |
/api/v1/settings/scan-playlist-mode | GET/PUT | Directory playlist merge mode: directory/top_level/bubble_up |
/api/v1/scan/fingerprints | POST | Trigger batch fingerprint computation |
/api/v1/scan/fingerprints/status | GET | Fingerprint computation status and stats |
/api/v1/scan/fingerprints/progress | GET | Fingerprint computation progress |
/api/v1/scan/directories | GET | Lazy loading of the directory tree |
/api/v1/scan/dir-names | GET | Directory name autocompletion |
10. Song Download (remote → local)
Source files: internal/services/song_downloader.go, internal/database/song_repository.go
10.1 Overview
SongDownloader.Download downloads a remote song into the local music library, converting its type from remote to local. This is the sole entry point for remote song localization, and can also be triggered automatically by TryAutoDownload upon cache completion.
10.2 Download Flow
Download(songID, opts)
|
(1) GetByID --> verify type=remote
|
(2) resolveTargetDir --> determine target directory (opts.TargetDir or music_path)
|
(3) ParsePathTemplate --> parse path template (e.g. "{artist}/{title}")
|
(4) acquireAudio --> acquire audio file (reuse cache hit, or download synchronously)
|
(5) Optional transcode --> if Format is set, convert to target format (e.g. mov → mp3)
|
(6) uniqueDestPath --> append sequence number on path conflict ("Song.mp3" → "Song (2).mp3")
|
(7) copyFile --> copy to target path
|
(8) Optional metadata embed --> WriteCacheSongTags (including lyrics fetch)
|
(9) Update DB --> type=local, file_path=target path, clear playback source fields10.3 Field Retention Rules (Critical)
When updating the song record after download, each field is handled as follows:
| Field | Action | Reason |
|---|---|---|
type | Set to local | Song is now local |
file_path | Set to target path | Required for local playback |
url | Clear | Direct URL no longer needed |
source_data | Clear | Plugin playback data no longer needed; also makes IsPluginSourced() return false, blocking source orchestration / metadata probing and other remote-song paths |
cache_path | Clear | Cache file no longer associated |
plugin_entry_path | Retain | Dedup identity — see below |
dedup_key | Retain | Dedup identity — see below |
plugin_entry_path and dedup_key MUST be retained. Reasons:
- Dedup chain integrity:
UpsertRemotelooks up existing songs by(plugin_entry_path, dedup_key). Ifplugin_entry_pathwere cleared on download, a subsequent re-import of the same playlist would fail to find the already-downloaded song viaFindSongByDedupKey, creating a duplicate record. Downloading that duplicate would then collide on the unique index(plugin_entry_path="", dedup_key), triggeringUNIQUE constraint failed. - UpsertRemote already handles this: When
UpsertRemotehits an existing row withtype=local, it merely reuses the ID without overwriting any fields — remote input parameters will not pollute post-localization metadata. - Safety:
IsPluginSourced()requires bothPluginEntryPathandSourceDatato be non-empty to returntrue. Since download clearsSourceData, all code paths gated byIsPluginSourced()(source orchestration, metadata probing, cache fetching) will not be triggered for the downloaded song.
10.4 Dedup Lifecycle
The complete dedup lifecycle:
Import --> UpsertRemote looks up by (plugin_entry_path, dedup_key)
|
+-- Not found --> CREATE (new song, type=remote)
|
+-- Found, type=remote --> UPDATE mutable fields (refresh source_data, etc.)
|
+-- Found, type=local --> Reuse ID only, no field modificationsRetaining dedup fields after download ensures the third path above is correctly hit:
(1) Import song A: (plugin_entry_path="ytdlp", dedup_key="bilibili:123", type=remote)
(2) Download song A: (plugin_entry_path="ytdlp", dedup_key="bilibili:123", type=local)
↑ plugin_entry_path retained, unique index slot unchanged
(3) Re-import same song: UpsertRemote hits song A, finds type=local, reuses ID
→ no duplicate record, no constraint violation10.5 Destination Path Dedup
uniqueDestPath prevents different songs that render to the same save path (e.g. same title, different versions) from overwriting each other. When the target file already exists, a sequence number is appended before the extension: Song.mp3 → Song (2).mp3 → Song (3).mp3.
10.6 Auto Download
TryAutoDownload is triggered by the cache-completion callback and executes download when all of the following conditions are met:
AutoDownloadConfig.Enabled = true(registered by plugin via bridge API)song.PluginEntryPath != ""(song originates from a plugin)song.Type == TypeRemote(not yet downloaded)
Condition 3 ensures already-downloaded local songs are not re-triggered.
10.7 Download Activity Gate
DownloadActivity provides a semaphore signaling that a download is in progress — Begin() on start, End() on finish. The background metadata prober (MetadataRefresher) yields while download activity is ongoing, preventing probing and downloading from contending for CPU (issue #265).
