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
@@ -15,7 +15,9 @@ export class SearchManager {
15
15
  * Matches Android's BrowserManager.search(params: SearchParams) behavior.
16
16
  *
17
17
  * @param params Search parameters (query is required, other fields optional)
18
- * @returns Array of matching tracks
18
+ * @returns Array of matching tracks — empty only when the source found none
19
+ * @throws The failure itself: an `Error` carrying a `NavigationErrorType`
20
+ * `code` from `HttpClient`, or whatever a `searchCallback` rejected with
19
21
  */
20
22
  async search(params: SearchParams): Promise<Track[]> {
21
23
  // Find __search__ route entry
@@ -48,36 +50,31 @@ export class SearchManager {
48
50
 
49
51
  // Delegate to the shared layered fetch (request → search) on BrowserManager
50
52
  // so the ladder lives in one place.
51
- try {
52
- results = await this.browserManager.fetchSearchResults(
53
- searchRoute.searchConfig,
54
- searchQueryParams
55
- )
56
- } catch (error) {
57
- console.error('Search failed:', error)
58
- return []
59
- }
53
+ results = await this.browserManager.fetchSearchResults(
54
+ searchRoute.searchConfig,
55
+ searchQueryParams
56
+ )
60
57
  }
61
58
 
62
59
  // Transform artwork URLs on search results using async method with full Track access
60
+ // Gated per track inside the resolver, as on Android — a result carrying a
61
+ // plain `artwork` URL resolves even with no `artwork` config.
63
62
  const { request: requestConfig, artwork: artworkConfig } =
64
63
  this.browserManager.configuration
65
- if (artworkConfig) {
66
- results = await Promise.all(
67
- results.map(async (track) => {
68
- const artworkSource =
69
- await RequestConfigBuilder.resolveArtworkSourceAsync(
70
- track,
71
- requestConfig,
72
- artworkConfig
73
- )
74
- if (artworkSource && !track.artworkSource) {
75
- return { ...track, artworkSource }
76
- }
77
- return track
78
- })
79
- )
80
- }
64
+ results = await Promise.all(
65
+ results.map(async (track) => {
66
+ const artworkSource =
67
+ await RequestConfigBuilder.resolveArtworkSourceAsync(
68
+ track,
69
+ requestConfig,
70
+ artworkConfig
71
+ )
72
+ if (artworkSource && !track.artworkSource) {
73
+ return { ...track, artworkSource }
74
+ }
75
+ return track
76
+ })
77
+ )
81
78
 
82
79
  return results
83
80
  }
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import type { HttpClient } from '../../http/HttpClient'
3
+ import { BrowserManager } from '../BrowserManager'
4
+ import { FavoriteManager } from '../FavoriteManager'
5
+ import { NavigationErrorManager } from '../NavigationErrorManager'
6
+
7
+ function makeManager(): BrowserManager {
8
+ const httpClient = {
9
+ executeRequest: vi.fn().mockRejectedValue(new Error('no network in test'))
10
+ } as unknown as HttpClient
11
+ return new BrowserManager(
12
+ httpClient,
13
+ new FavoriteManager(),
14
+ new NavigationErrorManager()
15
+ )
16
+ }
17
+
18
+ /**
19
+ * Who gets an `artworkSource`, and on whose say-so.
20
+ *
21
+ * Android gates this per *track* — `resolveArtworkUrl` returns early only when
22
+ * `artworkConfig == null && track.artwork == null`, so a track carrying a plain
23
+ * URL resolves to `{ uri }` with no `artwork` block configured at all. Web used
24
+ * to gate a level up, bailing out of the whole page when `configuration.artwork`
25
+ * was unset, which left every child's `artworkSource` undefined in any app that
26
+ * never declared one — the example app among them.
27
+ */
28
+ describe('artwork resolution without an artwork config', () => {
29
+ it('populates artworkSource for a child carrying a plain artwork url', async () => {
30
+ const manager = makeManager()
31
+ manager.configuration = {
32
+ path: '/',
33
+ routes: [
34
+ {
35
+ path: '/library',
36
+ browseStatic: {
37
+ path: '/library',
38
+ title: 'Library',
39
+ sections: [
40
+ {
41
+ title: 'Collections',
42
+ children: [
43
+ {
44
+ title: 'Cratediggers',
45
+ path: '/collection/cratediggers',
46
+ artwork: 'https://cdn.example.com/cratediggers.jpg'
47
+ }
48
+ ]
49
+ }
50
+ ]
51
+ }
52
+ }
53
+ ]
54
+ }
55
+
56
+ await manager.navigatePath('/library')
57
+
58
+ const tile = manager.content?.sections?.[0]?.children[0]
59
+ expect(tile?.artworkSource?.uri).toBe(
60
+ 'https://cdn.example.com/cratediggers.jpg'
61
+ )
62
+ })
63
+
64
+ it('leaves artworkSource undefined for a child with no artwork', async () => {
65
+ const manager = makeManager()
66
+ manager.configuration = {
67
+ path: '/',
68
+ routes: [
69
+ {
70
+ path: '/library',
71
+ browseStatic: {
72
+ path: '/library',
73
+ title: 'Library',
74
+ sections: [
75
+ {
76
+ title: 'Collections',
77
+ children: [{ title: 'Bare', path: '/collection/bare' }]
78
+ }
79
+ ]
80
+ }
81
+ }
82
+ ]
83
+ }
84
+
85
+ await manager.navigatePath('/library')
86
+
87
+ const tile = manager.content?.sections?.[0]?.children[0]
88
+ expect(tile?.artworkSource).toBeUndefined()
89
+ })
90
+
91
+ it('treats an empty artwork string as no artwork, as Android does', async () => {
92
+ const manager = makeManager()
93
+ manager.configuration = {
94
+ path: '/',
95
+ routes: [
96
+ {
97
+ path: '/library',
98
+ browseStatic: {
99
+ path: '/library',
100
+ title: 'Library',
101
+ sections: [
102
+ {
103
+ title: 'Collections',
104
+ children: [{ title: 'Empty', path: '/x', artwork: '' }]
105
+ }
106
+ ]
107
+ }
108
+ }
109
+ ]
110
+ }
111
+
112
+ await manager.navigatePath('/library')
113
+
114
+ const tile = manager.content?.sections?.[0]?.children[0]
115
+ expect(tile?.artworkSource).toBeUndefined()
116
+ })
117
+ })
@@ -66,4 +66,39 @@ describe('BrowserManager layer resolver', () => {
66
66
  await expect(manager.navigatePath('/a')).resolves.not.toThrow()
67
67
  expect(calls).toBe(1)
68
68
  })
69
+
70
+ /**
71
+ * The media and artwork paths sit outside browse and so must reach the
72
+ * request layer through this accessor. Reading `configuration.request`
73
+ * instead hands a resolver-only config no baseUrl and no headers — on
74
+ * exactly the requests that carry a credential. Android's
75
+ * `resolvedRequestConfig()` exists for the same reason.
76
+ */
77
+ it('exposes the resolver result to consumers outside the browse path', async () => {
78
+ const manager = makeManager()
79
+ manager.configuration = {
80
+ path: '/',
81
+ requestResolver: async () => ({
82
+ baseUrl: 'https://api.example.com',
83
+ headers: { Authorization: 'Bearer token' }
84
+ })
85
+ }
86
+
87
+ expect(await manager.resolvedRequestConfig()).toEqual({
88
+ baseUrl: 'https://api.example.com',
89
+ headers: { Authorization: 'Bearer token' }
90
+ })
91
+ })
92
+
93
+ it('falls back to the static request config when no resolver is set', async () => {
94
+ const manager = makeManager()
95
+ manager.configuration = {
96
+ path: '/',
97
+ request: { baseUrl: 'https://static.example.com' }
98
+ }
99
+
100
+ expect(await manager.resolvedRequestConfig()).toEqual({
101
+ baseUrl: 'https://static.example.com'
102
+ })
103
+ })
69
104
  })
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import type { FormattedNavigationError } from '../../../features'
3
+ import type { HttpClient } from '../../http/HttpClient'
4
+ import { BrowserManager } from '../BrowserManager'
5
+ import { FavoriteManager } from '../FavoriteManager'
6
+ import { NavigationErrorManager } from '../NavigationErrorManager'
7
+
8
+ /**
9
+ * A BrowserManager whose only route is a `searchConfig`, with its HTTP client
10
+ * answering every request with `respond`.
11
+ */
12
+ function makeManager(respond: () => Promise<unknown>): {
13
+ manager: BrowserManager
14
+ errors: NavigationErrorManager
15
+ formattedPaths: string[]
16
+ } {
17
+ const httpClient = {
18
+ executeRequest: vi.fn().mockImplementation(respond)
19
+ } as unknown as HttpClient
20
+ const errors = new NavigationErrorManager()
21
+ const manager = new BrowserManager(httpClient, new FavoriteManager(), errors)
22
+ const formattedPaths: string[] = []
23
+ manager.configuration = {
24
+ routes: [
25
+ {
26
+ path: '__search__',
27
+ searchConfig: { baseUrl: 'https://api.example.com' }
28
+ }
29
+ ],
30
+ formatNavigationError: ({ path }): FormattedNavigationError => {
31
+ formattedPaths.push(path)
32
+ return { title: 'Search unavailable' }
33
+ }
34
+ }
35
+ return { manager, errors, formattedPaths }
36
+ }
37
+
38
+ const httpError = (): Error => {
39
+ const error = new Error('HTTP 500: Internal Server Error') as Error & {
40
+ code: string
41
+ statusCode: number
42
+ }
43
+ error.code = 'http-error'
44
+ error.statusCode = 500
45
+ return error
46
+ }
47
+
48
+ describe('navigating to a search path', () => {
49
+ it('surfaces a navigation error when the search fails', async () => {
50
+ const { manager, errors, formattedPaths } = makeManager(() =>
51
+ Promise.reject(httpError())
52
+ )
53
+
54
+ await manager.navigatePath('/__search?q=jazz')
55
+
56
+ expect(errors.getNavigationError()).toEqual({
57
+ code: 'http-error',
58
+ message: 'HTTP 500: Internal Server Error',
59
+ statusCode: 500,
60
+ statusCodeSuccess: undefined
61
+ })
62
+ expect(formattedPaths).toEqual(['/__search?q=jazz'])
63
+ expect(manager.content).toBeUndefined()
64
+ })
65
+
66
+ it('resolves an empty page without an error when the search found nothing', async () => {
67
+ const { manager, errors } = makeManager(() =>
68
+ Promise.resolve({ children: [] })
69
+ )
70
+
71
+ await manager.navigatePath('/__search?q=jazz')
72
+
73
+ expect(errors.getNavigationError()).toBeUndefined()
74
+ expect(manager.content?.sections).toEqual([{ children: [] }])
75
+ })
76
+ })
@@ -1,5 +1,9 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import type { RequestConfig, TransformableRequestConfig } from '../../types'
2
+ import type {
3
+ RequestConfig,
4
+ Track,
5
+ TransformableRequestConfig
6
+ } from '../../types'
3
7
  import { RequestConfigBuilder } from './RequestConfigBuilder'
