react-native-audio-browser 0.2.0-next.17 → 0.2.0-next.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/android/build.gradle +2 -1
  2. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +160 -106
  3. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +11 -29
  4. package/android/src/main/java/com/audiobrowser/player/CrossOriginHeaderStripper.kt +77 -0
  5. package/android/src/main/java/com/audiobrowser/player/MediaFactory.kt +28 -10
  6. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +377 -110
  7. package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +13 -1
  8. package/android/src/main/res/values/strings.xml +6 -21
  9. package/android/src/test/java/com/audiobrowser/player/BrowseTilesTest.kt +385 -0
  10. package/android/src/test/java/com/audiobrowser/player/CrossOriginHeaderStripperTest.kt +94 -0
  11. package/lib/commonjs/features/browser.js +8 -0
  12. package/lib/commonjs/features/browser.js.map +1 -1
  13. package/lib/commonjs/web/NativeAudioBrowser.js +163 -50
  14. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  15. package/lib/commonjs/web/TrackPlayer/LoadAttempt.js +37 -0
  16. package/lib/commonjs/web/TrackPlayer/LoadAttempt.js.map +1 -0
  17. package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js +18 -2
  18. package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
  19. package/lib/commonjs/web/TrackPlayer/Player.js +232 -39
  20. package/lib/commonjs/web/TrackPlayer/Player.js.map +1 -1
  21. package/lib/commonjs/web/TrackPlayer/QueuePlayer.js +33 -12
  22. package/lib/commonjs/web/TrackPlayer/QueuePlayer.js.map +1 -1
  23. package/lib/commonjs/web/browser/BrowserManager.js +30 -12
  24. package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
  25. package/lib/commonjs/web/browser/SearchManager.js +16 -19
  26. package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
  27. package/lib/commonjs/web/http/RequestConfigBuilder.js +110 -27
  28. package/lib/commonjs/web/http/RequestConfigBuilder.js.map +1 -1
  29. package/lib/commonjs/web/http/followMediaRedirect.js +219 -0
  30. package/lib/commonjs/web/http/followMediaRedirect.js.map +1 -0
  31. package/lib/module/features/browser.js +8 -0
  32. package/lib/module/features/browser.js.map +1 -1
  33. package/lib/module/web/NativeAudioBrowser.js +163 -50
  34. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  35. package/lib/module/web/TrackPlayer/LoadAttempt.js +32 -0
  36. package/lib/module/web/TrackPlayer/LoadAttempt.js.map +1 -0
  37. package/lib/module/web/TrackPlayer/PlaybackStateMachine.js +18 -2
  38. package/lib/module/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
  39. package/lib/module/web/TrackPlayer/Player.js +232 -39
  40. package/lib/module/web/TrackPlayer/Player.js.map +1 -1
  41. package/lib/module/web/TrackPlayer/QueuePlayer.js +33 -12
  42. package/lib/module/web/TrackPlayer/QueuePlayer.js.map +1 -1
  43. package/lib/module/web/browser/BrowserManager.js +30 -12
  44. package/lib/module/web/browser/BrowserManager.js.map +1 -1
  45. package/lib/module/web/browser/SearchManager.js +16 -19
  46. package/lib/module/web/browser/SearchManager.js.map +1 -1
  47. package/lib/module/web/http/RequestConfigBuilder.js +110 -27
  48. package/lib/module/web/http/RequestConfigBuilder.js.map +1 -1
  49. package/lib/module/web/http/followMediaRedirect.js +215 -0
  50. package/lib/module/web/http/followMediaRedirect.js.map +1 -0
  51. package/lib/typescript/src/features/browser.d.ts +8 -0
  52. package/lib/typescript/src/features/browser.d.ts.map +1 -1
  53. package/lib/typescript/src/types/browser.d.ts +13 -4
  54. package/lib/typescript/src/types/browser.d.ts.map +1 -1
  55. package/package.json +1 -1
  56. package/src/features/browser.ts +8 -0
  57. package/src/types/browser.ts +13 -4
  58. package/src/web/NativeAudioBrowser.playWhenReady.test.ts +232 -2
  59. package/src/web/NativeAudioBrowser.ts +193 -60
  60. package/src/web/TrackPlayer/LoadAttempt.ts +29 -0
  61. package/src/web/TrackPlayer/PlaybackStateMachine.test.ts +60 -13
  62. package/src/web/TrackPlayer/PlaybackStateMachine.ts +22 -6
  63. package/src/web/TrackPlayer/Player.dispatch.test.ts +5 -3
  64. package/src/web/TrackPlayer/Player.load.test.ts +137 -1
  65. package/src/web/TrackPlayer/Player.ts +256 -37
  66. package/src/web/TrackPlayer/QueuePlayer.ts +33 -14
  67. package/src/web/__tests__/lifecycle.test.ts +345 -0
  68. package/src/web/__tests__/lifecycleHarness.ts +351 -0
  69. package/src/web/__tests__/loadEdgeCases.test.ts +195 -0
  70. package/src/web/__tests__/loadSupersession.test.ts +175 -0
  71. package/src/web/__tests__/retry.test.ts +169 -0
  72. package/src/web/browser/BrowserManager.ts +32 -15
  73. package/src/web/browser/SearchManager.test.ts +75 -0
  74. package/src/web/browser/SearchManager.ts +23 -26
  75. package/src/web/browser/__tests__/artwork-resolution.test.ts +117 -0
  76. package/src/web/browser/__tests__/layer-resolver.test.ts +35 -0
  77. package/src/web/browser/__tests__/search-navigation.test.ts +76 -0
  78. package/src/web/http/RequestConfigBuilder.test.ts +241 -1
  79. package/src/web/http/RequestConfigBuilder.ts +140 -33
  80. package/src/web/http/followMediaRedirect.test.ts +289 -0
  81. package/src/web/http/followMediaRedirect.ts +253 -0
