音频源编排
本文档基于 internal/services/source/ 子包(orchestrator.go、fetcher.go、resolver.go、metrics.go、validator.go、errors.go)、internal/services/playactivity/registry.go 及 internal/app/source_adapters.go 编写,完整描述远程歌曲的音源获取、校验、跨插件 fallback、健康度追踪与会话级取消机制。
目录
- 概览:为什么需要音频源编排
- 三层架构总览
- Fetcher -- 插件 URL 获取与文件下载
- Resolver -- 跨插件扇出搜索
- Orchestrator -- 编排器与两种工作模式
- Metrics -- 滚动窗口健康度
- Validator -- 音频完整性校验
- 错误分类与 Fallback 判定
- PlayActivity Registry -- 会话级 Cancel Table
- Adapter Pattern -- 四个适配器解耦三层包
- 完整链路时序图
1. 概览:为什么需要音频源编排
Songloft 的远程歌曲(type=remote)通过 JS 插件从第三方平台获取播放 URL。这些 URL 具有天然的不稳定性:
- URL 过期:大多数音乐平台的 CDN 链接带时效签名,几分钟到数小时后失效。
- 源站限流/封禁:单一插件因请求过频被源站风控,导致批量获取失败。
- 插件自身故障:JS 插件更新不及时或 API 变更,
/api/music/url接口返回空结果或错误。 - 音频质量问题:部分 URL 返回截断文件、静音 padding 或整张专辑合并文件,HTTP 200 不代表内容可用。
音频源编排系统(internal/services/source/)正是为了解决这些问题而设计:它在单个插件失败时自动尝试插件内自搜(L1),仍然失败时跨插件扇出搜索同名歌曲(L2),并通过健康度指标实时降权不可靠的插件。
章节来源:orchestrator.go 文件头注释、AGENTS.md 业务踩坑总结
2. 三层架构总览
source 子包采用三层分工,每层职责单一,通过接口解耦:
┌─────────────────────────────────────────────────────┐
│ 上层调用方 │
│ CacheService(含 AsyncReassign 后台换源) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ SourceOrchestrator │ │
│ │ (编排 Fetch 模式 + AsyncReassign) │ │
│ │ ModeStrict / ModeFallback │ │
│ └──────────┬──────────────┬───────────┘ │
│ │ │ │
│ step 1 step 2 │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ SourceFetcher│ │SourceResolver│ │
│ │ (单源下载+ │ │ (跨插件扇出 │ │
│ │ 探测+校验) │ │ 搜索+评分) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │PluginInvoker │ │PluginLister │ │
│ │ (JS 插件 │ │ (枚举活跃 │ │
│ │ HTTP 调用) │ │ 音源插件) │ │
│ └──────────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Prober │ │SourceMetrics │ │
│ │ (ffprobe │ │ (滚动窗口 │ │
│ │ 探测) │ │ 健康度) │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────┘| 层 | 结构体 | 职责 |
|---|---|---|
| Fetcher | SourceFetcher | 调用单个插件的 /api/music/url 获取 URL → HTTP 下载到临时文件 → ffprobe 探测 → Validator 校验 → 上报 Metrics |
| Resolver | SourceResolver | 枚举所有活跃插件 → 并发调用 /api/search → 相似度评分 → 健康度加权 → 排序截取 Top-N 候选 |
| Orchestrator | SourceOrchestrator | 编排 Fetcher + Resolver,提供 ModeStrict(仅主源 + L1)和 ModeFallback(完整三级链路)两种模式,以及 AsyncReassign 后台换源 |
图表来源:orchestrator.go、fetcher.go、resolver.go 结构体定义与依赖注入
3. Fetcher -- 插件 URL 获取与文件下载
3.1 核心结构
SourceFetcher 通过 FetcherOpts 注入所有外部依赖:
| 依赖 | 接口 / 类型 | 说明 |
|---|---|---|
Prober | source.Prober | 音频探测(ffprobe),返回 AudioInfoLike(时长、文件大小) |
PluginInvoker | source.PluginInvoker | JS 插件 HTTP 调用桥接(InvokeHTTP) |
Metrics | *SourceMetrics | 结果上报(每次 Fetch 结束时 Record(Outcome)) |
HTTPClient | *http.Client | 下载用 HTTP 客户端(默认 120s 超时) |
LoadValidationOpts | func() ValidationOpts | 每次 Fetch 时读取最新校验配置(支持运维热改) |
章节来源:fetcher.go:69-77 FetcherOpts 定义
3.2 Fetch 五步流程
┌──────────────────────────────────┐
│ 1. 调用插件 /api/music/url │
│ POST {source_data, fallback} │
└──────────┬───────────────────────┘
│ 返回 {url, source_data?, used_fallback}
▼
┌──────────────────────────────────┐
│ 2. HTTP GET url → 临时文件 │
│ downloadToTemp (流式写) │
└──────────┬───────────────────────┘
│ tmpPath, size
▼
┌──────────────────────────────────┐
│ 3. Prober.ProbeForValidation │
│ ffprobe 探测时长/格式/码率 │
└──────────┬───────────────────────┘
│ AudioInfoLike
▼
┌──────────────────────────────────┐
│ 4. Validate(info, expected, opts)│
│ 纯函数校验(见 §7) │
└──────────┬───────────────────────┘
│ ValidationResult.Valid?
▼
┌──────────────────────────────────┐
│ 5. 成功 → 返回 FetchResult │
│ 失败 → 清理临时文件、上报 │
└──────────────────────────────────┘每一步失败都会:
- 调用
report(result, reason, size)向SourceMetrics上报分类结果 - 清理已创建的临时文件(
os.Remove(tmpPath)) - 返回分类后的错误类型(见 §8)
章节来源:fetcher.go:135-253 Fetch 方法
3.3 L1 自愈 Hints(插件内 Fallback)
当 allowPluginFallback=true 时,Fetcher 在请求体中附带 fallback 字段:
{
"source_data": "<原始 opaque JSON>",
"fallback": {
"enabled": true,
"title": "晴天",
"artist": "周杰伦",
"duration": 269.5
}
}插件收到 fallback hints 后,如果主 source_data 对应的 URL 获取失败,可以用 title+artist+duration 在自己的数据源内搜索替代资源。若找到,响应中 used_fallback=true 且 source_data 字段返回新的标识:
{
"url": "https://cdn.example.com/new-resource.mp3",
"source_data": "{\"id\":\"new-id-456\"}",
"used_fallback": true
}Orchestrator 收到 used_fallback=true 后会通过 persistIfChanged 把新的 source_data 回写到数据库,下次播放直接用新源。
关键设计:L2 跨插件 fallback 时 allowPluginFallback=false——避免对每个候选插件都再触发一轮 L1 搜索,防止 O(N*M) 爆炸式重试。
章节来源:fetcher.go:99-118 musicURLRequest/musicURLResponse 结构、fetcher.go:157-164 fallback 构造逻辑
3.4 下载实现细节
downloadToTemp 方法采用流式下载:
- 使用
os.CreateTemp("", "songloft-source-*")创建系统临时文件 - 设置 User-Agent 模拟浏览器(部分 CDN 对空 UA 返回 403)
- 支持 URL 中嵌入的 Basic Auth(
httputil.ApplyBasicAuthFromURL) - 不做 Content-Type 校验:部分 CDN 返回
application/octet-stream是正常的,内容审计完全交给后续 Probe + Validate
章节来源:fetcher.go:258-294 downloadToTemp 方法
4. Resolver -- 跨插件扇出搜索
4.1 核心结构与配置
SourceResolver 负责在主源失败时寻找替代插件的同名歌曲:
| 参数 | 默认值 | 说明 |
|---|---|---|
PerPluginTimeout | 5s | 单个插件 /api/search 调用超时 |
GlobalTimeout | 8s | 整个 fan-out 总超时(包含所有插件并发) |
MinScore | 0.6 | 候选最低综合分数(低于此值直接过滤) |
MaxResults | 5 | 最多返回候选数量 |
ExcludeRed | true | 是否过滤 HealthRed 插件(成功率 < 40%) |
CacheTTL | 5min | LRU 缓存有效期 |
章节来源:resolver.go:28-46 ResolverOpts 与 DefaultResolverOpts
4.2 Discover 流程
Discover(ctx, song, excludePlugins)
│
├── 1. 构造 cacheKey = normalize(title) + "|" + normalize(artist)
│ 检查 LRU 缓存 → 命中则直接返回(过滤 excludePlugins 后)
│
├── 2. 枚举活跃插件(PluginLister.ListActiveEntryPaths)
│ 排除 excludePlugins + HealthRed 插件
│
├── 3. Fan-out 并发搜索
│ 每个插件开 goroutine → WithTimeout(perPlugin)
│ POST /api/search {keyword: "title artist", page:1, page_size:20}
│
├── 4. 收集结果 + 相似度评分 + 健康度加权
│ score = baseScore * WeightedScore(plugin)
│ 过滤 score < MinScore
│
├── 5. 降序排序 → 截取 Top MaxResults → 写入缓存
│
└── 返回 []MusicSource(按 Score 降序)关键特性:
- 失败的插件不影响整体——
searchOne方法在调用失败时返回空切片,不向上传播错误 GlobalTimeout作为整体超时兜底,即使某个插件 hang 住也不会拖慢全局- 缓存写入时顺便清理过期项,避免 map 无限增长
章节来源:resolver.go:114-207 Discover 方法
4.3 相似度评分算法
综合分数由三个维度加权求和:
score = 0.5 * titleSimilarity + 0.3 * artistSimilarity + 0.2 * durationSimilarity标题相似度(权重 0.5)
基于 Levenshtein 编辑距离归一化到 [0, 1]:
titleSimilarity = 1 - levenshtein(normalize(t1), normalize(t2)) / max(len(t1), len(t2))normalize 函数对字符串做标准化处理:小写化 → 去括号内容((remix)、[live]、【伴奏】 等) → 去空格。这样 "晴天 (Live)" 和 "晴天" 的相似度不会因括号内容而降低。
章节来源:resolver.go:267-284 normalize 函数
艺术家相似度(权重 0.3)
多艺术家按 /、&、,、、、feat.、feat 分隔,取集合 IoU(交并比):
artistSimilarity = |set1 ∩ set2| / |set1 ∪ set2|当一方缺失时给 0.3(非零,避免完全排除缺少艺术家信息的候选)。
章节来源:resolver.go:308-341 artistSimilarity 与 splitArtists
时长相似度(权重 0.2)
durationSimilarity = 1 - |d1 - d2| / d1 (ratio capped at 1.0)当任一方时长为 0 时取中性值 0.5。这一维度用于过滤明显不匹配的结果(如返回了整张专辑而非单曲)。
章节来源:resolver.go:289-305 similarityScore 函数
4.4 LRU 缓存
缓存以 normalize(title)|normalize(artist) 为 key,TTL 5 分钟。设计意图:
- 同一首歌在短时间内多次触发 fallback(如多客户端同时播放),避免重复 fan-out 搜索
- 5 分钟足够覆盖一次播放会话的重试周期,又不会因缓存过久而错过插件恢复
章节来源:resolver.go:223-251 cacheGet/cachePut 方法
5. Orchestrator -- 编排器与两种工作模式
5.1 FetchMode 枚举
| 模式 | 值 | 使用场景 | 行为 |
|---|---|---|---|
ModeStrict | 0 | Cache HTTP handler(同步返回) | 仅尝试主源 + L1 插件内自搜,失败立即返回 |
ModeFallback | 1 | AsyncReassign 后台换源 | 完整三级链路:主源 → L1 → L2 跨插件 fan-out |
设计决策:HTTP handler 使用 ModeStrict 是因为客户端在等待响应,跨插件 fallback 耗时过长(3-5s 间隔 * 最多 4 次尝试 = 12-20s)会导致播放器超时。失败时改用 AsyncReassign 后台静默换源。
章节来源:orchestrator.go:13-23 FetchMode 定义与注释
5.2 Fetch 编排流程
Fetch(ctx, song, mode)
│
├── Step 1: 主源 + L1
│ Fetcher.Fetch(song.plugin, song.source_data, allowFallback=true)
│ ├── 成功 → persistIfChanged → return FetchResult
│ ├── 失败 + ModeStrict → return error
│ └── 失败 + !IsFallbackable → return error
│
└── Step 2: L2 跨插件 fan-out(仅 ModeFallback)
Resolver.Discover(song, exclude=[主插件])
│
└── 遍历候选(按 Score 降序)
├── tried >= MaxAttempts(默认 4) → break
├── sleepFallback [3s, 5s) 随机间隔
├── Fetcher.Fetch(candidate.plugin, candidate.source_data, allowFallback=false)
│ ├── 成功 → persistIfChanged(回写新插件+source_data) → return
│ ├── 失败 + IsFallbackable → continue
│ └── 失败 + !IsFallbackable → return error
└── 全部失败 → AllSourcesFailedError{Tried, LastErr}章节来源:orchestrator.go:106-160 Fetch 方法
5.3 persistIfChanged -- 音源回写
每次 Fetch 成功后,Orchestrator 检查结果是否需要回写数据库:
- 时长回填:若
song.Duration == 0且探测结果有有效时长 →UpdateSongDuration - 音源切换:若实际使用的
(plugin_entry_path, source_data)与原 song 不同 →UpdateSongSource
回写通过 SongUpdater 接口完成,避免 source 包依赖 services 包。
章节来源:orchestrator.go:202-229 persistIfChanged 方法
5.4 Fallback 间隔与抖动
L2 候选之间的间隔采用 FallbackInterval + random [0, FallbackJitter) 策略:
| 参数 | 默认值 | 说明 |
|---|---|---|
FallbackInterval | 3s | 最小间隔(防风控) |
FallbackJitter | 2s | 随机抖动上界 |
实际间隔为 [3s, 5s) 的均匀分布。这确保多个并发 Fetch 不会在同一时刻集中请求同一插件,降低被源站风控的概率。
章节来源:orchestrator.go:231-238 sleepFallback 方法
5.5 AsyncReassign -- 后台静默换源
AsyncReassign 用于 cache HTTP handler 场景:同步返回错误给客户端后,在后台异步为歌曲寻找新源。
AsyncReassign(songID, sessionKey, loader)
│
├── tryAcquireReassign(songID) → 5 分钟去重窗口
│ └── 同一 songID 5 分钟内不重复触发
│
└── go func():
├── ctx = WithTimeout(Background, 60s)
├── 若注入了 ActivityRegistry → Track(ctx, sk, songID, "reassign")
│ └── 用户切歌时此 ctx 会被 cancel
├── loader(ctx, songID) → SongInfo
└── Fetch(ctx, song, ModeFallback) → 成功时 persistIfChanged 已回写去重机制:reassignActive map[int64]time.Time 维护每个 songID 的最后 reassign 时间。tryAcquireReassign 在 AsyncReassignDedupe(默认 5 分钟)窗口内拒绝重复请求,防止多客户端同时播放同一首歌时触发大量并行换源。
关键设计:releaseReassign 不删除 map entry(保留 timestamp 作为去重窗口),真正的清理由下次 tryAcquireReassign 检查时自然完成。
章节来源:orchestrator.go:171-200 AsyncReassign 方法,orchestrator.go:240-256 去重逻辑
6. Metrics -- 滚动窗口健康度
6.1 Ring Buffer 数据结构
每个插件维护一个固定容量的环形 buffer(ringBuffer),FIFO 覆盖最旧的记录:
WindowSize = 200(默认)
head=0 ┌───┬───┬───┬───┬───┬─── ... ───┬─────┐
│ O │ O │ O │ O │ O │ │ O │ ← 200 个 Outcome 槽位
└───┴───┴───┴───┴───┴─── ... ───┴─────┘
head=199push(o)在head位置写入,head = (head+1) % capcount追踪实际元素数(冷启动时count < cap)snapshot()返回从最早到最新的有序副本
设计选择:纯内存存储,进程重启清零。理由是健康度是短期信号(反映"当前"插件状态),持久化的写放大成本不值得。
章节来源:metrics.go:67-102 ringBuffer 实现
6.2 Outcome 记录
每次 Fetcher.Fetch 结束时上报一条 Outcome:
| 字段 | 说明 |
|---|---|
PluginEntryPath | 插件标识(空值丢弃——纯外链场景无插件维度) |
Result | 结果分类:success / network_fail / probe_fail / validation_fail / plugin_invocation_fail |
Reason | 失败原因详情 |
Latency | 本次 Fetch 总耗时 |
SizeBytes | 下载文件大小 |
Timestamp | 记录时间 |
章节来源:metrics.go:9-27 OutcomeResult 与 Outcome 定义
6.3 健康分级
Class(pluginEntryPath) 根据滚动窗口内的成功率划分三级:
| 级别 | 条件 | 含义 |
|---|---|---|
HealthGreen | 成功率 >= 80% 且样本 >= 10 | 插件运行正常 |
HealthYellow | 介于 Green 和 Red 之间,或样本不足 | 需关注或冷启动 |
HealthRed | 成功率 < 40% 且样本 >= 10 | 插件不可靠 |
冷启动保护:样本数低于 MinSamples(默认 10)时统一返回 HealthYellow,避免新插件因前几次随机失败被误杀(直接标记为 Red 会被 Resolver 过滤)。
章节来源:metrics.go:39-55 MetricsOpts 与阈值,metrics.go:163-175 Class 方法
6.4 加权评分(WeightedScore)
Resolver 排序时使用 WeightedScore 给可靠插件加权:
WeightedScore = 0.3 + 0.7 * successRate| 场景 | successRate | WeightedScore | 效果 |
|---|---|---|---|
| 高质量插件 | 1.0 | 1.0 | 相似度分数不打折 |
| 中等插件 | 0.6 | 0.72 | 轻微降权 |
| 低质量插件 | 0.2 | 0.44 | 大幅降权 |
| 冷启动 | N/A | 0.86 (= 0.3+0.7*0.8) | 中性偏高,给新插件机会 |
0.3 的基础分保证即使成功率为 0 的插件也不会被完全排除(权重 0.3),给"偶尔可用"的插件留一线生机。
章节来源:metrics.go:182-188 WeightedScore 方法
6.5 Admin API 快照
Snapshot(maxFailures) 生成所有插件的健康度快照,用于 GET /api/v1/plugins/health:
[
{
"plugin_entry_path": "miot",
"class": "green",
"success_rate": 0.92,
"samples": 156,
"last_failures": [
{"result": "network_fail", "reason": "http status 403", ...}
]
}
]最近失败记录从最新到最旧排列,方便运维快速定位问题。
章节来源:metrics.go:192-229 Snapshot 方法
7. Validator -- 音频完整性校验
7.1 设计哲学
Validator 是纯函数,无 IO,便于单元测试。它的输入是 AudioInfoLike 接口(仅需 GetDuration() 和 GetSize()),不依赖任何具体的元数据库。
7.2 校验参数
| 参数 | 默认值 | 说明 |
|---|---|---|
Enabled | true | 总开关(false 时直接返回 Valid,灰度降级用) |
MinDuration | 30s | 实测时长绝对下限,过滤截断文件 |
DurationRatio | 0.85 | 实测/预期 时长容忍下限(85%) |
MaxDurationRatio | 1.5 | 实测/预期 时长容忍上限(150%,过滤整张专辑) |
MinBitrate | 8 kbps | 平均码率下限,过滤静音 padding 文件 |
所有阈值可通过 source_validation 配置 key 热改(FetcherOpts.LoadValidationOpts 每次 Fetch 时读取最新值)。
章节来源:validator.go:12-36 ValidationOpts 与 DefaultValidationOpts
7.3 校验判定链
Validate(info, expectedDuration, opts) 按以下顺序判定,任一不通过即返回 Invalid:
| 序号 | 条件 | Reason | 场景 |
|---|---|---|---|
| 1 | !opts.Enabled | (直接 Valid) | 灰度降级 |
| 2 | info == nil 或 Duration <= 0 | probe_failed | ffprobe 无法解析 |
| 3 | Duration < MinDuration | too_short | 文件被截断(如 5 秒的错误页面音频) |
| 4a | Duration < expected * DurationRatio | duration_mismatch_low | 时长偏短(可能是不同版本) |
| 4b | Duration > expected * MaxDurationRatio | duration_mismatch_high | 时长偏长(可能是整张专辑) |
| 5 | size*8/duration/1000 < MinBitrate | bitrate_too_low | 静音 padding + 极低码率文件 |
注意:expectedDuration 来自搜索阶段插件返回的元数据,它本身可能不准确(个别插件给估算值),所以 DurationRatio 取宽松值 0.85。
章节来源:validator.go:86-119 Validate 函数
8. 错误分类与 Fallback 判定
8.1 四种错误类型
source 包定义了四种结构化错误,供 Orchestrator 决策是否继续 fallback:
| 错误类型 | 触发场景 | IsFallbackable |
|---|---|---|
InvalidAudioError | 下载文件未通过完整性校验(携带 ValidationReason) | Yes |
NetworkError | HTTP 层失败:DNS / 连接 / 超时 / 非 2xx | Yes |
PluginInvocationError | 插件 /api/music/url 调用失败、返回非 200、响应体解析错误 | Yes |
AllSourcesFailedError | 所有候选(主源 + L2)均失败的终态错误 | N/A(终态) |
章节来源:errors.go:17-76 四种错误类型定义
8.2 IsFallbackable 判定
func IsFallbackable(err error) bool {
var ia *InvalidAudioError
var ne *NetworkError
var pe *PluginInvocationError
return errors.As(err, &ia) || errors.As(err, &ne) || errors.As(err, &pe)
}设计原则:
InvalidAudioError/NetworkError/PluginInvocationError→ 允许 fallback(可能是单个源 / 单次请求的临时问题)context.Canceled/context.DeadlineExceeded/ 内部逻辑错误 → 不 fallback(用户主动取消或系统级超时,重试无意义)
这一判定直接影响 Orchestrator 的控制流:不可 fallback 的错误会立即终止整个 Fetch 链路。
章节来源:errors.go:80-88 IsFallbackable 函数
9. PlayActivity Registry -- 会话级 Cancel Table
9.1 问题背景
Issue #79 描述了"快速切歌仍会转圈"的问题:用户快速切歌时,旧请求的 HTTP 下载 / ffmpeg 转码 / AsyncReassign 仍在后台占用 plugin worker 和转码信号量,新请求被阻塞。根因是后端无法从外部得知用户已经放弃旧请求。
9.2 核心设计
playactivity.Registry 是按 (SessionKey, songID, Category) 索引的 cancel table:
Registry
└── buckets: map[SessionKey]map[uint64]*entry
│
├── SessionKey{ClientID: "abc123"}
│ ├── entry{songID:1, cat:play, cancel: ...}
│ ├── entry{songID:1, cat:transcode, cancel: ...}
│ └── entry{songID:2, cat:prefetch, cancel: ...}
│
└── SessionKey{ClientID: "def456"}
└── entry{songID:3, cat:play, cancel: ...}9.3 SessionKey 分桶
SessionKey 目前只有 ClientID 字段,来自请求 ctx 中的 client_id(JWT 中间件注入)。按 ClientID 分桶确保多客户端同时登录时互不干扰——A 客户端切歌不会 cancel B 客户端的请求。
章节来源:registry.go:27-46 SessionKey 与 SessionFromContext
9.4 Track 与 Release
ctx, release := registry.Track(parentCtx, sessionKey, songID, CatPlay)
defer release()
// ... 执行下载/转码工作 ...Track 返回派生 ctx(context.WithCancel(parent))和 release 闭包。release 必须用 defer 调用:它先 cancel ctx 再从 registry 移除 entry,保证不泄漏 goroutine。
章节来源:registry.go:74-103 Track 方法
9.5 Activate -- 切歌触发取消
registry.Activate(sessionKey, keepSongID)Activate 在用户开始播放新歌时调用。它在 sessionKey 桶内执行以下 cancel 逻辑:
| 条件 | 行为 |
|---|---|
songID != keepSongID | cancel 所有工作(play / prefetch / transcode / reassign) |
songID == keepSongID && cat == prefetch | cancel(已真实播放,预热没意义了) |
songID == keepSongID && cat != prefetch | 不动(避免取消"自己",如当前 play 入口刚 Track 进来的) |
并发安全:先收集要 cancel 的 entries、释放锁、再逐个 cancel。这避免了 cancel → 唤醒 select → 触发 release → 重入同一把锁 的死锁问题。
章节来源:registry.go:113-143 Activate 方法
9.6 Category 分类
| Category | 含义 | 谁 Track |
|---|---|---|
CatPlay | GET /songs/{id}/play 主播放路径 | cache handler |
CatPrefetch | GET /songs/{id}/play?prefetch=1 预加载 | cache handler |
CatTranscode | ffmpeg 转码(GetOrTranscode) | transcode service |
CatReassign | AsyncReassign 后台换源 | orchestrator(通过 ReassignTracker 适配) |
章节来源:registry.go:17-22 Category 常量
10. Adapter Pattern -- 四个适配器解耦三层包
source 子包通过接口抽象(Prober、PluginInvoker、PluginLister、SongUpdater)避免反向依赖 services 和 jsplugin 包。internal/app/source_adapters.go 集中定义四个适配器完成桥接:
| 适配器 | 被适配类型 | 满足接口 | 桥接方式 |
|---|---|---|---|
proberAdapter | *services.MetadataExtractor | source.Prober | ProbeForValidation 直接透传 |
jsPluginInvokerAdapter | *jsplugin.Manager | source.PluginInvoker | InvokeHTTP 直接透传 |
jsPluginListerAdapter | *jsplugin.Manager | source.PluginLister | ListActive() → 提取 EntryPath 列表 |
songUpdaterAdapter | *services.SongService | source.SongUpdater | UpdateSongSource / UpdateSongDuration 透传 |
此外还有两个更高层的适配器:
| 适配器 | 说明 |
|---|---|
reassignAdapter | 包装 SourceOrchestrator + SongService,给 cache handler 提供 AsyncReassign(songID, sk) 接口,把"按 ID 加载 song → 转换为 SongInfo"的职责从 source 包剥离 |
playActivityReassignTracker | 把 playactivity.Registry 适配到 source.ReassignTracker,让 source 包的 AsyncReassign goroutine 注册进 cancel table,用户切歌时自动取消 |
设计效果:source 包零外部依赖(不 import services、jsplugin、playactivity),所有桥接在 app 层的 wire 阶段完成注入。
章节来源:source_adapters.go 完整文件
11. 完整链路时序图
以 cache HTTP handler 场景为例(GET /songs/{id}/play),展示从请求到 fallback 到 AsyncReassign 的完整链路:
Client Cache Handler Orchestrator Fetcher Plugin A Plugin B Resolver
│ │ │ │ │ │ │
│ GET /songs/1/play │ │ │ │ │ │
├───────────────────────>│ │ │ │ │ │
│ │ Track(CatPlay) │ │ │ │ │
│ │ Activate(sk, songID=1) │ │ │ │ │
│ │ │ │ │ │ │
│ │ Fetch(song, ModeStrict) │ │ │ │ │
│ ├─────────────────────────>│ │ │ │ │
│ │ │ Fetch(pluginA, │ │ │ │
│ │ │ sourceData, │ │ │ │
│ │ │ allowFallback=T) │ │ │ │
│ │ ├───────────────────>│ │ │ │
│ │ │ │ POST /api/music/url│ │ │
│ │ │ ├───────────────────>│ │ │
│ │ │ │ url expired │ │ │
│ │ │ │<───────────────────│ │ │
│ │ │ │ L1 自搜(fallback)│ │ │
│ │ │ ├───────────────────>│ │ │
│ │ │ │ 仍然失败 │ │ │
│ │ │ │<───────────────────│ │ │
│ │ │ PluginInvocationError │ │ │
│ │ │<───────────────────│ │ │ │
│ │ │ │ │ │ │
│ │ ModeStrict → 直接返回 error │ │ │ │
│ │<─────────────────────────│ │ │ │ │
│ │ │ │ │ │ │
│ HTTP 502/503 │ │ │ │ │ │
│<───────────────────────│ │ │ │ │ │
│ │ │ │ │ │ │
│ │ AsyncReassign(songID=1, sk) │ │ │ │
│ ├─────────────────────────>│ (background goroutine) │ │ │
│ │ │ Track(CatReassign) │ │ │ │
│ │ │ │ │ │ │
│ │ │ Fetch(song, ModeFallback) │ │ │
│ │ │ ── Step 1 主源 ──>│────────────────────>│ (again fail) │ │
│ │ │ │ │ │ │
│ │ │ ── Step 2 L2 ────────────────────────────────────────────────────────────>│
│ │ │ │ │ │ │
│ │ │ │ │ Discover(song) │
│ │ │ │ │ fan-out search │
│ │ │ │ │ │<──────────────│
│ │ │ │ │ POST /search │ │
│ │ │ │ │ │ │
│ │ │ candidates=[{B, score=0.92}] │ │ │
│ │ │ │ │ │ │
│ │ │ sleep [3s,5s) │ │ │ │
│ │ │ Fetch(pluginB, candSourceData, false) │ │ │
│ │ ├───────────────────>│ POST /api/music/url│ │ │
│ │ │ ├────────────────────────────────────>│ │
│ │ │ │ │ {url: ok} │ │
│ │ │ │<────────────────────────────────────│ │
│ │ │ │ download + probe + validate │ │
│ │ │ FetchResult (OK) │ │ │ │
│ │ │<───────────────────│ │ │ │
│ │ │ │ │ │ │
│ │ │ persistIfChanged: UpdateSongSource(pluginB, newSourceData) │
│ │ │ │ │ │ │
│ │ │ ── reassign 完成 ── │ │ │
│ │ │ │ │ │ │
│ (下次播放自动用 pluginB) │ │ │ │ │
│ │ │ │ │ │ │链路要点:
ModeStrict保证 HTTP handler 快速响应,不让客户端等待 fallbackAsyncReassign在后台走完整ModeFallback链路,成功后回写数据库- 下次播放时
song.plugin_entry_path已是pluginB,直接命中新源 - 若用户在 reassign 期间切歌,
Activate会 cancel reassign 的 ctx,释放 plugin worker
图表来源:综合 orchestrator.go、fetcher.go、resolver.go、registry.go 的调用关系
