音乐管理
本文档基于以下源文件编写:
- internal/services/scanner.go -- 文件扫描器(Walker、排除规则、软链接去环)
- internal/services/metadata.go -- 音频元数据提取(tag + ffprobe 双通道)
- internal/services/song_service.go -- 歌曲服务(ScanAndImport、远程歌曲、歌曲整理)
- internal/services/fingerprint.go -- Chromaprint 音频指纹计算
- internal/services/auto_scan.go -- 自动扫描定时调度
- internal/services/scan_progress.go -- 扫描进度追踪与状态机
- internal/services/song_file_writer.go -- Tag 写入(原子回写音频文件)
- internal/handlers/scan.go -- 扫描相关 HTTP handler
- internal/handlers/music.go -- 歌曲管理 HTTP handler
- internal/app/app.go -- 支持格式白名单初始化
目录
1. 文件扫描流程
章节来源: internal/services/scanner.go, internal/app/app.go
1.1 Scanner 架构
Scanner 是无状态的文件遍历器,由 ScanConfig 驱动:
| 配置字段 | 说明 | 默认值 |
|---|---|---|
MusicPath | 音乐根目录 | music(相对于数据目录) |
ExcludeDirs | 按名称匹配的排除目录 | ["@eaDir", "tmp"] |
ExcludePaths | 按完整路径精确匹配的排除路径 | [] |
SupportedFormats | 允许的音频扩展名 | ["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"] |
配置存储在 config 表的 music_path 键中,通过 /api/v1/settings/music-path 业务端点读写。配置变更时触发 onMusicPathChanged 回调,重建 Scanner 实例并清理排除区域内的已有歌曲。
1.2 递归遍历(Walker)
ScanFiles 从 MusicPath 出发递归遍历目录树,核心逻辑在 scanDir 方法:
- 软链接支持: 使用
filepath.EvalSymlinks解析真实路径,os.Stat跟随软链接获取文件信息,软链接目录也会被递归遍历。 - 循环检测: 维护
visited map[string]bool记录已访问的真实路径,遇到重复路径直接跳过,防止符号链接构成的环路导致无限递归。 - 可取消: 每次进入新目录和处理每个条目时检查
ctx.Done(),支持通过 context 取消扫描。 - 格式过滤:
IsAudioFile提取文件扩展名(小写归一化后去点号),与SupportedFormats列表逐一比较。
1.3 排除规则
ShouldExcludeDir 实现两级排除:
- 路径精确排除(
ExcludePaths):目标路径等于排除路径或是其子目录时排除。典型场景为排除某个特定的子目录。 - 名称模式排除(
ExcludeDirs):对路径按路径分隔符拆分后逐段检查,任何层级包含排除名称即排除。典型场景为排除 NAS 生成的@eaDir缩略图目录。
IsFileInExcludedArea 对已入库歌曲的文件路径做排除判定,供 CleanInvalidSongs 使用。
1.4 辅助功能
ListSubDirs: 返回指定目录下的一级子目录(含是否有下级子目录标记),用于前端目录树懒加载。CollectAllDirNames: 递归收集所有目录名称并排序,用于排除目录名称的自动补全。GetFileInfo: 返回文件基本信息(路径、名称、大小、修改时间、格式)。
2. 元数据提取
章节来源: internal/services/metadata.go
2.1 双通道提取策略
MetadataExtractor.Extract 采用 tag 库优先、ffprobe 兜底 的分层策略:
tag 库 (github.com/hanxi/tag)
|
+-- 成功 --> 提取 Title/Artist/Album/Duration/Cover/Lyrics/ISRC
| |
| +-- Duration > 0 --> 完成(不调用 ffprobe)
| +-- Duration = 0 --> 回退 ffprobe 补充时长及技术参数
|
+-- 失败 --> 回退 ffprobe 提取全部信息这一设计使大部分 MP3/FLAC/M4A 文件无需启动 ffprobe 子进程,显著提升批量扫描性能。仅在 tag 库无法解析的格式(如 WMA、APE 的某些变体)或 VBR 时长不准确时才调用 ffprobe。
2.2 ffprobe 调用
runFFProbe 以 JSON 格式运行 ffprobe:
ffprobe -v quiet -print_format json -show_format -show_streams <filePath>解析输出的 FFProbeOutput 结构,从 format 段获取时长、比特率、格式名,从 streams 段获取采样率。当 tag 库未解析出标签时,mergeFFProbeTags 合并 format.tags 与音频流 tags(format 优先),通过 pickTag 按候选 key 顺序提取 title/artist/album/lyrics。
2.3 标题来源配置
MetadataConfig.TitleSource 支持两种模式:
| 值 | 行为 |
|---|---|
"tag"(默认) | tag 有 title 则用 tag,否则用文件名(去扩展名) |
"filename" | 始终用文件名(去扩展名)作为标题 |
通过 /api/v1/settings/scan-title-source 端点配置。mergeTitle 函数实现「tag 有就用 tag,没有就用文件名」的简单规则,历史版本曾做过「最长公共子串去重 + 拼接」但已移除,因为会导致艺术家冗余到标题字段。
2.4 封面提取与去重存储
封面从 tag 库的 Picture() 方法获取原始字节数据和扩展名。SaveCover / SaveCoverData 按内容 SHA-256 哈希值生成分层目录路径:
{CoverStoragePath}/{hash[0:2]}/{hash[2:4]}/{fullhash}.{ext}相同内容的封面自动去重(写入同一路径),避免单目录文件数过多。删除歌曲时 removeCoverIfUnreferenced 通过引用计数判断是否可以安全删除物理文件。
2.5 歌词提取
优先级:外挂 .lrc 文件 > 内嵌歌词(tag)。FindLyricFile 在音频文件同目录查找同名 .lrc 文件,命中时用 ReadLyricFile 读取内容并通过 tag.FixEncoding 修正编码。
2.6 ISRC 提取
extractISRC 从 tag 库的 Raw() 原始标签数据中按候选 key 顺序查找:ID3v2.3/2.4 的 TSRC、ID3v2.2 的 TRC、Vorbis/FLAC 的 isrc。
2.7 视频轨探测(is_video)
Metadata.IsVideo 标记音频文件是否含真实视频画面(写入 models.Song.IsVideo,客户端据此渲染画面/选择投屏 mime)。探测逻辑:
isVideoContainerCandidate先按扩展名筛选可能含视频的容器(mp4/mov/m4v/mkv/webm/avi/ts/mpg/mpeg/flv/wmv/rm/rmvb/3gp)。- 命中候选且配置了 ffprobe 时,调用
hasRealVideoStream判定 ffprobe 结果是否含真实视频流。 - 排除封面伪视频流:内嵌封面会以
codec_type=video+disposition.attached_pic=1出现,跳过;对mjpeg/png/bmp/gif等静态图编解码器也按名兜底排除。 - 复用优化:含视频轨的
mp4/mov会被 tag 库成功读取(拿到时长),不进入时长兜底的 ffprobe 分支,故此处对视频容器候选独立探测;已探测过(如无 tag 的mkv)则复用probe避免重复调用。
3. 歌曲导入(ScanAndImport)
章节来源: internal/services/song_service.go
3.1 整体流程
ScanAndImportAsync 是异步入口,通过 ScanProgressManager 保证同一时刻只有一个扫描任务运行。核心逻辑在 doScanAndImport:
ScanAndImportAsync
|
+-- scanProgressManager.Start() -- 获取扫描锁
|
+-- go doScanAndImport(ctx, reimport)
|
(1) scanner.ScanFiles() -- 遍历文件
|
(2) 预过滤 -- 比对 ListLocalPaths,跳过已存在且 duration>0 的文件
| -- 文件稳定性检测(修改时间 < 10s 则跳过)
|
(3) cleanStaleRecords -- 清理磁盘已不存在的过期记录
|
(4) 并发元数据提取 -- 4 worker 池
|
(5) fixSpamTags -- 垃圾 tag 检测
|
(6) 批量入库 -- 每 50 条一个事务
|
(7) runAutoCreatePlaylists -- 按目录结构自动创建歌单
|
(8) runAutoFingerprint -- 自动计算缺失指纹3.2 预过滤与去重
从 ListLocalPaths 获取数据库中所有本地歌曲的 {path -> (songID, duration)} 映射。对每个扫描到的文件:
- 已存在且 duration > 0 且非重新导入 --> 跳过(标记为 skipped)
- 已存在但 duration = 0 或重新导入 --> 重新提取元数据,记录
existingSongID用于 UPDATE - 不存在 --> 新文件,标记为待处理
文件稳定性检测:修改时间距当前时刻 < 10 秒的文件视为「正在拷贝中」,跳过避免导入不完整文件。
3.3 并发元数据提取
使用 metadataWorkers = 4 个 goroutine 组成 worker 池,通过 inputCh 分发任务、resultCh 收集结果。每个 worker:
- 调用
safeExtractMetadata提取元数据(recover panic 防止单文件错误导致整个扫描崩溃) - 提取成功后保存封面并释放
CoverData内存 - 获取文件大小信息
所有 worker 完成后关闭 resultCh,主 goroutine 收集全部结果到 allResults 切片。
3.4 垃圾 Tag 检测
fixSpamTags 在入库前检测同目录下大量文件拥有完全相同 (title, artist) 的情况(如某些盗版音频的广告 tag):
- 按目录分组统计最高频的
(title, artist)对 - 频次 >= 3 且占该目录总文件数 > 50% 时判定为垃圾 tag
- 将这些文件的 title 回退为文件名,artist 清空
3.5 批量事务入库
flushScanBatch 以 dbBatchSize = 50 条为一批,通过 Transactor.RunInTx 在单一事务中完成:
- 重新导入(
existingSongID > 0):读取已有 song 对象,更新所有元数据字段。lyric_source=manual的歌词不被覆盖,保护用户手动调整。 - 新导入:创建
models.Song对象,类型为TypeLocal,调用Create入库。
3.6 过期记录清理
cleanStaleRecords 对比扫描文件集与数据库记录,找出数据库中存在但磁盘已不存在的文件路径,通过 os.Stat 二次确认后批量删除。
3.7 后置动作
扫描完成后依序执行:
- 自动创建歌单(如果
scan_auto_create_playlists配置为 true):调用PlaylistAutoCreator.AutoCreate按目录结构重建auto_created歌单。 - 自动指纹计算(如果 chromaprint 可用且已注入
FingerprintService):调用ComputeMissing为缺失指纹的歌曲异步计算。
4. 远程歌曲与电台添加
章节来源: internal/services/song_service.go, internal/handlers/music.go
4.1 AddRemoteSongs
批量添加网络歌曲,支持两种音源标识:
| 模式 | 必填字段 | 说明 |
|---|---|---|
| 纯外链 | url + title | 直接 HTTP(S) URL |
| 插件来源 | plugin_entry_path + source_data + title | 音源由插件 resolve |
通过 UpsertRemote 实现去重:当 dedup_key 非空时,按 (dedup_key) 做 UPSERT(插件定义的去重 key,典型形态 <platform>:<platform_id>);dedup_key 为空时直接 INSERT。
支持歌词字段 lyric / lyric_source,通过 models.ApplyLyricToSong 统一处理。
is_video 字段:远程歌曲不走扫描期 ffprobe 探测,而是由调用方(插件/客户端)在输入中直接声明 IsVideo,标记该网络歌曲是否含视频画面。
4.2 AddRadios
批量添加电台/广播,歌曲类型为 TypeRadio,is_live = true。通过 BatchCreate 批量入库,不做去重。电台必须提供 url 和 title。电台同样支持声明 is_video(标记是否为含直播画面的视频电台)。
5. 指纹计算
章节来源: internal/services/fingerprint.go, internal/handlers/scan.go
5.1 Chromaprint 可用性检测
IsChromaprintAvailable 通过 ffmpeg -hide_banner -muxers 检测输出是否包含 chromaprint,结果通过 sync.Once 缓存,全局只检测一次。
5.2 指纹提取
ExtractFingerprint 调用 ffmpeg chromaprint muxer:
ffmpeg -i <filePath> -map 0:a:0 -map_metadata -1 -f chromaprint -fp_format base64 -- 超时:15 秒 context timeout
- 输出:base64 编码的指纹字符串(从 stdout 读取,取第一行)
- 时长:从 stderr 解析
Duration: HH:MM:SS.XX格式
5.3 异步批量计算
FingerprintService 管理指纹计算的异步生命周期:
| 方法 | 行为 |
|---|---|
ComputeMissing | 为所有缺失指纹的本地歌曲计算指纹 |
RecomputeAll | 清空所有已有指纹后重新计算全部 |
若已有任务在运行,先 cancel 旧任务等待其完成再启动新任务。
doCompute 使用 fpWorkers = 4 个并发 worker,通过 channel 分发任务。每个 worker 调用 ExtractFingerprint 后通过 UpdateFingerprint 将指纹和时长写入数据库。进度通过 FingerprintProgress(status/computed/total/failed)实时可查。
5.4 去重检测
指纹入库后可通过 ListDuplicateGroups 查询所有具有相同指纹的本地歌曲组。前端通过 GET /songs/duplicates 展示重复歌曲,支持通过 POST /songs/batch-delete(带 delete_files=true)删除重复文件。
6. 自动扫描
章节来源: internal/services/auto_scan.go, internal/handlers/scan.go
6.1 AutoScanner 调度器
AutoScanner 基于 time.Ticker 实现定时扫描:
ApplyConfig(cfg)
|
+-- stopLocked() -- 停止旧的定时器
|
+-- cfg.Enabled == false --> 返回(不启动)
|
+-- 区间钳位 --> [60s, 86400s]
|
+-- go run(ctx, interval)
|
+-- ticker.C --> ScanAndImportAsync(reimport=false)
| (已有扫描在进行时静默跳过)
|
+-- ctx.Done() --> 退出6.2 配置端点
通过 /api/v1/settings/auto-scan 读写:
{
"enabled": false,
"interval_seconds": 3600
}interval_seconds有效范围 [60, 86400],超出范围钳位到边界值- PUT 后立即生效:通过
onAutoScanChanged回调重启调度器,无需重启服务 - 默认关闭,间隔 1 小时
7. Tag 写入
章节来源: internal/services/song_file_writer.go
7.1 WriteSongTags 函数
WriteSongTags 将 song 的完整元数据回写到音频文件,是所有 tag 写入的统一入口。
关键约束: pkg/tag.WriteTag 是「重建标签块」模式(非增量),未填充的字段会被清空。因此必须传入完整的 *models.Song,将所有想保留的字段(Title/Artist/Album/Year/Genre/Lyrics/Picture)一次性写回。
7.2 写入流程
WriteSongTags(filePath, song)
|
(1) 构建 tag.WriteOptions -- 填充所有字段
| |
| +-- Lyrics: 从 LyricPayload JSON 解包取主歌词
| +-- Picture: 从 song.CoverPath 读取封面文件
| +-- lyric_source=url 时清空 Lyrics(不回写远程歌词)
|
(2) tagsUnchanged 比较 -- 读取文件现有标签逐字段对比
| |
| +-- 全部一致 --> 返回 FileWriteSkipped
| +-- 任何不同 --> 继续写入
|
(3) tag.WriteTag(filePath, opts) -- 原子写入
|
+-- 成功 --> FileWriteWritten
+-- ErrUnsupportedWrite --> FileWriteUnchanged(扩展名不在支持矩阵内的格式)
+-- 其它错误 --> FileWriteFailed7.3 格式支持
pkg/tag.WriteTag 按扩展名 dispatch,所有支持格式均采用「临时文件 + os.Rename」原子写入。写入矩阵:
| 格式 | 文本字段 | 歌词 | 封面 |
|---|---|---|---|
| MP3 | ID3v2.3 text frames | USLT | APIC |
| FLAC | Vorbis Comment | LYRICS | PICTURE block |
| M4A/MP4/M4B/MOV | iTunes atoms(©nam 等) | ©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 | 不支持(格式限制) |
| AIFF/AIF | ID3v2.3(ID3 chunk)+ NAME/AUTH | USLT(ID3 chunk) | APIC(ID3 chunk) |
仅上述扩展名之外的格式(如 WMA 及其它视频容器)会返回 ErrUnsupportedWrite。WAV 支持文本与歌词写入,但因格式限制不支持封面。不支持写入的格式不阻塞主流程,仅返回 FileWriteUnchanged 状态,由调用方决定如何处理。
7.4 状态语义
| 状态 | 含义 |
|---|---|
written | 文件回写成功 |
unchanged | 未尝试回写(非本地歌曲/无文件路径/不支持的格式/url 来源歌词) |
skipped | 文件标签与待写入内容一致,跳过写入 |
failed | 尝试写入但失败(IO / 解析错误),DB 已成功 |
7.5 原子写入保证
pkg/tag.WriteTag 内部使用临时文件 + os.Rename 实现原子写入。临时文件在源文件同目录创建(os.CreateTemp(dir, ...)),保证 rename 操作在同一文件系统内,不会触发跨设备错误。
8. 歌曲文件整理
章节来源: internal/services/song_service.go, internal/handlers/music.go
8.1 OrganizeSongs
OrganizeSongs 批量移动/重命名本地歌曲文件,前端按 Artist/Album 结构组织文件时调用。
每项操作通过 organizeOne 独立处理:
- 校验: 歌曲必须为本地类型且有文件路径
- 路径安全:
target_path不允许..前缀(防目录遍历),绝对路径必须在musicPath之下,扩展名必须与原文件一致 - 目录创建:
os.MkdirAll自动创建目标目录结构 - 文件搬移: 使用
moveFile而非裸os.Rename,先尝试 rename,跨设备时自动回退 copy + remove - 数据库更新: 更新
song.FilePath为新路径。若数据库更新失败则回滚文件搬移 - 清理: 尝试删除源文件原所在的空目录
图表来源: internal/services/song_service.go -- organizeOne 方法
organizeOne(musicPath, item)
|
+-- GetByID --> 校验 type=local
|
+-- 路径安全校验 --> 防遍历、防越界、扩展名一致
|
+-- moveFile(absSource, absTarget)
| |
| +-- 失败 --> 返回 error
|
+-- songs.Update(song with new FilePath)
| |
| +-- 失败 --> moveFile(absTarget, absSource) 回滚
|
+-- os.Remove(原目录) -- 清理空目录
|
+-- 返回 ok + 新路径9. 扫描进度追踪
章节来源: internal/services/scan_progress.go, internal/handlers/scan.go
9.1 状态机
ScanProgressManager 通过 sync.RWMutex 保护的状态机管理扫描全生命周期:
idle --> scanning --> importing --> creating_playlists --> completed
| | | |
| +-----> cancelling --> cancelled |
| | | |
| +----------> failed |
| |
+-----------------------------------------------------------+| 状态 | 含义 |
|---|---|
idle | 空闲,可启动新扫描 |
scanning | 正在遍历文件系统 |
importing | 文件数已确定,正在提取元数据和入库 |
creating_playlists | 自动创建歌单阶段(不可取消) |
completed | 扫描完成 |
failed | 扫描失败 |
cancelling | 用户请求取消,等待 worker 退出 |
cancelled | 已取消 |
9.2 进度数据
ScanProgress 结构体包含:
| 字段 | 说明 |
|---|---|
TotalFiles | 扫描发现的总文件数 |
ScannedFiles | 已处理的文件数(含跳过和失败) |
ImportedFiles | 成功导入的文件数 |
SkippedFiles | 跳过的文件数(已存在) |
FailedFiles | 处理失败的文件数 |
CleanedFiles | 清理的过期记录数 |
CurrentFile | 当前正在处理的文件路径 |
StartTime / EndTime | 扫描开始/结束时间 |
Error | 错误信息(仅 failed 状态) |
9.3 取消机制
Cancel 方法关闭 cancel chan struct{},所有 worker 在每次循环和 channel 发送时检查取消信号,确保快速响应取消请求。creating_playlists 阶段不允许取消(事务已在提交中)。
9.4 HTTP API
| 端点 | 方法 | 说明 |
|---|---|---|
/api/v1/scan | POST | 启动扫描(reimport=true 强制重新导入) |
/api/v1/scan/progress | GET | 获取当前进度(轮询) |
/api/v1/scan/cancel | POST | 取消正在进行的扫描 |
/api/v1/settings/music-path | GET/PUT | 音乐路径与排除配置 |
/api/v1/settings/auto-scan | GET/PUT | 自动扫描开关与间隔 |
/api/v1/settings/scan-title-source | GET/PUT | 标题来源(tag / filename) |
/api/v1/settings/scan-auto-create-playlists | GET/PUT | 扫描后是否自动创建歌单 |
/api/v1/settings/scan-playlist-mode | GET/PUT | 目录歌单归并模式:directory/top_level/bubble_up |
/api/v1/scan/fingerprints | POST | 触发批量指纹计算 |
/api/v1/scan/fingerprints/status | GET | 指纹计算状态与统计 |
/api/v1/scan/fingerprints/progress | GET | 指纹计算进度 |
/api/v1/scan/directories | GET | 目录树懒加载 |
/api/v1/scan/dir-names | GET | 目录名称自动补全 |
10. 歌曲下载(remote → local)
章节来源: internal/services/song_downloader.go, internal/database/song_repository.go
10.1 概述
SongDownloader.Download 将远程歌曲下载到本地音乐库,完成后将歌曲类型从 remote 转为 local。这是远程歌曲本地化的唯一入口,也可由自动下载(TryAutoDownload)在缓存完成后触发。
10.2 下载流程
Download(songID, opts)
|
(1) GetByID --> 校验 type=remote
|
(2) resolveTargetDir --> 确定目标目录(opts.TargetDir 或 music_path)
|
(3) ParsePathTemplate --> 解析路径模板(如 "{artist}/{title}")
|
(4) acquireAudio --> 获取音频文件(缓存命中则复用,否则同步下载)
|
(5) 可选转码 --> Format 非空时转为目标格式(如 mov → mp3)
|
(6) uniqueDestPath --> 目标路径冲突时追加序号("Song.mp3" → "Song (2).mp3")
|
(7) copyFile --> 复制到目标路径
|
(8) 可选嵌入元数据 --> WriteCacheSongTags(含歌词拉取)
|
(9) 更新 DB --> type=local, file_path=目标路径, 清理播放源字段10.3 字段保留规则(关键)
下载完成后更新歌曲记录时,各字段的处理规则:
| 字段 | 操作 | 原因 |
|---|---|---|
type | 改为 local | 歌曲已本地化 |
file_path | 写入目标路径 | 本地播放需要 |
url | 清空 | 不再需要直链 URL |
source_data | 清空 | 不再需要插件播放数据;同时使 IsPluginSourced() 返回 false,阻断源编排/元数据探测等远程歌曲路径 |
cache_path | 清空 | 缓存文件不再关联 |
plugin_entry_path | 保留 | 去重身份标识,见下文 |
dedup_key | 保留 | 去重身份标识,见下文 |
plugin_entry_path 和 dedup_key 必须保留,原因:
- 去重链完整性:
UpsertRemote按(plugin_entry_path, dedup_key)查找已有歌曲。若下载时清空plugin_entry_path,后续重新导入同一歌单时FindSongByDedupKey无法命中已下载的歌曲,会创建重复记录。再次下载该重复记录时,两行的(plugin_entry_path="", dedup_key)撞上唯一索引,触发UNIQUE constraint failed。 - UpsertRemote 已有保护:
UpsertRemote命中已有行且type=local时,仅复用 ID 不覆盖任何字段,不会用远程入参污染本地化后的元数据。 - 安全性:
IsPluginSourced()要求PluginEntryPath且SourceData同时非空才返回true。下载已清空SourceData,因此所有依赖IsPluginSourced()的远程歌曲路径(源编排、元数据探测、缓存拉取)均不会被误触发。
10.4 去重闭环
完整的去重生命周期:
导入 --> UpsertRemote 按 (plugin_entry_path, dedup_key) 查重
|
+-- 不存在 --> CREATE(新歌曲,type=remote)
|
+-- 已存在且 type=remote --> UPDATE 可变字段(刷新 source_data 等)
|
+-- 已存在且 type=local --> 仅复用 ID,不改动任何字段下载后歌曲保留去重字段,确保上述第三条路径能被正确命中:
(1) 导入歌曲 A: (plugin_entry_path="ytdlp", dedup_key="bilibili:123", type=remote)
(2) 下载歌曲 A: (plugin_entry_path="ytdlp", dedup_key="bilibili:123", type=local)
↑ plugin_entry_path 保留,去重索引位不变
(3) 再次导入同一首歌: UpsertRemote 命中歌曲 A,发现 type=local,直接复用 ID
→ 无重复记录,无约束冲突10.5 目标路径去重
uniqueDestPath 防止不同歌曲渲染出相同保存路径时互相覆盖(如同名不同版本)。目标文件已存在时在扩展名前追加序号:Song.mp3 → Song (2).mp3 → Song (3).mp3。
10.6 自动下载
TryAutoDownload 由缓存完成回调触发,满足以下条件时自动执行下载:
AutoDownloadConfig.Enabled = true(插件通过 bridge API 注册)song.PluginEntryPath != ""(来自插件的歌曲)song.Type == TypeRemote(尚未下载)
条件 3 确保已下载的本地歌曲不会被重复触发。
10.7 下载活动闸门
DownloadActivity 提供下载进行中的信号量,下载开始时 Begin()、结束时 End()。后台元数据探测器(MetadataRefresher)在下载活动进行中时让路,避免探测与下载撞车打满 CPU(issue #265)。
