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.
- package/android/build.gradle +2 -1
- package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +160 -106
- package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +11 -29
- package/android/src/main/java/com/audiobrowser/player/CrossOriginHeaderStripper.kt +77 -0
- package/android/src/main/java/com/audiobrowser/player/MediaFactory.kt +28 -10
- package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +377 -110
- package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +13 -1
- package/android/src/main/res/values/strings.xml +6 -21
- package/android/src/test/java/com/audiobrowser/player/BrowseTilesTest.kt +385 -0
- package/android/src/test/java/com/audiobrowser/player/CrossOriginHeaderStripperTest.kt +94 -0
- package/lib/commonjs/features/browser.js +8 -0
- package/lib/commonjs/features/browser.js.map +1 -1
- package/lib/commonjs/web/NativeAudioBrowser.js +163 -50
- package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/LoadAttempt.js +37 -0
- package/lib/commonjs/web/TrackPlayer/LoadAttempt.js.map +1 -0
- package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js +18 -2
- package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/Player.js +232 -39
- package/lib/commonjs/web/TrackPlayer/Player.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/QueuePlayer.js +33 -12
- package/lib/commonjs/web/TrackPlayer/QueuePlayer.js.map +1 -1
- package/lib/commonjs/web/browser/BrowserManager.js +30 -12
- package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
- package/lib/commonjs/web/browser/SearchManager.js +16 -19
- package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
- package/lib/commonjs/web/http/RequestConfigBuilder.js +110 -27
- package/lib/commonjs/web/http/RequestConfigBuilder.js.map +1 -1
- package/lib/commonjs/web/http/followMediaRedirect.js +219 -0
- package/lib/commonjs/web/http/followMediaRedirect.js.map +1 -0
- package/lib/module/features/browser.js +8 -0
- package/lib/module/features/browser.js.map +1 -1
- package/lib/module/web/NativeAudioBrowser.js +163 -50
- package/lib/module/web/NativeAudioBrowser.js.map +1 -1
- package/lib/module/web/TrackPlayer/LoadAttempt.js +32 -0
- package/lib/module/web/TrackPlayer/LoadAttempt.js.map +1 -0
- package/lib/module/web/TrackPlayer/PlaybackStateMachine.js +18 -2
- package/lib/module/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
- package/lib/module/web/TrackPlayer/Player.js +232 -39
- package/lib/module/web/TrackPlayer/Player.js.map +1 -1
- package/lib/module/web/TrackPlayer/QueuePlayer.js +33 -12
- package/lib/module/web/TrackPlayer/QueuePlayer.js.map +1 -1
- package/lib/module/web/browser/BrowserManager.js +30 -12
- package/lib/module/web/browser/BrowserManager.js.map +1 -1
- package/lib/module/web/browser/SearchManager.js +16 -19
- package/lib/module/web/browser/SearchManager.js.map +1 -1
- package/lib/module/web/http/RequestConfigBuilder.js +110 -27
- package/lib/module/web/http/RequestConfigBuilder.js.map +1 -1
- package/lib/module/web/http/followMediaRedirect.js +215 -0
- package/lib/module/web/http/followMediaRedirect.js.map +1 -0
- package/lib/typescript/src/features/browser.d.ts +8 -0
- package/lib/typescript/src/features/browser.d.ts.map +1 -1
- package/lib/typescript/src/types/browser.d.ts +13 -4
- package/lib/typescript/src/types/browser.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/features/browser.ts +8 -0
- package/src/types/browser.ts +13 -4
- package/src/web/NativeAudioBrowser.playWhenReady.test.ts +232 -2
- package/src/web/NativeAudioBrowser.ts +193 -60
- package/src/web/TrackPlayer/LoadAttempt.ts +29 -0
- package/src/web/TrackPlayer/PlaybackStateMachine.test.ts +60 -13
- package/src/web/TrackPlayer/PlaybackStateMachine.ts +22 -6
- package/src/web/TrackPlayer/Player.dispatch.test.ts +5 -3
- package/src/web/TrackPlayer/Player.load.test.ts +137 -1
- package/src/web/TrackPlayer/Player.ts +256 -37
- package/src/web/TrackPlayer/QueuePlayer.ts +33 -14
- package/src/web/__tests__/lifecycle.test.ts +345 -0
- package/src/web/__tests__/lifecycleHarness.ts +351 -0
- package/src/web/__tests__/loadEdgeCases.test.ts +195 -0
- package/src/web/__tests__/loadSupersession.test.ts +175 -0
- package/src/web/__tests__/retry.test.ts +169 -0
- package/src/web/browser/BrowserManager.ts +32 -15
- package/src/web/browser/SearchManager.test.ts +75 -0
- package/src/web/browser/SearchManager.ts +23 -26
- package/src/web/browser/__tests__/artwork-resolution.test.ts +117 -0
- package/src/web/browser/__tests__/layer-resolver.test.ts +35 -0
- package/src/web/browser/__tests__/search-navigation.test.ts +76 -0
- package/src/web/http/RequestConfigBuilder.test.ts +241 -1
- package/src/web/http/RequestConfigBuilder.ts +140 -33
- package/src/web/http/followMediaRedirect.test.ts +289 -0
- 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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
238
|
+
async resolveMediaRequest(
|
|
190
239
|
src: string,
|
|
191
240
|
requestConfig: TransformableRequestConfig | undefined,
|
|
192
|
-
mediaConfig: MediaRequestConfig | undefined
|
|
193
|
-
|
|
241
|
+
mediaConfig: MediaRequestConfig | undefined,
|
|
242
|
+
track?: Track
|
|
243
|
+
): Promise<ResolvedMediaRequest> {
|
|
194
244
|
try {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
})
|