Skip to content

音乐管理

本文档基于以下源文件编写:

目录

  1. 文件扫描流程
  2. 元数据提取
  3. 歌曲导入(ScanAndImport)
  4. 远程歌曲与电台添加
  5. 指纹计算
  6. 自动扫描
  7. Tag 写入
  8. 歌曲文件整理
  9. 扫描进度追踪
  10. 歌曲下载(remote → local)

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)

ScanFilesMusicPath 出发递归遍历目录树,核心逻辑在 scanDir 方法:

  1. 软链接支持: 使用 filepath.EvalSymlinks 解析真实路径,os.Stat 跟随软链接获取文件信息,软链接目录也会被递归遍历。
  2. 循环检测: 维护 visited map[string]bool 记录已访问的真实路径,遇到重复路径直接跳过,防止符号链接构成的环路导致无限递归。
  3. 可取消: 每次进入新目录和处理每个条目时检查 ctx.Done(),支持通过 context 取消扫描。
  4. 格式过滤: 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:

  1. 调用 safeExtractMetadata 提取元数据(recover panic 防止单文件错误导致整个扫描崩溃)
  2. 提取成功后保存封面并释放 CoverData 内存
  3. 获取文件大小信息

所有 worker 完成后关闭 resultCh,主 goroutine 收集全部结果到 allResults 切片。

3.4 垃圾 Tag 检测

fixSpamTags 在入库前检测同目录下大量文件拥有完全相同 (title, artist) 的情况(如某些盗版音频的广告 tag):

  • 按目录分组统计最高频的 (title, artist)
  • 频次 >= 3 且占该目录总文件数 > 50% 时判定为垃圾 tag
  • 将这些文件的 title 回退为文件名,artist 清空

3.5 批量事务入库

flushScanBatchdbBatchSize = 50 条为一批,通过 Transactor.RunInTx 在单一事务中完成:

  • 重新导入existingSongID > 0):读取已有 song 对象,更新所有元数据字段。lyric_source=manual 的歌词不被覆盖,保护用户手动调整。
  • 新导入:创建 models.Song 对象,类型为 TypeLocal,调用 Create 入库。

3.6 过期记录清理

cleanStaleRecords 对比扫描文件集与数据库记录,找出数据库中存在但磁盘已不存在的文件路径,通过 os.Stat 二次确认后批量删除。

3.7 后置动作

扫描完成后依序执行:

  1. 自动创建歌单(如果 scan_auto_create_playlists 配置为 true):调用 PlaylistAutoCreator.AutoCreate 按目录结构重建 auto_created 歌单。
  2. 自动指纹计算(如果 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

批量添加电台/广播,歌曲类型为 TypeRadiois_live = true。通过 BatchCreate 批量入库,不做去重。电台必须提供 urltitle。电台同样支持声明 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 读写:

json
{
  "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(扩展名不在支持矩阵内的格式)
        +-- 其它错误 --> FileWriteFailed

7.3 格式支持

pkg/tag.WriteTag 按扩展名 dispatch,所有支持格式均采用「临时文件 + os.Rename」原子写入。写入矩阵:

格式文本字段歌词封面
MP3ID3v2.3 text framesUSLTAPIC
FLACVorbis CommentLYRICSPICTURE block
M4A/MP4/M4B/MOViTunes atoms(©nam 等)©lyrcovr
OGG(.ogg/.oga)Vorbis CommentLYRICSMETADATA_BLOCK_PICTURE(base64)
APEAPEv2 text itemsLyricsCover Art (Front)(binary item)
WAVRIFF LIST INFOICMT不支持(格式限制)
AIFF/AIFID3v2.3(ID3 chunk)+ NAME/AUTHUSLT(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 独立处理:

  1. 校验: 歌曲必须为本地类型且有文件路径
  2. 路径安全: target_path 不允许 .. 前缀(防目录遍历),绝对路径必须在 musicPath 之下,扩展名必须与原文件一致
  3. 目录创建: os.MkdirAll 自动创建目标目录结构
  4. 文件搬移: 使用 moveFile 而非裸 os.Rename,先尝试 rename,跨设备时自动回退 copy + remove
  5. 数据库更新: 更新 song.FilePath 为新路径。若数据库更新失败则回滚文件搬移
  6. 清理: 尝试删除源文件原所在的空目录

图表来源: 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/scanPOST启动扫描(reimport=true 强制重新导入)
/api/v1/scan/progressGET获取当前进度(轮询)
/api/v1/scan/cancelPOST取消正在进行的扫描
/api/v1/settings/music-pathGET/PUT音乐路径与排除配置
/api/v1/settings/auto-scanGET/PUT自动扫描开关与间隔
/api/v1/settings/scan-title-sourceGET/PUT标题来源(tag / filename)
/api/v1/settings/scan-auto-create-playlistsGET/PUT扫描后是否自动创建歌单
/api/v1/settings/scan-playlist-modeGET/PUT目录歌单归并模式:directory/top_level/bubble_up
/api/v1/scan/fingerprintsPOST触发批量指纹计算
/api/v1/scan/fingerprints/statusGET指纹计算状态与统计
/api/v1/scan/fingerprints/progressGET指纹计算进度
/api/v1/scan/directoriesGET目录树懒加载
/api/v1/scan/dir-namesGET目录名称自动补全

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_pathdedup_key 必须保留,原因:

  1. 去重链完整性UpsertRemote(plugin_entry_path, dedup_key) 查找已有歌曲。若下载时清空 plugin_entry_path,后续重新导入同一歌单时 FindSongByDedupKey 无法命中已下载的歌曲,会创建重复记录。再次下载该重复记录时,两行的 (plugin_entry_path="", dedup_key) 撞上唯一索引,触发 UNIQUE constraint failed
  2. UpsertRemote 已有保护UpsertRemote 命中已有行且 type=local 时,仅复用 ID 不覆盖任何字段,不会用远程入参污染本地化后的元数据。
  3. 安全性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.mp3Song (2).mp3Song (3).mp3

10.6 自动下载

TryAutoDownload 由缓存完成回调触发,满足以下条件时自动执行下载:

  1. AutoDownloadConfig.Enabled = true(插件通过 bridge API 注册)
  2. song.PluginEntryPath != ""(来自插件的歌曲)
  3. song.Type == TypeRemote(尚未下载)

条件 3 确保已下载的本地歌曲不会被重复触发。

10.7 下载活动闸门

DownloadActivity 提供下载进行中的信号量,下载开始时 Begin()、结束时 End()。后台元数据探测器(MetadataRefresher)在下载活动进行中时让路,避免探测与下载撞车打满 CPU(issue #265)。