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
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
66
|
-
results
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 {
|
|
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
|
+
})
|