Skip to content

音频源编排

本文档基于 internal/services/source/ 子包(orchestrator.go、fetcher.go、resolver.go、metrics.go、validator.go、errors.go)、internal/services/playactivity/registry.gointernal/app/source_adapters.go 编写,完整描述远程歌曲的音源获取、校验、跨插件 fallback、健康度追踪与会话级取消机制。

目录

  1. 概览:为什么需要音频源编排
  2. 三层架构总览
  3. Fetcher -- 插件 URL 获取与文件下载
  4. Resolver -- 跨插件扇出搜索
  5. Orchestrator -- 编排器与两种工作模式
  6. Metrics -- 滚动窗口健康度
  7. Validator -- 音频完整性校验
  8. 错误分类与 Fallback 判定
  9. PlayActivity Registry -- 会话级 Cancel Table
  10. Adapter Pattern -- 四个适配器解耦三层包
  11. 完整链路时序图

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    │  │ (滚动窗口    │                  │
│  │   探测)      │  │  健康度)     │                  │
│  └──────────────┘  └──────────────┘                  │
└─────────────────────────────────────────────────────┘
结构体职责
FetcherSourceFetcher调用单个插件的 /api/music/url 获取 URL → HTTP 下载到临时文件 → ffprobe 探测 → Validator 校验 → 上报 Metrics
ResolverSourceResolver枚举所有活跃插件 → 并发调用 /api/search → 相似度评分 → 健康度加权 → 排序截取 Top-N 候选
OrchestratorSourceOrchestrator编排 Fetcher + Resolver,提供 ModeStrict(仅主源 + L1)和 ModeFallback(完整三级链路)两种模式,以及 AsyncReassign 后台换源

图表来源orchestrator.gofetcher.goresolver.go 结构体定义与依赖注入


3. Fetcher -- 插件 URL 获取与文件下载

3.1 核心结构

SourceFetcher 通过 FetcherOpts 注入所有外部依赖:

依赖接口 / 类型说明
Probersource.Prober音频探测(ffprobe),返回 AudioInfoLike(时长、文件大小)
PluginInvokersource.PluginInvokerJS 插件 HTTP 调用桥接(InvokeHTTP
Metrics*SourceMetrics结果上报(每次 Fetch 结束时 Record(Outcome)
HTTPClient*http.Client下载用 HTTP 客户端(默认 120s 超时)
LoadValidationOptsfunc() 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       │
     │     失败 → 清理临时文件、上报      │
     └──────────────────────────────────┘

每一步失败都会:

  1. 调用 report(result, reason, size)SourceMetrics 上报分类结果
  2. 清理已创建的临时文件(os.Remove(tmpPath)
  3. 返回分类后的错误类型(见 §8

章节来源fetcher.go:135-253 Fetch 方法

3.3 L1 自愈 Hints(插件内 Fallback)

allowPluginFallback=true 时,Fetcher 在请求体中附带 fallback 字段:

json
{
  "source_data": "<原始 opaque JSON>",
  "fallback": {
    "enabled": true,
    "title": "晴天",
    "artist": "周杰伦",
    "duration": 269.5
  }
}

插件收到 fallback hints 后,如果主 source_data 对应的 URL 获取失败,可以用 title+artist+duration 在自己的数据源内搜索替代资源。若找到,响应中 used_fallback=truesource_data 字段返回新的标识:

json
{
  "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 负责在主源失败时寻找替代插件的同名歌曲:

参数默认值说明
PerPluginTimeout5s单个插件 /api/search 调用超时
GlobalTimeout8s整个 fan-out 总超时(包含所有插件并发)
MinScore0.6候选最低综合分数(低于此值直接过滤)
MaxResults5最多返回候选数量
ExcludeRedtrue是否过滤 HealthRed 插件(成功率 < 40%)
CacheTTL5minLRU 缓存有效期

章节来源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 枚举

模式使用场景行为
ModeStrict0Cache HTTP handler(同步返回)仅尝试主源 + L1 插件内自搜,失败立即返回
ModeFallback1AsyncReassign 后台换源完整三级链路:主源 → 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 检查结果是否需要回写数据库:

  1. 时长回填:若 song.Duration == 0 且探测结果有有效时长 → UpdateSongDuration
  2. 音源切换:若实际使用的 (plugin_entry_path, source_data) 与原 song 不同 → UpdateSongSource

回写通过 SongUpdater 接口完成,避免 source 包依赖 services 包。

章节来源orchestrator.go:202-229 persistIfChanged 方法

5.4 Fallback 间隔与抖动

L2 候选之间的间隔采用 FallbackInterval + random [0, FallbackJitter) 策略:

参数默认值说明
FallbackInterval3s最小间隔(防风控)
FallbackJitter2s随机抖动上界

实际间隔为 [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 时间。tryAcquireReassignAsyncReassignDedupe(默认 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=199
  • push(o)head 位置写入,head = (head+1) % cap
  • count 追踪实际元素数(冷启动时 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
场景successRateWeightedScore效果
高质量插件1.01.0相似度分数不打折
中等插件0.60.72轻微降权
低质量插件0.20.44大幅降权
冷启动N/A0.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

json
[
  {
    "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 校验参数

参数默认值说明
Enabledtrue总开关(false 时直接返回 Valid,灰度降级用)
MinDuration30s实测时长绝对下限,过滤截断文件
DurationRatio0.85实测/预期 时长容忍下限(85%)
MaxDurationRatio1.5实测/预期 时长容忍上限(150%,过滤整张专辑)
MinBitrate8 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)灰度降级
2info == nilDuration <= 0probe_failedffprobe 无法解析
3Duration < MinDurationtoo_short文件被截断(如 5 秒的错误页面音频)
4aDuration < expected * DurationRatioduration_mismatch_low时长偏短(可能是不同版本)
4bDuration > expected * MaxDurationRatioduration_mismatch_high时长偏长(可能是整张专辑)
5size*8/duration/1000 < MinBitratebitrate_too_low静音 padding + 极低码率文件

注意expectedDuration 来自搜索阶段插件返回的元数据,它本身可能不准确(个别插件给估算值),所以 DurationRatio 取宽松值 0.85。

章节来源validator.go:86-119 Validate 函数


8. 错误分类与 Fallback 判定

8.1 四种错误类型

source 包定义了四种结构化错误,供 Orchestrator 决策是否继续 fallback:

错误类型触发场景IsFallbackable
InvalidAudioError下载文件未通过完整性校验(携带 ValidationReasonYes
NetworkErrorHTTP 层失败:DNS / 连接 / 超时 / 非 2xxYes
PluginInvocationError插件 /api/music/url 调用失败、返回非 200、响应体解析错误Yes
AllSourcesFailedError所有候选(主源 + L2)均失败的终态错误N/A(终态)

章节来源errors.go:17-76 四种错误类型定义

8.2 IsFallbackable 判定

go
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

go
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 -- 切歌触发取消

go
registry.Activate(sessionKey, keepSongID)

Activate 在用户开始播放新歌时调用。它在 sessionKey 桶内执行以下 cancel 逻辑:

条件行为
songID != keepSongIDcancel 所有工作(play / prefetch / transcode / reassign)
songID == keepSongID && cat == prefetchcancel(已真实播放,预热没意义了)
songID == keepSongID && cat != prefetch不动(避免取消"自己",如当前 play 入口刚 Track 进来的)

并发安全:先收集要 cancel 的 entries、释放锁、再逐个 cancel。这避免了 cancel → 唤醒 select → 触发 release → 重入同一把锁 的死锁问题。

章节来源registry.go:113-143 Activate 方法

9.6 Category 分类

Category含义谁 Track
CatPlayGET /songs/{id}/play 主播放路径cache handler
CatPrefetchGET /songs/{id}/play?prefetch=1 预加载cache handler
CatTranscodeffmpeg 转码(GetOrTranscodetranscode service
CatReassignAsyncReassign 后台换源orchestrator(通过 ReassignTracker 适配)

章节来源registry.go:17-22 Category 常量


10. Adapter Pattern -- 四个适配器解耦三层包

source 子包通过接口抽象(ProberPluginInvokerPluginListerSongUpdater)避免反向依赖 servicesjsplugin 包。internal/app/source_adapters.go 集中定义四个适配器完成桥接:

适配器被适配类型满足接口桥接方式
proberAdapter*services.MetadataExtractorsource.ProberProbeForValidation 直接透传
jsPluginInvokerAdapter*jsplugin.Managersource.PluginInvokerInvokeHTTP 直接透传
jsPluginListerAdapter*jsplugin.Managersource.PluginListerListActive() → 提取 EntryPath 列表
songUpdaterAdapter*services.SongServicesource.SongUpdaterUpdateSongSource / UpdateSongDuration 透传

此外还有两个更高层的适配器:

适配器说明
reassignAdapter包装 SourceOrchestrator + SongService,给 cache handler 提供 AsyncReassign(songID, sk) 接口,把"按 ID 加载 song → 转换为 SongInfo"的职责从 source 包剥离
playActivityReassignTrackerplayactivity.Registry 适配到 source.ReassignTracker,让 source 包的 AsyncReassign goroutine 注册进 cancel table,用户切歌时自动取消

设计效果:source 包零外部依赖(不 import servicesjspluginplayactivity),所有桥接在 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 快速响应,不让客户端等待 fallback
  • AsyncReassign 在后台走完整 ModeFallback 链路,成功后回写数据库
  • 下次播放时 song.plugin_entry_path 已是 pluginB,直接命中新源
  • 若用户在 reassign 期间切歌,Activate 会 cancel reassign 的 ctx,释放 plugin worker

图表来源:综合 orchestrator.gofetcher.goresolver.goregistry.go 的调用关系