@@ -56,6 +56,29 @@ function applyImageQueryParams(
56
56
  return { ...config, query }
57
57
  }
58
58
 
59
+ /** Whether `after` carries a header `before` had not already set identically. */
60
+ function addsHeaders(
61
+ before: Record<string, string> | undefined,
62
+ after: Record<string, string> | undefined
63
+ ): boolean {
64
+ if (!after) return false
65
+ return Object.entries(after).some(([key, value]) => before?.[key] !== value)
66
+ }
67
+
68
+ export interface ResolvedMediaRequest extends RequestConfig {
69
+ /**
70
+ * Whether the `media` layer set a header the shared `request` layer had not
71
+ * already set identically. A media-specific header is the signal that media
72
+ * is separately authenticated, which is what the web redirect probe gates
73
+ * on.
74
+ *
75
+ * By value, not by declaration: a media layer re-declaring a shared header
76
+ * with the same value reads as no contribution, which is what keeps an
77
+ * identity transform from tripping the probe.
78
+ */
79
+ mediaLayerHeaders: boolean
80
+ }
81
+
59
82
  /**
60
83
  * Builds and merges request configurations.
61
84
  * Mirrors Android's RequestConfigBuilder.kt
@@ -98,7 +121,9 @@ export const RequestConfigBuilder = {
98
121
  * A layer with a transform wins completely — it receives the base (plus route
99
122
  * params) and its result replaces the base; the layer's own static fields are
100
123
  * ignored. A layer without a transform merges its static fields over the base.
101
- * `path` is carried from the base — only a transform may change it.
124
+ * `path` is carried from the base — only a transform may change it. A
125
+ * throwing transform falls back to the base, so the layer is skipped and the
126
+ * ladder continues.
102
127
  *
103
128
  * Mirrors native's BrowserManager.applyLayer so web resolves the shared
104
129
  * `request` → `<kind>` → route chain identically across platforms.
@@ -113,10 +138,24 @@ export const RequestConfigBuilder = {
113
138
  // its result replaces it. When both are set they run as a pipeline — async
114
139
  // first, then sync (mirrors native applyLayer).
115
140
  if (layer.transform || layer.transformSync) {
116
- let cfg = base
117
- if (layer.transform) cfg = await layer.transform(cfg, params)
118
- if (layer.transformSync) cfg = layer.transformSync(cfg, params)
119
- return cfg
141
+ try {
142
+ let cfg = base
143
+ if (layer.transform) cfg = await layer.transform(cfg, params)
144
+ if (layer.transformSync) cfg = layer.transformSync(cfg, params)
145
+ return cfg
146
+ } catch (e) {
147
+ // A throwing transform costs its own layer, not the ladder: the layers
148
+ // under it stand, so a relative path still gets the shared `baseUrl`
149
+ // and a media request keeps the shared credential. One catch around
150
+ // the pair, as native has it, so a sync stage that throws after a
151
+ // successful async one also falls back to the layer's input rather
152
+ // than to a half-applied config.
153
+ console.error(
154
+ 'Failed to apply transform function, using base config',
155
+ e
156
+ )
157
+ return base
158
+ }
120
159
  }
121
160
  return {
122
161
  method: layer.method ?? base.method,
@@ -171,38 +210,115 @@ export const RequestConfigBuilder = {
171
210
  },
172
211
 
173
212
  /**
174
- * Resolves a media URL using the media configuration.
175
- * Creates a RequestConfig with the track's src as the path, then builds the URL.
176
- * Supports the transform callback for URL manipulation.
213
+ * Resolves a media request from the layered configuration, returning the
214
+ * whole request rather than just its URL.
215
+ *
216
+ * The layers resolve headers (and a user agent) alongside the URL, and the
217
+ * media request needs all of them: native applies them to the AVURLAsset /
218
+ * ExoPlayer DataSpec, so web has to apply them to Shaka's requests or the
219
+ * same configuration authenticates on iOS and Android but 401s here.
177
220
  *
178
- * The shared `request` layer is applied first (its transform runs for media
179
- * too, per the documented contract — e.g. a dynamic baseUrl), then the media
180
- * transform / static fields on top. Mirrors native's resolveMediaUrl,
181
- * including its best-effort behaviour: if a transform throws, fall back to the
182
- * original `src` rather than failing the load.
221
+ * The resolved URL is returned in `path`, with `baseUrl` already folded into
222
+ * it. Best-effort throughout, but at two levels: a throwing transform or
223
+ * resolver costs only its own layer (see {@link applyLayer}), and the catch
224
+ * here is the backstop for anything left — a malformed `baseUrl` reaching
225
+ * `buildUrl`, say — which falls back to the bare `src`.
226
+ *
227
+ * The chain is `request` → `media` → `media.resolve(track)`, matching
228
+ * native's. See {@link applyMediaResolve} for why the per-track resolver goes
229
+ * last.
183
230
  *
184
231
  * @param src The track's src value (may be relative or absolute)
185
232
  * @param requestConfig The shared request configuration (applied first)
186
233
  * @param mediaConfig The media request configuration
187
- * @returns The resolved absolute URL
234
+ * @param track The track being loaded, for `media.resolve`; without it the
235
+ * per-track layer is skipped and the static layers stand
236
+ * @returns The resolved request, its URL in `path`
188
237
  */
