Skip to content

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

  1. Overview: Why Audio Source Orchestration Is Needed
  2. Three-Layer Architecture Overview
  3. Fetcher -- Plugin URL Acquisition and File Download
  4. Resolver -- Cross-Plugin Fan-Out Search
  5. Orchestrator -- The Orchestrator and Two Working Modes
  6. Metrics -- Rolling-Window Health
  7. Validator -- Audio Integrity Validation
  8. Error Classification and Fallback Determination
  9. PlayActivity Registry -- Session-Level Cancel Table
  10. Adapter Pattern -- Four Adapters Decouple the Three-Layer Package
  11. 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/url endpoint 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)     │                  │
│  └──────────────┘  └──────────────┘                  │
└─────────────────────────────────────────────────────┘
LayerStructResponsibility
FetcherSourceFetcherCall a single plugin's /api/music/url to get the URL → HTTP download to a temp file → ffprobe probe → Validator check → report to Metrics
ResolverSourceResolverEnumerate all active plugins → concurrently call /api/search → similarity scoring → health weighting → sort and take the Top-N candidates
OrchestratorSourceOrchestratorOrchestrate 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:

DependencyInterface / TypeDescription
Probersource.ProberAudio probing (ffprobe), returns AudioInfoLike (duration, file size)
PluginInvokersource.PluginInvokerJS plugin HTTP call bridge (InvokeHTTP)
Metrics*SourceMetricsResult reporting (Record(Outcome) at the end of each Fetch)
HTTPClient*http.ClientHTTP client for downloading (default 120s timeout)
LoadValidationOptsfunc() ValidationOptsReads 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:

  1. Call report(result, reason, size) to report the classified result to SourceMetrics
  2. Clean up the temp file already created (os.Remove(tmpPath))
  3. 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:

json
{
  "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:

json
{
  "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.1 Core Structure and Configuration

SourceResolver is responsible for finding the same song from an alternative plugin when the primary source fails:

ParameterDefaultDescription
PerPluginTimeout5sTimeout for a single plugin's /api/search call
GlobalTimeout8sTotal timeout for the entire fan-out (including all plugin concurrency)
MinScore0.6Minimum composite score for a candidate (filtered out below this value)
MaxResults5Maximum number of candidates to return
ExcludeRedtrueWhether to filter out HealthRed plugins (success rate < 40%)
CacheTTL5minLRU 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 searchOne method returns an empty slice on call failure and does not propagate errors upward
  • GlobalTimeout serves 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 * durationSimilarity

Title 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

ModeValueUse CaseBehavior
ModeStrict0Cache HTTP handler (synchronous return)Try only the primary source + L1 in-plugin self-search; return immediately on failure
ModeFallback1AsyncReassign background reassignmentThe 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:

  1. Duration backfill: If song.Duration == 0 and the probe result has a valid duration → UpdateSongDuration
  2. 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:

ParameterDefaultDescription
FallbackInterval3sMinimum interval (anti risk-control)
FallbackJitter2sUpper 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 back

Deduplication 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=199
  • push(o) writes at the head position, head = (head+1) % cap
  • count tracks the actual number of elements (count < cap during 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:

FieldDescription
PluginEntryPathPlugin identifier (empty value discarded—pure external-link scenarios have no plugin dimension)
ResultResult classification: success / network_fail / probe_fail / validation_fail / plugin_invocation_fail
ReasonFailure reason details
LatencyTotal time of this Fetch
SizeBytesDownloaded file size
TimestampRecord 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:

GradeConditionMeaning
HealthGreenSuccess rate >= 80% and samples >= 10Plugin operating normally
HealthYellowBetween Green and Red, or insufficient samplesNeeds attention or cold start
HealthRedSuccess rate < 40% and samples >= 10Plugin 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
ScenariosuccessRateWeightedScoreEffect
High-quality plugin1.01.0Similarity score not discounted
Medium plugin0.60.72Slight demotion
Low-quality plugin0.20.44Significant demotion
Cold startN/A0.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:

json
[
  {
    "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

ParameterDefaultDescription
EnabledtrueMaster switch (returns Valid directly when false, for gradual degradation)
MinDuration30sAbsolute lower bound on measured duration, filters truncated files
DurationRatio0.85Lower tolerance bound of measured/expected duration (85%)
MaxDurationRatio1.5Upper tolerance bound of measured/expected duration (150%, filters whole albums)
MinBitrate8 kbpsLower 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.ConditionReasonScenario
1!opts.Enabled(returns Valid directly)Gradual degradation
2info == nil or Duration <= 0probe_failedffprobe cannot parse
3Duration < MinDurationtoo_shortFile is truncated (e.g., 5 seconds of error-page audio)
4aDuration < expected * DurationRatioduration_mismatch_lowDuration too short (possibly a different version)
4bDuration > expected * MaxDurationRatioduration_mismatch_highDuration too long (possibly a whole album)
5size*8/duration/1000 < MinBitratebitrate_too_lowSilence 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 TypeTrigger ScenarioIsFallbackable
InvalidAudioErrorDownloaded file failed the integrity check (carries ValidationReason)Yes
NetworkErrorHTTP-layer failure: DNS / connection / timeout / non-2xxYes
PluginInvocationErrorPlugin /api/music/url call failed, returned non-200, or response body parse errorYes
AllSourcesFailedErrorTerminal error where all candidates (primary source + L2) failedN/A (terminal)

Section sources: errors.go:17-76 four error type definitions

8.2 The IsFallbackable Determination

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)
}

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

go
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

go
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:

ConditionBehavior
songID != keepSongIDCancel all work (play / prefetch / transcode / reassign)
songID == keepSongID && cat == prefetchCancel (already actually playing, prewarming is pointless)
songID == keepSongID && cat != prefetchLeave 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

CategoryMeaningWho Tracks
CatPlayGET /songs/{id}/play main playback pathcache handler
CatPrefetchGET /songs/{id}/play?prefetch=1 preloadcache handler
CatTranscodeffmpeg transcode (GetOrTranscode)transcode service
CatReassignAsyncReassign background reassignmentorchestrator (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:

AdapterAdapted TypeInterface SatisfiedBridging Method
proberAdapter*services.MetadataExtractorsource.ProberProbeForValidation passed through directly
jsPluginInvokerAdapter*jsplugin.Managersource.PluginInvokerInvokeHTTP passed through directly
jsPluginListerAdapter*jsplugin.Managersource.PluginListerListActive() → extract the EntryPath list
songUpdaterAdapter*services.SongServicesource.SongUpdaterUpdateSongSource / UpdateSongDuration passed through

In addition, there are two higher-level adapters:

AdapterDescription
reassignAdapterWraps 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
playActivityReassignTrackerAdapts 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:

  • ModeStrict guarantees the HTTP handler responds quickly, without making the client wait for fallback
  • AsyncReassign runs the full ModeFallback chain in the background and writes back to the database on success
  • On the next playback, song.plugin_entry_path is already pluginB, directly hitting the new source
  • If the user switches songs during the reassign, Activate cancels the reassign's ctx, releasing the plugin worker

Diagram sources: synthesized from the call relationships of orchestrator.go, fetcher.go, resolver.go, registry.go