4
8
 
5
9
  /**
@@ -48,6 +52,29 @@ describe('RequestConfigBuilder.applyLayer — sync/async transform composition',
48
52
  expect(out.query).toEqual({ stage: 'sync' })
49
53
  })
50
54
 
55
+ it('falls back to the base when a transform throws', async () => {
56
+ const layer: TransformableRequestConfig = {
57
+ transform: async () => {
58
+ throw new Error('boom')
59
+ }
60
+ }
61
+ const out = await RequestConfigBuilder.applyLayer(base, layer)
62
+ expect(out).toEqual(base)
63
+ })
64
+
65
+ it('falls back to the base when the sync stage throws after the async one', async () => {
66
+ const layer: TransformableRequestConfig = {
67
+ transform: async (req) => ({ ...req, headers: { a: '1' } }),
68
+ transformSync: () => {
69
+ throw new Error('boom')
70
+ }
71
+ }
72
+ const out = await RequestConfigBuilder.applyLayer(base, layer)
73
+ // the layer's input, not the half-applied config — matches native's single
74
+ // catch around the pair
75
+ expect(out).toEqual(base)
76
+ })
77
+
51
78
  it('falls back to a static field merge when no transform is set', async () => {
52
79
  const layer: TransformableRequestConfig = {
53
80
  baseUrl: 'https://override.example.com'
@@ -57,3 +84,216 @@ describe('RequestConfigBuilder.applyLayer — sync/async transform composition',
57
84
  expect(out.path).toBe('/p')
58
85
  })
59
86
  })
87
+
88
+ /**
89
+ * A media request needs more than its URL. The layers resolve headers (and a
90
+ * user agent) alongside it, and native applies them to the AVURLAsset /
91
+ * ExoPlayer DataSpec — so a resolver that returned only the URL left web unable
92
+ * to play media that authenticated fine on iOS and Android.
93
+ */
94
+ describe('RequestConfigBuilder.resolveMediaRequest', () => {
95
+ const src = 'https://cdn.example.com/track.m3u8'
96
+
97
+ it('keeps the headers a media transform resolves', async () => {
98
+ const media = {
99
+ transform: async (req: RequestConfig) => ({
100
+ ...req,
101
+ headers: { ...req.headers, Authorization: 'Bearer token' }
102
+ })
103
+ }
104
+
105
+ const out = await RequestConfigBuilder.resolveMediaRequest(
106
+ src,
107
+ undefined,
108
+ media
109
+ )
110
+
111
+ expect(out.path).toBe(src)
112
+ expect(out.headers).toEqual({ Authorization: 'Bearer token' })
113
+ })
114
+
115
+ it('keeps the headers of a static media config', async () => {
116
+ const out = await RequestConfigBuilder.resolveMediaRequest(src, undefined, {
117
+ headers: { 'X-Api-Key': 'k' }
118
+ })
119
+
120
+ expect(out.headers).toEqual({ 'X-Api-Key': 'k' })
121
+ })
122
+
123
+ it('merges the shared request layer under the media layer', async () => {
124
+ const out = await RequestConfigBuilder.resolveMediaRequest(
125
+ src,
126
+ { headers: { 'User-Agent': 'shared', 'X-Shared': 'yes' } },
127
+ { headers: { 'User-Agent': 'media' } }
128
+ )
129
+
130
+ expect(out.headers).toEqual({
131
+ 'User-Agent': 'media',
132
+ 'X-Shared': 'yes'
133
+ })
134
+ })
135
+
136
+ it('folds baseUrl into the resolved path', async () => {
137
+ const out = await RequestConfigBuilder.resolveMediaRequest(
138
+ '/track.m3u8',
139
+ undefined,
140
+ { baseUrl: 'https://cdn.example.com' }
141
+ )
142
+
143
+ expect(out.path).toBe('https://cdn.example.com/track.m3u8')
144
+ expect(out.baseUrl).toBeUndefined()
145
+ })
146
+
147
+ it('carries the media config query onto the resolved url', async () => {
148
+ const out = await RequestConfigBuilder.resolveMediaRequest(src, undefined, {
149
+ query: { token: 'abc' }
150
+ })
151
+
152
+ expect(out.path).toBe(`${src}?token=abc`)
153
+ // cleared so the resolved url can't be rebuilt and double-append it
154
+ expect(out.query).toBeUndefined()
155
+ })
156
+
157
+ it('appends query to a url that already has one', async () => {
158
+ const out = await RequestConfigBuilder.resolveMediaRequest(
159
+ 'https://cdn.example.com/track.m3u8?v=2',
160
+ undefined,
161
+ { query: { token: 'abc' } }
162
+ )
163
+
164
+ expect(out.path).toBe('https://cdn.example.com/track.m3u8?v=2&token=abc')
165
+ })
166
+
167
+ it('falls back to the original src when a transform throws', async () => {
168
+ const out = await RequestConfigBuilder.resolveMediaRequest(src, undefined, {
169
+ transform: async () => {
170
+ throw new Error('boom')
171
+ }
172
+ })
173
+
174
+ expect(out.path).toBe(src)
175
+ expect(out.headers).toBeUndefined()
176
+ })
177
+
178
+ it('keeps the shared layer when the media transform throws', async () => {
179
+ const out = await RequestConfigBuilder.resolveMediaRequest(
180
+ '/track.mp3',
181
+ {
182
+ baseUrl: 'https://api.example.com',
183
+ headers: { Authorization: 'Bearer token' }
184
+ },
185
+ {
186
+ transform: async () => {
187
+ throw new Error('boom')
188
+ }
189
+ }
190
+ )
191
+
192
+ // the media layer is skipped, the ladder under it stands — dropping it too
193
+ // would play a relative url with no credential
194
+ expect(out.path).toBe('https://api.example.com/track.mp3')
195
+ expect(out.headers).toEqual({ Authorization: 'Bearer token' })
196
+ })
197
+ })
198
+
199
+ /**
200
+ * `media.resolve(track)` is the final, most-specific layer — the shape native
201
+ * runs (Android `applyMediaResolve`, iOS `applyMediaResolveLayer`) and the one
202
+ * the public docs promise. Web ignored it entirely until these landed, so a
203
+ * config minting per-track signed URLs played on both native platforms and
204
+ * silently played the unsigned src on web.
205
+ */
206
+ describe('RequestConfigBuilder.resolveMediaRequest — media.resolve', () => {
207
+ const src = '/track.mp3'
208
+ const track: Track = { id: '7', title: 'Track 7', src }
209
+
210
+ it('lets the resolver replace the url', async () => {
211
+ const out = await RequestConfigBuilder.resolveMediaRequest(
212
+ src,
213
+ undefined,
214
+ { resolve: async (t) => ({ path: `/signed/${t.id}.mp3` }) },
215
+ track
216
+ )
217
+
218
+ expect(out.path).toBe('/signed/7.mp3')
219
+ })
220
+
221
+ it('runs after the media transform, and wins over it', async () => {
222
+ const order: string[] = []
223
+ const out = await RequestConfigBuilder.resolveMediaRequest(
224
+ src,
225
+ undefined,
226
+ {
227
+ transform: async (req) => {
228
+ order.push('transform')
229
+ return { ...req, path: '/unsigned.mp3', headers: { A: 'transform' } }
230
+ },
231
+ resolve: async () => {
232
+ order.push('resolve')
233
+ return { path: '/signed.mp3', headers: { A: 'resolve' } }
234
+ }
235
+ },
236
+ track
237
+ )
238
+
239
+ expect(order).toEqual(['transform', 'resolve'])
240
+ expect(out.path).toBe('/signed.mp3')
241
+ expect(out.headers).toEqual({ A: 'resolve' })
242
+ })
243
+
244
+ it('merges resolveSync over resolve, and both over the layers', async () => {
245
+ const out = await RequestConfigBuilder.resolveMediaRequest(
246
+ src,
247
+ { headers: { 'X-Shared': 'yes' } },
248
+ {
249
+ headers: { 'X-Media': 'yes' },
250
+ resolve: async () => ({ headers: { Authorization: 'async' } }),
251
+ resolveSync: () => ({ headers: { Authorization: 'sync' } })
252
+ },
253
+ track
254
+ )
255
+
256
+ expect(out.headers).toEqual({
257
+ 'X-Shared': 'yes',
258
+ 'X-Media': 'yes',
259
+ 'Authorization': 'sync'
260
+ })
261
+ })
262
+
263
+ it('reports the resolver contribution as a media-layer header', async () => {
264
+ const out = await RequestConfigBuilder.resolveMediaRequest(
265
+ src,
266
+ { headers: { 'X-Client-Version': '1.2.3' } },
267
+ { resolve: async () => ({ headers: { 'X-Api-Key': 'k' } }) },
268
+ track
269
+ )
270
+
271
+ // per-track and media-specific, so the web redirect probe should run
272
+ expect(out.mediaLayerHeaders).toBe(true)
273
+ })
274
+
275
+ it('keeps the layers when the resolver throws', async () => {
276
+ const out = await RequestConfigBuilder.resolveMediaRequest(
277
+ src,
278
+ { baseUrl: 'https://api.example.com', headers: { 'X-Shared': 'yes' } },
279
+ {
280
+ resolve: async () => {
281
+ throw new Error('boom')
282
+ }
283
+ },
284
+ track
285
+ )
286
+
287
+ // native loses the per-track shaping, not the layers under it
288
+ expect(out.path).toBe('https://api.example.com/track.mp3')
289
+ expect(out.headers).toEqual({ 'X-Shared': 'yes' })
290
+ })
291
+
292
+ it('skips the resolver when there is no track', async () => {
293
+ const out = await RequestConfigBuilder.resolveMediaRequest(src, undefined, {
294
+ resolve: async () => ({ path: '/signed.mp3' })
295
+ })
296
+
297
+ expect(out.path).toBe(src)
298
+ })
299
+ })