189
- async resolveMediaUrl(
238
+ async resolveMediaRequest(
190
239
  src: string,
191
240
  requestConfig: TransformableRequestConfig | undefined,
192
- mediaConfig: MediaRequestConfig | undefined
193
- ): Promise<string> {
241
+ mediaConfig: MediaRequestConfig | undefined,
242
+ track?: Track
243
+ ): Promise<ResolvedMediaRequest> {
194
244
  try {
195
- const config = await this.applyLayers({ path: src }, [
196
- requestConfig,
197
- mediaConfig
198
- ])
199
- return BrowserPathHelper.buildUrl(config.baseUrl, config.path ?? src)
245
+ // The layers are applied one at a time rather than through `applyLayers`
246
+ // so the media layer's own header contribution stays visible; each
247
+ // transform still runs exactly once.
248
+ const shared = await this.applyLayer({ path: src }, requestConfig)
249
+ const layered = await this.applyLayer(shared, mediaConfig)
250
+ const config = await this.applyMediaResolve(layered, mediaConfig, track)
251
+ // buildUrl folds in baseUrl *and* appends `query`; both are cleared on
252
+ // the way out so the resolved url can't be rebuilt and double-applied.
253
+ return {
254
+ ...config,
255
+ baseUrl: undefined,
256
+ query: undefined,
257
+ path: this.buildUrl({ ...config, path: config.path ?? src }),
258
+ mediaLayerHeaders: addsHeaders(shared.headers, config.headers)
259
+ }
200
260
  } catch (e) {
201
261
  console.error('Failed to resolve media URL, using original src', e)
202
- return BrowserPathHelper.buildUrl(undefined, src)
262
+ return {
263
+ path: BrowserPathHelper.buildUrl(undefined, src),
264
+ mediaLayerHeaders: false
265
+ }
203
266
  }
204
267
  },
205
268
 
269
+ /**
270
+ * Applies `media.resolve(track)` / `media.resolveSync(track)` as the final,
271
+ * most-specific layer over an already request+media-layered config. A no-op
272
+ * without a track or a resolver, so the layers stand on their own.
273
+ *
274
+ * Last, not first: native runs the media layer's transform and then lets the
275
+ * per-track resolver win over the result (Android `applyMediaResolve`, iOS
276
+ * `applyMediaResolveLayer`), so a resolver minting a signed URL is not
277
+ * overwritten by the static layer that shaped the unsigned one. Override-wins
278
+ * on every field, `path` included — replacing the URL outright is the point.
279
+ *
280
+ * A throwing resolver falls back to the layered config, as native does: the
281
+ * per-track shaping is lost, the layers under it are not.
282
+ */
283
+ async applyMediaResolve(
284
+ layered: RequestConfig,
285
+ mediaConfig: MediaRequestConfig | undefined,
286
+ track: Track | undefined
287
+ ): Promise<RequestConfig> {
288
+ if (!track || !mediaConfig) return layered
289
+ if (!mediaConfig.resolve && !mediaConfig.resolveSync) return layered
290
+
291
+ try {
292
+ const resolved = await this.composeResolved(mediaConfig, track)
293
+ return resolved ? this.mergeConfig(layered, resolved) : layered
294
+ } catch (e) {
295
+ console.error('Failed to apply media.resolve, using layered config', e)
296
+ return layered
297
+ }
298
+ },
299
+
300
+ /**
301
+ * Runs a config's per-track resolvers and composes their output: async
302
+ * `resolve` first, then `resolveSync` merged over it, sync winning.
303
+ * `undefined` when neither is set or neither produced a config.
304
+ *
305
+ * Media and artwork share the pairing, so it lives here rather than in both
306
+ * — the same reason native has `composeResolved`. Errors are the callers' to
307
+ * handle: the two paths differ on what a throwing resolver costs.
308
+ */
309
+ async composeResolved(
310
+ config: Pick<MediaRequestConfig, 'resolve' | 'resolveSync'>,
311
+ track: Track
312
+ ): Promise<RequestConfig | undefined> {
313
+ let resolved: RequestConfig | undefined
314
+ if (config.resolve) resolved = await config.resolve(track)
315
+ if (config.resolveSync) {
316
+ const sync = config.resolveSync(track)
317
+ resolved = resolved ? this.mergeConfig(resolved, sync) : sync
318
+ }
319
+ return resolved
320
+ },
321
+
206
322
  /**
207
323
  * Resolves an artwork URL and creates an ImageSource.
208
324
  * Matches Android's artwork URL transformation behavior.
@@ -297,16 +413,7 @@ export const RequestConfigBuilder = {
297
413
 
298
414
  // Step 1: Per-track resolution — async `resolve` first, then `resolveSync`
299
415
  // merged over it (mirrors native).
300
- let resolvedConfig: RequestConfig | undefined
301
- if (artworkConfig.resolve) {
302
- resolvedConfig = await artworkConfig.resolve(track)
303
- }
304
- if (artworkConfig.resolveSync) {
305
- const r = artworkConfig.resolveSync(track)
306
- resolvedConfig = resolvedConfig
307
- ? this.mergeConfig(resolvedConfig, r)
308
- : r
309
- }
416
+ const resolvedConfig = await this.composeResolved(artworkConfig, track)
310
417
  // If a resolver ran but produced nothing and there's no artwork URL, no artwork
311
418
  if (
312
419
  (artworkConfig.resolve || artworkConfig.resolveSync) &&
@@ -0,0 +1,289 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { followMediaRedirect } from './followMediaRedirect'
3
+
4
+ const res = (over: Partial<Response> = {}): Response =>
5
+ ({ ok: true, url: '', ...over }) as Response
6
+
7
+ describe('followMediaRedirect', () => {
8
+ const url = 'https://api.example.com/stream/content/7'
9
+ const signed = 'https://cdn.example.com/track.mp3?sig=abc'
10
+ const headers = { Authorization: 'Bearer token' }
11
+
12
+ it('returns the url the redirect chain lands on', async () => {
13
+ const fetchImpl = vi.fn().mockResolvedValue(res({ url: signed }))
14
+
15
+ const out = await followMediaRedirect(url, headers, { fetchImpl })
16
+
17
+ expect(out.src).toBe(signed)
18
+ // the credential was for the authenticating host, not the one it sent us to
19
+ expect(out.headers).toBeUndefined()
20
+ expect(fetchImpl).toHaveBeenCalledWith(url, {
21
+ method: 'HEAD',
22
+ headers,
23
+ redirect: 'follow',
24
+ signal: expect.any(AbortSignal)
25
+ })
26
+ })
27
+
28
+ it('does not fetch when there are no headers to apply', async () => {
29
+ const fetchImpl = vi.fn()
30
+
31
+ expect((await followMediaRedirect(url, undefined, { fetchImpl })).src).toBe(
32
+ url
33
+ )
34
+ expect((await followMediaRedirect(url, {}, { fetchImpl })).src).toBe(url)
35
+ expect(fetchImpl).not.toHaveBeenCalled()
36
+ })
37
+
38
+ it('does not fetch for shared headers that are not a credential', async () => {
39
+ const fetchImpl = vi.fn()
40
+
41
+ // an X-Client-Version that also rides browse requests: probing here would
42
+ // cost a preflight on every play, at every stream host, for nothing
43
+ const out = await followMediaRedirect(
44
+ url,
45
+ { 'X-Client-Version': '1.2.3' },
46
+ { fetchImpl }
47
+ )
48
+
49
+ expect(out.src).toBe(url)
50
+ expect(fetchImpl).not.toHaveBeenCalled()
51
+ })
52
+
53
+ it('fetches for a non-Authorization header the media layer set', async () => {
54
+ const fetchImpl = vi.fn().mockResolvedValue(res({ url: signed }))
55
+
56
+ const out = await followMediaRedirect(
57
+ url,
58
+ { 'X-Api-Key': 'k' },
59
+ { fetchImpl, mediaLayerHeaders: true }
60
+ )
61
+
62
+ expect(out.src).toBe(signed)
63
+ })
64
+
65
+ it('keeps the signed url when the final hop refuses the HEAD', async () => {
66
+ // presigned S3/GCS sign the method, so a URL signed for GET 403s a HEAD —
67
+ // but hop one authenticated, so the signed url it handed back is good
68
+ const fetchImpl = vi
69
+ .fn()
70
+ .mockResolvedValue(res({ ok: false, status: 403, url: signed }))
71
+
72
+ const out = await followMediaRedirect(url, headers, { fetchImpl })
73
+
74
+ expect(out.src).toBe(signed)
75
+ expect(out.headers).toBeUndefined()
76
+ })
77
+
78
+ /**
79
+ * A manifest is played through Shaka's networking engine, where the request
80
+ * filter carries the headers to the manifest, its segments and its keys.
81
+ * Probing one is a wasted round trip at best, and at worst clears the headers
82
+ * on a cross-origin redirect so the segments Android still authenticates go
83
+ * out bare — which is the divergence this skip closes.
84
+ */
85
+ describe('manifests are not probed', () => {
86
+ it.each([
87
+ 'https://api.example.com/stream/7/playlist.m3u8',
88
+ 'https://api.example.com/stream/7/manifest.mpd',
89
+ 'https://api.example.com/stream/7/Manifest.ISM',
90
+ 'https://api.example.com/stream/7/playlist.m3u8?token=abc'
91
+ ])('skips %s', async (manifest) => {
92
+ const fetchImpl = vi.fn()
93
+
94
+ const out = await followMediaRedirect(manifest, headers, { fetchImpl })
95
+
96
+ expect(out.src).toBe(manifest)
97
+ expect(out.headers).toEqual(headers)
98
+ expect(fetchImpl).not.toHaveBeenCalled()
99
+ })
100
+
101
+ it('still probes a progressive file', async () => {
102
+ const fetchImpl = vi.fn().mockResolvedValue(res({ url: signed }))
103
+
104
+ await followMediaRedirect('https://api.example.com/7.mp3', headers, {
105
+ fetchImpl
106
+ })
107
+
108
+ expect(fetchImpl).toHaveBeenCalled()
109
+ })
110
+
111
+ it('still probes an extensionless url', async () => {
112
+ // Shaka cannot type these from the extension either, so they stay on the
113
+ // probe path rather than being guessed either way
114
+ const fetchImpl = vi.fn().mockResolvedValue(res({ url: signed }))
115
+
116
+ await followMediaRedirect(url, headers, { fetchImpl })
117
+
118
+ expect(fetchImpl).toHaveBeenCalled()
119
+ })
120
+ })
121
+
122
+ it('does not fetch local urls', async () => {
123
+ const fetchImpl = vi.fn()
124
+ const file = 'file:///var/media/track.mp3'
125
+
126
+ expect((await followMediaRedirect(file, headers, { fetchImpl })).src).toBe(
127
+ file
128
+ )
129
+ expect(fetchImpl).not.toHaveBeenCalled()
130
+ })
131
+
132
+ it('resolves a url that does not redirect to itself', async () => {
133
+ const fetchImpl = vi.fn().mockResolvedValue(res({ url }))
134
+
135
+ const out = await followMediaRedirect(url, headers, { fetchImpl })
136
+ expect(out.src).toBe(url)
137
+ // same origin, so the headers are still ours to send
138
+ expect(out.headers).toEqual(headers)
139
+ })
140
+
141
+ it('keeps the headers when the redirect stays on the same origin', async () => {
142
+ const sameOrigin = 'https://api.example.com/signed/track.mp3'
143
+ const fetchImpl = vi.fn().mockResolvedValue(res({ url: sameOrigin }))
144
+
145
+ const out = await followMediaRedirect(url, headers, { fetchImpl })
146
+
147
+ expect(out.src).toBe(sameOrigin)
148
+ expect(out.headers).toEqual(headers)
149
+ })
150
+
151
+ it('keeps the headers for a relative src served from the page origin', async () => {
152
+ // `fetch('/api/authed/7')` goes to the page's own origin, so the absolute
153
+ // url the response reports is the same origin — not a hop. Comparing the
154
+ // raw relative string instead dropped the credential on every such load.
155
+ vi.stubGlobal('location', { href: 'https://app.example.com/player' })
156
+ try {
157
+ const fetchImpl = vi
158
+ .fn()
159
+ .mockResolvedValue(res({ url: 'https://app.example.com/api/authed/7' }))
160
+
161
+ const out = await followMediaRedirect('/api/authed/7', headers, {
162
+ fetchImpl
163
+ })
164
+
165
+ expect(out.headers).toEqual(headers)
166
+ } finally {
167
+ vi.unstubAllGlobals()
168
+ }
169
+ })
170
+
171
+ it('clears the headers when a relative src redirects off the page origin', async () => {
172
+ vi.stubGlobal('location', { href: 'https://app.example.com/player' })
173
+ try {
174
+ const fetchImpl = vi.fn().mockResolvedValue(res({ url: signed }))
175
+
176
+ const out = await followMediaRedirect('/api/authed/7', headers, {
177
+ fetchImpl
178
+ })
179
+
180
+ expect(out.src).toBe(signed)
181
+ expect(out.headers).toBeUndefined()
182
+ } finally {
183
+ vi.unstubAllGlobals()
184
+ }
185
+ })
186
+
187
+ it('keeps the headers when the origin cannot be determined', async () => {
188
+ // no document base, so a relative src has no origin — unknown is not
189
+ // evidence of a hop, and guessing cross-origin loses the credential
190
+ const fetchImpl = vi
191
+ .fn()
192
+ .mockResolvedValue(res({ url: 'https://app.example.com/api/authed/7' }))
193
+
194
+ const out = await followMediaRedirect('/api/authed/7', headers, {
195
+ fetchImpl
196
+ })
197
+
198
+ expect(out.headers).toEqual(headers)
199
+ })
200
+
201
+ it('treats a default port as the same origin', async () => {
202
+ // Android compares HttpUrl.port, which resolves to the scheme default, so
203
+ // web must normalize too or it strips headers Android keeps
204
+ const explicitPort = 'https://api.example.com:443/signed/track.mp3'
205
+ const fetchImpl = vi.fn().mockResolvedValue(res({ url: explicitPort }))
206
+
207
+ const out = await followMediaRedirect(url, headers, { fetchImpl })
208
+
209
+ expect(out.src).toBe(explicitPort)
210
+ expect(out.headers).toEqual(headers)
211
+ })
212
+
213
+ it('ignores userinfo when comparing origins', async () => {
214
+ const withUser = 'https://user@api.example.com/signed/track.mp3'
215
+ const fetchImpl = vi.fn().mockResolvedValue(res({ url: withUser }))
216
+
217
+ const out = await followMediaRedirect(url, headers, { fetchImpl })
218
+
219
+ expect(out.headers).toEqual(headers)
220
+ })
221
+
222
+ it('clears the headers on a real cross-origin hop', async () => {
223
+ const otherPort = 'https://api.example.com:8443/signed/track.mp3'
224
+ const fetchImpl = vi.fn().mockResolvedValue(res({ url: otherPort }))
225
+
226
+ expect(
227
+ (await followMediaRedirect(url, headers, { fetchImpl })).headers
228
+ ).toBeUndefined()
229
+ })
230
+
231
+ it('falls back to the original url when the credential is refused', async () => {
232
+ // no redirect, so the 401 is the authenticating endpoint's own answer
233
+ const fetchImpl = vi
234
+ .fn()
235
+ .mockResolvedValue(res({ ok: false, status: 401, url }))
236
+
237
+ expect((await followMediaRedirect(url, headers, { fetchImpl })).src).toBe(
238
+ url
239
+ )
240
+ })
241
+
242
+ it('falls back to the original url when the probe outlives its deadline', async () => {
243
+ vi.useFakeTimers()
244
+ try {
245
+ // a host that accepts the connection and then says nothing: without a
246
+ // deadline the load waits on this forever, with no audio and no error
247
+ const fetchImpl = vi.fn(() => new Promise<Response>(() => {}))
248
+ const pending = followMediaRedirect(url, headers, {
249
+ fetchImpl,
250
+ timeoutMs: 1000
251
+ })
252
+
253
+ await vi.advanceTimersByTimeAsync(1000)
254
+ const out = await pending
255
+
256
+ expect(out.src).toBe(url)
257
+ expect(out.headers).toEqual(headers)
258
+ } finally {
259
+ vi.useRealTimers()
260
+ }
261
+ })
262
+
263
+ it('passes an abort signal so a dead host does not hold the connection', async () => {
264
+ const fetchImpl = vi.fn().mockResolvedValue(res({ url: signed }))
265
+
266
+ await followMediaRedirect(url, headers, { fetchImpl })
267
+
268
+ expect(fetchImpl.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal)
269
+ })
270
+
271
+ it('falls back to the original url when the fetch throws', async () => {
272
+ const fetchImpl = vi.fn().mockRejectedValue(new Error('CORS'))
273
+
274
+ expect((await followMediaRedirect(url, headers, { fetchImpl })).src).toBe(
275
+ url
276
+ )
277
+ })
278
+
279
+ it('falls back to the original url when no fetch is available', async () => {
280
+ const globalFetch = globalThis.fetch
281
+ // @ts-expect-error — exercising a runtime without fetch
282
+ globalThis.fetch = undefined
283
+ try {
284
+ expect((await followMediaRedirect(url, headers)).src).toBe(url)
285
+ } finally {
286
+ globalThis.fetch = globalFetch
287
+ }
288
+ })
289
+ })