Skip to content

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 computation
  • internal/services/auto_scan.go -- Auto scan scheduling
  • internal/services/scan_progress.go -- Scan progress tracking and state machine
  • internal/services/song_file_writer.go -- Tag writing (atomic write-back to audio files)
  • internal/handlers/scan.go -- Scan-related HTTP handlers
  • internal/handlers/music.go -- Song management HTTP handlers
  • internal/app/app.go -- Supported-format whitelist initialization

Table of Contents

  1. File Scanning Flow
  2. Metadata Extraction
  3. Song Import (ScanAndImport)
  4. Remote Songs and Radio Stations
  5. Fingerprint Computation
  6. Auto Scan
  7. Tag Writing
  8. Song File Organization
  9. Scan Progress Tracking
  10. 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 fieldDescriptionDefault
MusicPathMusic root directorymusic (relative to the data directory)
ExcludeDirsDirectories excluded by name match["@eaDir", "tmp"]
ExcludePathsPaths excluded by exact full-path match[]
SupportedFormatsAllowed 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:

  1. Symlink support: Uses filepath.EvalSymlinks to resolve the real path, and os.Stat follows symlinks to obtain file info; symlinked directories are also traversed recursively.
  2. Loop detection: Maintains a visited map[string]bool recording already-visited real paths; duplicate paths are skipped directly, preventing infinite recursion caused by loops formed by symbolic links.
  3. Cancelable: Checks ctx.Done() when entering each new directory and processing each entry, supporting cancellation of the scan via context.
  4. Format filtering: IsAudioFile extracts the file extension (normalized to lowercase with the dot stripped) and compares it one by one against the SupportedFormats list.

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 @eaDir thumbnail 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 information

This 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:

ValueBehavior
"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:

  • isVideoContainerCandidate first 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, hasRealVideoStream is 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=1 and is skipped; static-image codecs such as mjpeg/png/bmp/gif are also excluded by name as a safety net.
  • Reuse optimization: mp4/mov files 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 tagless mkv) reuse probe to 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 fingerprints

3.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 existingSongID for 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:

  1. Calls safeExtractMetadata to extract metadata (recovering from panic so a single-file error does not crash the entire scan)
  2. Saves the cover art after successful extraction and releases the CoverData memory
  3. 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 with lyric_source=manual are not overwritten, protecting manual user adjustments.
  • New import: creates a models.Song object of type TypeLocal and calls Create to 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:

  1. Auto-create playlists (if scan_auto_create_playlists is configured to true): calls PlaylistAutoCreator.AutoCreate to rebuild the auto_created playlists by directory structure.
  2. Auto fingerprint computation (if chromaprint is available and a FingerprintService has been injected): calls ComputeMissing to 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:

ModeRequired fieldsDescription
Plain external linkurl + titleDirect HTTP(S) URL
Plugin sourceplugin_entry_path + source_data + titleAudio 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.XX format

5.3 Asynchronous Batch Computation

FingerprintService manages the async lifecycle of fingerprint computation:

MethodBehavior
ComputeMissingCompute fingerprints for all local songs missing them
RecomputeAllClear 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() --> exit

6.2 Config Endpoint

Read/written via /api/v1/settings/auto-scan:

json
{
  "enabled": false,
  "interval_seconds": 3600
}
  • interval_seconds has 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 onAutoScanChanged callback, 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 --> FileWriteFailed

7.3 Format Support

pkg/tag.WriteTag dispatches by extension; all supported formats use "temporary file + os.Rename" atomic writes. Write matrix:

FormatText fieldsLyricsCover art
MP3ID3v2.3 text framesUSLTAPIC
FLACVorbis CommentLYRICSPICTURE block
M4A/MP4/M4B/MOViTunes atoms (©nam, etc.)©lyrcovr
OGG(.ogg/.oga)Vorbis CommentLYRICSMETADATA_BLOCK_PICTURE (base64)
APEAPEv2 text itemsLyricsCover Art (Front) (binary item)
WAVRIFF LIST INFOICMTnot supported (format limitation)
AIFF/AIFID3v2.3 (ID3 chunk) + NAME/AUTHUSLT (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

StatusMeaning
writtenFile written back successfully
unchangedNo write-back attempted (non-local song / no file path / unsupported format / url-sourced lyrics)
skippedThe file tags match the content to be written; write skipped
failedWrite 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:

  1. Validation: the song must be of local type and have a file path
  2. Path safety: target_path must not have a .. prefix (to prevent directory traversal), an absolute path must be under musicPath, and the extension must match the original file
  3. Directory creation: os.MkdirAll automatically creates the target directory structure
  4. File move: uses moveFile rather than a bare os.Rename — it tries rename first, and automatically falls back to copy + remove across devices
  5. Database update: updates song.FilePath to the new path. If the database update fails, the file move is rolled back
  6. 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 path

9. 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                               |
  |                                                           |
  +-----------------------------------------------------------+
StatusMeaning
idleIdle; a new scan can be started
scanningTraversing the file system
importingFile count determined; extracting metadata and importing
creating_playlistsAuto-create-playlists phase (not cancelable)
completedScan complete
failedScan failed
cancellingUser requested cancellation; waiting for workers to exit
cancelledCancelled

9.2 Progress Data

The ScanProgress struct contains:

FieldDescription
TotalFilesTotal number of files discovered by the scan
ScannedFilesNumber of files processed (including skipped and failed)
ImportedFilesNumber of files imported successfully
SkippedFilesNumber of files skipped (already exist)
FailedFilesNumber of files that failed processing
CleanedFilesNumber of stale records cleaned up
CurrentFilePath of the file currently being processed
StartTime / EndTimeScan start/end time
ErrorError 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

EndpointMethodDescription
/api/v1/scanPOSTStart a scan (reimport=true forces re-import)
/api/v1/scan/progressGETGet current progress (polling)
/api/v1/scan/cancelPOSTCancel an in-progress scan
/api/v1/settings/music-pathGET/PUTMusic path and exclusion config
/api/v1/settings/auto-scanGET/PUTAuto-scan toggle and interval
/api/v1/settings/scan-title-sourceGET/PUTTitle source (tag / filename)
/api/v1/settings/scan-auto-create-playlistsGET/PUTWhether to auto-create playlists after a scan
/api/v1/settings/scan-playlist-modeGET/PUTDirectory playlist merge mode: directory/top_level/bubble_up
/api/v1/scan/fingerprintsPOSTTrigger batch fingerprint computation
/api/v1/scan/fingerprints/statusGETFingerprint computation status and stats
/api/v1/scan/fingerprints/progressGETFingerprint computation progress
/api/v1/scan/directoriesGETLazy loading of the directory tree
/api/v1/scan/dir-namesGETDirectory 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 fields

10.3 Field Retention Rules (Critical)

When updating the song record after download, each field is handled as follows:

FieldActionReason
typeSet to localSong is now local
file_pathSet to target pathRequired for local playback
urlClearDirect URL no longer needed
source_dataClearPlugin playback data no longer needed; also makes IsPluginSourced() return false, blocking source orchestration / metadata probing and other remote-song paths
cache_pathClearCache file no longer associated
plugin_entry_pathRetainDedup identity — see below
dedup_keyRetainDedup identity — see below

plugin_entry_path and dedup_key MUST be retained. Reasons:

  1. Dedup chain integrity: UpsertRemote looks up existing songs by (plugin_entry_path, dedup_key). If plugin_entry_path were cleared on download, a subsequent re-import of the same playlist would fail to find the already-downloaded song via FindSongByDedupKey, creating a duplicate record. Downloading that duplicate would then collide on the unique index (plugin_entry_path="", dedup_key), triggering UNIQUE constraint failed.
  2. UpsertRemote already handles this: When UpsertRemote hits an existing row with type=local, it merely reuses the ID without overwriting any fields — remote input parameters will not pollute post-localization metadata.
  3. Safety: IsPluginSourced() requires both PluginEntryPath and SourceData to be non-empty to return true. Since download clears SourceData, all code paths gated by IsPluginSourced() (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 modifications

Retaining 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 violation

10.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.mp3Song (2).mp3Song (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:

  1. AutoDownloadConfig.Enabled = true (registered by plugin via bridge API)
  2. song.PluginEntryPath != "" (song originates from a plugin)
  3. 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).