Audio Source Orchestration
This document is based on the internal/services/source/ subpackage (orchestrator.go, fetcher.go, resolver.go, metrics.go, validator.go, errors.go), internal/services/playactivity/registry.go, and internal/app/source_adapters.go. It fully describes the audio-source acquisition, validation, cross-plugin fallback, health tracking, and session-level cancellation mechanisms for remote songs.
Table of Contents
- Overview: Why Audio Source Orchestration Is Needed
- Three-Layer Architecture Overview
- Fetcher -- Plugin URL Acquisition and File Download
- Resolver -- Cross-Plugin Fan-Out Search
- Orchestrator -- The Orchestrator and Two Working Modes
- Metrics -- Rolling-Window Health
- Validator -- Audio Integrity Validation
- Error Classification and Fallback Determination
- PlayActivity Registry -- Session-Level Cancel Table
- Adapter Pattern -- Four Adapters Decouple the Three-Layer Package
- Full Chain Sequence Diagram
1. Overview: Why Audio Source Orchestration Is Needed
Songloft's remote songs (type=remote) obtain playback URLs from third-party platforms via JS plugins. These URLs are inherently unstable:
- URL expiration: Most music platforms' CDN links carry time-limited signatures that expire in minutes to hours.
- Origin-server rate limiting/blocking: A single plugin gets risk-controlled by the origin server due to overly frequent requests, causing batch acquisition failures.
- Plugin failure itself: A JS plugin is not updated in time or the API changes, and the
/api/music/urlendpoint returns empty results or errors. - Audio quality issues: Some URLs return truncated files, silence padding, or a whole album merged into one file; an HTTP 200 does not mean the content is usable.
The audio source orchestration system (internal/services/source/) is designed exactly to solve these problems: when a single plugin fails it automatically tries in-plugin self-search (L1), when that still fails it fans out a cross-plugin search for the same song (L2), and it demotes unreliable plugins in real time via health metrics.
Section sources: orchestrator.go file-header comment, AGENTS.md business-pitfall summary
2. Three-Layer Architecture Overview
The source subpackage adopts a three-layer division of labor, each layer with a single responsibility, decoupled through interfaces:
┌─────────────────────────────────────────────────────┐
│ Upper-layer caller │
│ CacheService (incl. AsyncReassign bg reassignment) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ SourceOrchestrator │ │
│ │ (orchestrates Fetch mode + │ │
│ │ AsyncReassign) │ │
│ │ ModeStrict / ModeFallback │ │
│ └──────────┬──────────────┬───────────┘ │
│ │ │ │
│ step 1 step 2 │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ SourceFetcher│ │SourceResolver│ │
│ │ (single-src │ │ (cross-plugin│ │
│ │ download + │ │ fan-out │ │
│ │ probe+valid)│ │ search + │ │
│ │ │ │ scoring) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │PluginInvoker │ │PluginLister │ │
│ │ (JS plugin │ │ (enumerate │ │
│ │ HTTP calls) │ │ active src │ │
│ │ │ │ plugins) │ │
│ └──────────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Prober │ │SourceMetrics │ │
│ │ (ffprobe │ │ (rolling │ │
│ │ probing) │ │ window │ │
│ │ │ │ health) │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────┘| Layer | Struct | Responsibility |
|---|---|---|
| Fetcher | SourceFetcher | Call a single plugin's /api/music/url to get the URL → HTTP download to a temp file → ffprobe probe → Validator check → report to Metrics |
| Resolver | SourceResolver | Enumerate all active plugins → concurrently call /api/search → similarity scoring → health weighting → sort and take the Top-N candidates |
| Orchestrator | SourceOrchestrator | Orchestrate Fetcher + Resolver, providing ModeStrict (primary source + L1 only) and ModeFallback (the full three-level chain), plus AsyncReassign background source reassignment |
Diagram sources: orchestrator.go, fetcher.go, resolver.go struct definitions and dependency injection
3. Fetcher -- Plugin URL Acquisition and File Download
3.1 Core Structure
SourceFetcher injects all external dependencies via FetcherOpts:
| Dependency | Interface / Type | Description |
|---|---|---|
Prober | source.Prober | Audio probing (ffprobe), returns AudioInfoLike (duration, file size) |
PluginInvoker | source.PluginInvoker | JS plugin HTTP call bridge (InvokeHTTP) |
Metrics | *SourceMetrics | Result reporting (Record(Outcome) at the end of each Fetch) |
HTTPClient | *http.Client | HTTP client for downloading (default 120s timeout) |
LoadValidationOpts | func() ValidationOpts | Reads the latest validation config on each Fetch (supports live ops changes) |
Section sources: fetcher.go:69-77 FetcherOpts definition
3.2 The Five-Step Fetch Flow
┌──────────────────────────────────┐
│ 1. Call plugin /api/music/url │
│ POST {source_data, fallback} │
└──────────┬───────────────────────┘
│ returns {url, source_data?, used_fallback}
▼
┌──────────────────────────────────┐
│ 2. HTTP GET url → temp file │
│ downloadToTemp (streaming) │
└──────────┬───────────────────────┘
│ tmpPath, size
▼
┌──────────────────────────────────┐
│ 3. Prober.ProbeForValidation │
│ ffprobe: duration/format/rate │
└──────────┬───────────────────────┘
│ AudioInfoLike
▼
┌──────────────────────────────────┐
│ 4. Validate(info, expected, opts)│
│ pure-function check (see §7) │
└──────────┬───────────────────────┘
│ ValidationResult.Valid?
▼
┌──────────────────────────────────┐
│ 5. Success → return FetchResult │
│ Failure → cleanup temp, report│
└──────────────────────────────────┘On failure at each step:
- Call
report(result, reason, size)to report the classified result toSourceMetrics - Clean up the temp file already created (
os.Remove(tmpPath)) - Return the classified error type (see §8)
Section sources: fetcher.go:135-253 Fetch method
3.3 L1 Self-Heal Hints (In-Plugin Fallback)
When allowPluginFallback=true, the Fetcher attaches a fallback field in the request body:
{
"source_data": "<original opaque JSON>",
"fallback": {
"enabled": true,
"title": "晴天",
"artist": "周杰伦",
"duration": 269.5
}
}After receiving the fallback hints, if the URL corresponding to the primary source_data fails to be obtained, the plugin can search for a replacement resource within its own data source using title+artist+duration. If found, the response has used_fallback=true and the source_data field returns the new identifier:
{
"url": "https://cdn.example.com/new-resource.mp3",
"source_data": "{\"id\":\"new-id-456\"}",
"used_fallback": true
}After receiving used_fallback=true, the Orchestrator writes the new source_data back to the database via persistIfChanged, so the next playback uses the new source directly.
Key design: During L2 cross-plugin fallback, allowPluginFallback=false—this avoids triggering another round of L1 search for every candidate plugin, preventing an O(N*M) explosive retry.
Section sources: fetcher.go:99-118 musicURLRequest/musicURLResponse structs, fetcher.go:157-164 fallback construction logic
3.4 Download Implementation Details
The downloadToTemp method uses streaming download:
- Uses
os.CreateTemp("", "songloft-source-*")to create a system temp file - Sets a User-Agent to mimic a browser (some CDNs return 403 for an empty UA)
- Supports Basic Auth embedded in the URL (
httputil.ApplyBasicAuthFromURL) - No Content-Type check: it is normal for some CDNs to return
application/octet-stream; content auditing is fully delegated to the subsequent Probe + Validate
Section sources: fetcher.go:258-294 downloadToTemp method
4. Resolver -- Cross-Plugin Fan-Out Search
4.1 Core Structure and Configuration
SourceResolver is responsible for finding the same song from an alternative plugin when the primary source fails:
| Parameter | Default | Description |
|---|---|---|
PerPluginTimeout | 5s | Timeout for a single plugin's /api/search call |
GlobalTimeout | 8s | Total timeout for the entire fan-out (including all plugin concurrency) |
MinScore | 0.6 | Minimum composite score for a candidate (filtered out below this value) |
MaxResults | 5 | Maximum number of candidates to return |
ExcludeRed | true | Whether to filter out HealthRed plugins (success rate < 40%) |
CacheTTL | 5min | LRU cache validity period |
Section sources: resolver.go:28-46 ResolverOpts and DefaultResolverOpts
4.2 The Discover Flow
Discover(ctx, song, excludePlugins)
│
├── 1. Build cacheKey = normalize(title) + "|" + normalize(artist)
│ Check LRU cache → on hit return directly (after filtering excludePlugins)
│
├── 2. Enumerate active plugins (PluginLister.ListActiveEntryPaths)
│ Exclude excludePlugins + HealthRed plugins
│
├── 3. Fan-out concurrent search
│ One goroutine per plugin → WithTimeout(perPlugin)
│ POST /api/search {keyword: "title artist", page:1, page_size:20}
│
├── 4. Collect results + similarity scoring + health weighting
│ score = baseScore * WeightedScore(plugin)
│ Filter out score < MinScore
│
├── 5. Sort descending → take Top MaxResults → write to cache
│
└── Return []MusicSource (descending by Score)Key features:
- A failing plugin does not affect the whole—the
searchOnemethod returns an empty slice on call failure and does not propagate errors upward GlobalTimeoutserves as an overall timeout backstop, so even if one plugin hangs it will not slow down the whole- Cache writes also clean up expired entries along the way, avoiding unbounded map growth
Section sources: resolver.go:114-207 Discover method
4.3 Similarity Scoring Algorithm
The composite score is a weighted sum of three dimensions:
score = 0.5 * titleSimilarity + 0.3 * artistSimilarity + 0.2 * durationSimilarityTitle Similarity (weight 0.5)
Based on Levenshtein edit distance normalized to [0, 1]:
titleSimilarity = 1 - levenshtein(normalize(t1), normalize(t2)) / max(len(t1), len(t2))The normalize function standardizes the string: lowercasing → removing parenthesized content ((remix), [live], 【伴奏】, etc.) → removing whitespace. This way the similarity of "晴天 (Live)" and "晴天" is not reduced by the parenthesized content.
Section sources: resolver.go:267-284 normalize function
Artist Similarity (weight 0.3)
Multiple artists are split by /, &, ,, 、, feat., feat, and the set IoU (Intersection over Union) is taken:
artistSimilarity = |set1 ∩ set2| / |set1 ∪ set2|When one side is missing, 0.3 is given (non-zero, to avoid completely excluding candidates lacking artist information).
Section sources: resolver.go:308-341 artistSimilarity and splitArtists
Duration Similarity (weight 0.2)
durationSimilarity = 1 - |d1 - d2| / d1 (ratio capped at 1.0)When either side's duration is 0, the neutral value 0.5 is taken. This dimension is used to filter out clearly mismatched results (such as a whole album returned instead of a single track).
Section sources: resolver.go:289-305 similarityScore function
4.4 LRU Cache
The cache is keyed by normalize(title)|normalize(artist), with a TTL of 5 minutes. Design intent:
- The same song triggering fallback multiple times in a short period (e.g., multiple clients playing simultaneously) avoids repeated fan-out searches
- 5 minutes is enough to cover the retry cycle of one playback session, yet not so long that plugin recovery is missed due to a stale cache
Section sources: resolver.go:223-251 cacheGet/cachePut methods
5. Orchestrator -- The Orchestrator and Two Working Modes
5.1 The FetchMode Enum
| Mode | Value | Use Case | Behavior |
|---|---|---|---|
ModeStrict | 0 | Cache HTTP handler (synchronous return) | Try only the primary source + L1 in-plugin self-search; return immediately on failure |
ModeFallback | 1 | AsyncReassign background reassignment | The full three-level chain: primary source → L1 → L2 cross-plugin fan-out |
Design decision: The HTTP handler uses ModeStrict because the client is waiting for a response, and cross-plugin fallback takes too long (3-5s interval * up to 4 attempts = 12-20s), which would cause a player timeout. On failure, AsyncReassign is used instead to silently reassign the source in the background.
Section sources: orchestrator.go:13-23 FetchMode definition and comments
5.2 The Fetch Orchestration Flow
Fetch(ctx, song, mode)
│
├── Step 1: primary source + L1
│ Fetcher.Fetch(song.plugin, song.source_data, allowFallback=true)
│ ├── Success → persistIfChanged → return FetchResult
│ ├── Failure + ModeStrict → return error
│ └── Failure + !IsFallbackable → return error
│
└── Step 2: L2 cross-plugin fan-out (ModeFallback only)
Resolver.Discover(song, exclude=[primary plugin])
│
└── Iterate candidates (descending by Score)
├── tried >= MaxAttempts(default 4) → break
├── sleepFallback [3s, 5s) random interval
├── Fetcher.Fetch(candidate.plugin, candidate.source_data, allowFallback=false)
│ ├── Success → persistIfChanged(write back new plugin+source_data) → return
│ ├── Failure + IsFallbackable → continue
│ └── Failure + !IsFallbackable → return error
└── All failed → AllSourcesFailedError{Tried, LastErr}Section sources: orchestrator.go:106-160 Fetch method
5.3 persistIfChanged -- Source Write-Back
After each successful Fetch, the Orchestrator checks whether the result needs to be written back to the database:
- Duration backfill: If
song.Duration == 0and the probe result has a valid duration →UpdateSongDuration - Source switch: If the actually-used
(plugin_entry_path, source_data)differs from the original song →UpdateSongSource
The write-back is done via the SongUpdater interface, avoiding a dependency of the source package on the services package.
Section sources: orchestrator.go:202-229 persistIfChanged method
5.4 Fallback Interval and Jitter
The interval between L2 candidates uses the FallbackInterval + random [0, FallbackJitter) strategy:
| Parameter | Default | Description |
|---|---|---|
FallbackInterval | 3s | Minimum interval (anti risk-control) |
FallbackJitter | 2s | Upper bound of random jitter |
The actual interval is a uniform distribution of [3s, 5s). This ensures multiple concurrent Fetches do not request the same plugin at the same instant, reducing the probability of being risk-controlled by the origin server.
Section sources: orchestrator.go:231-238 sleepFallback method
5.5 AsyncReassign -- Background Silent Reassignment
AsyncReassign is used for the cache HTTP handler scenario: after synchronously returning an error to the client, it asynchronously finds a new source for the song in the background.
AsyncReassign(songID, sessionKey, loader)
│
├── tryAcquireReassign(songID) → 5-minute dedup window
│ └── Same songID not re-triggered within 5 minutes
│
└── go func():
├── ctx = WithTimeout(Background, 60s)
├── if ActivityRegistry injected → Track(ctx, sk, songID, "reassign")
│ └── this ctx is canceled when the user switches songs
├── loader(ctx, songID) → SongInfo
└── Fetch(ctx, song, ModeFallback) → on success persistIfChanged already wrote backDeduplication mechanism: reassignActive map[int64]time.Time maintains the last reassign time for each songID. tryAcquireReassign rejects repeated requests within the AsyncReassignDedupe (default 5 minutes) window, preventing a flood of parallel reassignments when multiple clients play the same song simultaneously.
Key design: releaseReassign does not delete the map entry (keeping the timestamp as the dedup window); the actual cleanup is done naturally on the next tryAcquireReassign check.
Section sources: orchestrator.go:171-200 AsyncReassign method, orchestrator.go:240-256 deduplication logic
6. Metrics -- Rolling-Window Health
6.1 The Ring Buffer Data Structure
Each plugin maintains a fixed-capacity ring buffer (ringBuffer), FIFO-overwriting the oldest records:
WindowSize = 200 (default)
head=0 ┌───┬───┬───┬───┬───┬─── ... ───┬─────┐
│ O │ O │ O │ O │ O │ │ O │ ← 200 Outcome slots
└───┴───┴───┴───┴───┴─── ... ───┴─────┘
head=199push(o)writes at theheadposition,head = (head+1) % capcounttracks the actual number of elements (count < capduring cold start)snapshot()returns an ordered copy from earliest to newest
Design choice: Pure in-memory storage, reset to zero on process restart. The rationale is that health is a short-term signal (reflecting the "current" plugin state), and the write-amplification cost of persistence is not worth it.
Section sources: metrics.go:67-102 ringBuffer implementation
6.2 Outcome Records
An Outcome is reported at the end of each Fetcher.Fetch:
| Field | Description |
|---|---|
PluginEntryPath | Plugin identifier (empty value discarded—pure external-link scenarios have no plugin dimension) |
Result | Result classification: success / network_fail / probe_fail / validation_fail / plugin_invocation_fail |
Reason | Failure reason details |
Latency | Total time of this Fetch |
SizeBytes | Downloaded file size |
Timestamp | Record time |
Section sources: metrics.go:9-27 OutcomeResult and Outcome definitions
6.3 Health Grading
Class(pluginEntryPath) divides into three grades based on the success rate within the rolling window:
| Grade | Condition | Meaning |
|---|---|---|
HealthGreen | Success rate >= 80% and samples >= 10 | Plugin operating normally |
HealthYellow | Between Green and Red, or insufficient samples | Needs attention or cold start |
HealthRed | Success rate < 40% and samples >= 10 | Plugin unreliable |
Cold-start protection: When the sample count is below MinSamples (default 10), HealthYellow is uniformly returned, avoiding a new plugin being falsely killed due to a few random early failures (marking it Red directly would cause the Resolver to filter it out).
Section sources: metrics.go:39-55 MetricsOpts and thresholds, metrics.go:163-175 Class method
6.4 Weighted Scoring (WeightedScore)
When sorting, the Resolver uses WeightedScore to give reliable plugins extra weight:
WeightedScore = 0.3 + 0.7 * successRate| Scenario | successRate | WeightedScore | Effect |
|---|---|---|---|
| High-quality plugin | 1.0 | 1.0 | Similarity score not discounted |
| Medium plugin | 0.6 | 0.72 | Slight demotion |
| Low-quality plugin | 0.2 | 0.44 | Significant demotion |
| Cold start | N/A | 0.86 (= 0.3+0.7*0.8) | Neutral-to-high, gives new plugins a chance |
The base score of 0.3 guarantees that even a plugin with a 0% success rate is not completely excluded (weight 0.3), giving "occasionally usable" plugins a lifeline.
Section sources: metrics.go:182-188 WeightedScore method
6.5 Admin API Snapshot
Snapshot(maxFailures) generates a health snapshot of all plugins, used for 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", ...}
]
}
]The most recent failure records are arranged from newest to oldest, making it easy for ops to quickly locate problems.
Section sources: metrics.go:192-229 Snapshot method
7. Validator -- Audio Integrity Validation
7.1 Design Philosophy
The Validator is a pure function with no IO, convenient for unit testing. Its input is the AudioInfoLike interface (requiring only GetDuration() and GetSize()), independent of any concrete metadata library.
7.2 Validation Parameters
| Parameter | Default | Description |
|---|---|---|
Enabled | true | Master switch (returns Valid directly when false, for gradual degradation) |
MinDuration | 30s | Absolute lower bound on measured duration, filters truncated files |
DurationRatio | 0.85 | Lower tolerance bound of measured/expected duration (85%) |
MaxDurationRatio | 1.5 | Upper tolerance bound of measured/expected duration (150%, filters whole albums) |
MinBitrate | 8 kbps | Lower bound on average bitrate, filters silence-padding files |
All thresholds can be hot-changed via the source_validation config key (FetcherOpts.LoadValidationOpts reads the latest values on each Fetch).
Section sources: validator.go:12-36 ValidationOpts and DefaultValidationOpts
7.3 The Validation Decision Chain
Validate(info, expectedDuration, opts) decides in the following order; failing any one returns Invalid:
| No. | Condition | Reason | Scenario |
|---|---|---|---|
| 1 | !opts.Enabled | (returns Valid directly) | Gradual degradation |
| 2 | info == nil or Duration <= 0 | probe_failed | ffprobe cannot parse |
| 3 | Duration < MinDuration | too_short | File is truncated (e.g., 5 seconds of error-page audio) |
| 4a | Duration < expected * DurationRatio | duration_mismatch_low | Duration too short (possibly a different version) |
| 4b | Duration > expected * MaxDurationRatio | duration_mismatch_high | Duration too long (possibly a whole album) |
| 5 | size*8/duration/1000 < MinBitrate | bitrate_too_low | Silence padding + extremely low-bitrate file |
Note: expectedDuration comes from the metadata returned by the plugin during the search stage, and it may itself be inaccurate (some plugins give estimated values), so DurationRatio takes the loose value 0.85.
Section sources: validator.go:86-119 Validate function
8. Error Classification and Fallback Determination
8.1 Four Error Types
The source package defines four structured errors for the Orchestrator to decide whether to continue fallback:
| Error Type | Trigger Scenario | IsFallbackable |
|---|---|---|
InvalidAudioError | Downloaded file failed the integrity check (carries ValidationReason) | Yes |
NetworkError | HTTP-layer failure: DNS / connection / timeout / non-2xx | Yes |
PluginInvocationError | Plugin /api/music/url call failed, returned non-200, or response body parse error | Yes |
AllSourcesFailedError | Terminal error where all candidates (primary source + L2) failed | N/A (terminal) |
Section sources: errors.go:17-76 four error type definitions
8.2 The IsFallbackable Determination
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)
}Design principle:
InvalidAudioError/NetworkError/PluginInvocationError→ allow fallback (may be a temporary problem of a single source / single request)context.Canceled/context.DeadlineExceeded/ internal logic errors → do not fallback (user-initiated cancellation or system-level timeout, retrying is meaningless)
This determination directly affects the Orchestrator's control flow: a non-fallbackable error immediately terminates the entire Fetch chain.
Section sources: errors.go:80-88 IsFallbackable function
9. PlayActivity Registry -- Session-Level Cancel Table
9.1 Background
Issue #79 described the "spinner keeps spinning on rapid song switching" problem: when the user switches songs rapidly, the old request's HTTP download / ffmpeg transcode / AsyncReassign is still occupying the plugin worker and transcode semaphore in the background, blocking the new request. The root cause is that the backend cannot externally tell that the user has abandoned the old request.
9.2 Core Design
playactivity.Registry is a cancel table indexed by (SessionKey, songID, Category):
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 Bucketing
SessionKey currently has only the ClientID field, which comes from client_id in the request ctx (injected by the JWT middleware). Bucketing by ClientID ensures that multiple simultaneously-logged-in clients do not interfere with each other—client A switching songs does not cancel client B's requests.
Section sources: registry.go:27-46 SessionKey and SessionFromContext
9.4 Track and Release
ctx, release := registry.Track(parentCtx, sessionKey, songID, CatPlay)
defer release()
// ... perform download/transcode work ...Track returns a derived ctx (context.WithCancel(parent)) and a release closure. release must be called via defer: it first cancels the ctx, then removes the entry from the registry, ensuring no goroutine leak.
Section sources: registry.go:74-103 Track method
9.5 Activate -- Song Switching Triggers Cancellation
registry.Activate(sessionKey, keepSongID)Activate is called when the user starts playing a new song. It executes the following cancel logic within the sessionKey bucket:
| Condition | Behavior |
|---|---|
songID != keepSongID | Cancel all work (play / prefetch / transcode / reassign) |
songID == keepSongID && cat == prefetch | Cancel (already actually playing, prewarming is pointless) |
songID == keepSongID && cat != prefetch | Leave alone (avoid canceling "itself", such as the current play entry that just got Track'd in) |
Concurrency safety: First collect the entries to cancel, release the lock, then cancel them one by one. This avoids the deadlock of cancel → wake up select → trigger release → re-enter the same lock.
Section sources: registry.go:113-143 Activate method
9.6 Category Classification
| Category | Meaning | Who Tracks |
|---|---|---|
CatPlay | GET /songs/{id}/play main playback path | cache handler |
CatPrefetch | GET /songs/{id}/play?prefetch=1 preload | cache handler |
CatTranscode | ffmpeg transcode (GetOrTranscode) | transcode service |
CatReassign | AsyncReassign background reassignment | orchestrator (via ReassignTracker adapter) |
Section sources: registry.go:17-22 Category constants
10. Adapter Pattern -- Four Adapters Decouple the Three-Layer Package
The source subpackage uses interface abstractions (Prober, PluginInvoker, PluginLister, SongUpdater) to avoid a reverse dependency on the services and jsplugin packages. internal/app/source_adapters.go centrally defines four adapters that complete the bridging:
| Adapter | Adapted Type | Interface Satisfied | Bridging Method |
|---|---|---|---|
proberAdapter | *services.MetadataExtractor | source.Prober | ProbeForValidation passed through directly |
jsPluginInvokerAdapter | *jsplugin.Manager | source.PluginInvoker | InvokeHTTP passed through directly |
jsPluginListerAdapter | *jsplugin.Manager | source.PluginLister | ListActive() → extract the EntryPath list |
songUpdaterAdapter | *services.SongService | source.SongUpdater | UpdateSongSource / UpdateSongDuration passed through |
In addition, there are two higher-level adapters:
| Adapter | Description |
|---|---|
reassignAdapter | Wraps SourceOrchestrator + SongService, providing the cache handler with an AsyncReassign(songID, sk) interface, offloading the "load song by ID → convert to SongInfo" responsibility from the source package |
playActivityReassignTracker | Adapts playactivity.Registry to source.ReassignTracker, letting the source package's AsyncReassign goroutine register into the cancel table so it is automatically canceled when the user switches songs |
Design effect: The source package has zero external dependencies (does not import services, jsplugin, playactivity); all bridging is injected during the wire phase at the app layer.
Section sources: source_adapters.go complete file
11. Full Chain Sequence Diagram
Taking the cache HTTP handler scenario as an example (GET /songs/{id}/play), this shows the full chain from request to fallback to 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 self-search(fb) │ │ │
│ │ │ ├───────────────────>│ │ │
│ │ │ │ still fails │ │ │
│ │ │ │<───────────────────│ │ │
│ │ │ PluginInvocationError │ │ │
│ │ │<───────────────────│ │ │ │
│ │ │ │ │ │ │
│ │ ModeStrict → return error directly │ │ │ │
│ │<─────────────────────────│ │ │ │ │
│ │ │ │ │ │ │
│ HTTP 502/503 │ │ │ │ │ │
│<───────────────────────│ │ │ │ │ │
│ │ │ │ │ │ │
│ │ AsyncReassign(songID=1, sk) │ │ │ │
│ ├─────────────────────────>│ (background goroutine) │ │ │
│ │ │ Track(CatReassign) │ │ │ │
│ │ │ │ │ │ │
│ │ │ Fetch(song, ModeFallback) │ │ │
│ │ │ ── Step 1 primary ─>│───────────────────>│ (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 done ── │ │ │
│ │ │ │ │ │ │
│ (next playback auto-uses pluginB) │ │ │ │ │
│ │ │ │ │ │ │Chain highlights:
ModeStrictguarantees the HTTP handler responds quickly, without making the client wait for fallbackAsyncReassignruns the fullModeFallbackchain in the background and writes back to the database on success- On the next playback,
song.plugin_entry_pathis alreadypluginB, directly hitting the new source - If the user switches songs during the reassign,
Activatecancels the reassign's ctx, releasing the plugin worker
Diagram sources: synthesized from the call relationships of orchestrator.go, fetcher.go, resolver.go, registry.go
