@editframe/elements 0.18.8-beta.0 → 0.18.19-beta.0
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/dist/elements/EFMedia/AssetIdMediaEngine.js +4 -1
- package/dist/elements/EFMedia/AssetMediaEngine.d.ts +3 -4
- package/dist/elements/EFMedia/AssetMediaEngine.js +16 -15
- package/dist/elements/EFMedia/BaseMediaEngine.d.ts +30 -11
- package/dist/elements/EFMedia/BaseMediaEngine.js +83 -31
- package/dist/elements/EFMedia/JitMediaEngine.d.ts +2 -4
- package/dist/elements/EFMedia/JitMediaEngine.js +12 -12
- package/dist/elements/EFVideo.d.ts +0 -1
- package/dist/elements/EFVideo.js +0 -9
- package/dist/elements/TargetController.js +3 -2
- package/package.json +2 -2
- package/src/elements/EFMedia/AssetIdMediaEngine.ts +10 -1
- package/src/elements/EFMedia/AssetMediaEngine.ts +25 -21
- package/src/elements/EFMedia/BaseMediaEngine.browsertest.ts +311 -0
- package/src/elements/EFMedia/BaseMediaEngine.ts +168 -51
- package/src/elements/EFMedia/JitMediaEngine.browsertest.ts +2 -12
- package/src/elements/EFMedia/JitMediaEngine.ts +25 -16
- package/src/elements/EFTemporal.browsertest.ts +47 -0
- package/src/elements/EFVideo.browsertest.ts +127 -281
- package/src/elements/EFVideo.ts +9 -9
- package/src/elements/TargetController.ts +6 -2
- package/test/__cache__/GET__api_v1_transcode_audio_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__32da3954ba60c96ad732020c65a08ebc/metadata.json +3 -8
- package/test/__cache__/GET__api_v1_transcode_audio_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__b0b2b07efcf607de8ee0f650328c32f7/metadata.json +3 -8
- package/test/__cache__/GET__api_v1_transcode_audio_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a75c2252b542e0c152c780e9a8d7b154/metadata.json +3 -8
- package/test/__cache__/GET__api_v1_transcode_audio_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a64ff1cfb1b52cae14df4b5dfa1e222b/metadata.json +3 -8
- package/test/__cache__/GET__api_v1_transcode_audio_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__91e8a522f950809b9f09f4173113b4b0/metadata.json +3 -8
- package/test/__cache__/GET__api_v1_transcode_audio_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__e66d2c831d951e74ad0aeaa6489795d0/metadata.json +3 -8
- package/test/__cache__/GET__api_v1_transcode_high_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__26197f6f7c46cacb0a71134131c3f775/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__26197f6f7c46cacb0a71134131c3f775/metadata.json +4 -9
- package/test/__cache__/GET__api_v1_transcode_high_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__4cb6774cd3650ccf59c8f8dc6678c0b9/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__4cb6774cd3650ccf59c8f8dc6678c0b9/metadata.json +4 -9
- package/test/__cache__/GET__api_v1_transcode_high_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0b3b2b1c8933f7fcf8a9ecaa88d58b41/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0b3b2b1c8933f7fcf8a9ecaa88d58b41/metadata.json +4 -9
- package/test/__cache__/GET__api_v1_transcode_high_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a6fb05a22b18d850f7f2950bbcdbdeed/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a6fb05a22b18d850f7f2950bbcdbdeed/metadata.json +4 -9
- package/test/__cache__/GET__api_v1_transcode_high_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a50058c7c3602e90879fe3428ed891f4/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a50058c7c3602e90879fe3428ed891f4/metadata.json +4 -9
- package/test/__cache__/GET__api_v1_transcode_high_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0798c479b44aaeef850609a430f6e613/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0798c479b44aaeef850609a430f6e613/metadata.json +4 -9
- package/test/__cache__/GET__api_v1_transcode_manifest_json_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__3be92a0437de726b431ed5af2369158a/metadata.json +2 -4
- package/test/recordReplayProxyPlugin.js +46 -31
- package/test/setup.ts +16 -0
- package/test/useAssetMSW.ts +54 -0
- package/test/useMSW.ts +4 -11
- package/types.json +1 -1
- package/dist/elements/MediaController.d.ts +0 -30
- package/src/elements/EFMedia/BaseMediaEngine.test.ts +0 -164
- package/src/elements/MediaController.ts +0 -98
- package/test/__cache__/GET__api_v1_transcode_audio_1_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__9ed2d25c675aa6bb6ff5b3ae23887c71/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_audio_1_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__9ed2d25c675aa6bb6ff5b3ae23887c71/metadata.json +0 -22
- package/test/__cache__/GET__api_v1_transcode_audio_2_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__d5a3309a2bf756dd6e304807eb402f56/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_audio_2_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__d5a3309a2bf756dd6e304807eb402f56/metadata.json +0 -22
- package/test/__cache__/GET__api_v1_transcode_audio_3_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__773254bb671e3466fca8677139fb239e/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_audio_3_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__773254bb671e3466fca8677139fb239e/metadata.json +0 -22
- /package/dist/elements/EFMedia/{BaseMediaEngine.test.d.ts → BaseMediaEngine.browsertest.d.ts} +0 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { describe, vi } from "vitest";
|
|
2
|
+
import { test as baseTest } from "../../../test/useMSW.js";
|
|
3
|
+
|
|
4
|
+
import type { EFMedia } from "../EFMedia.js";
|
|
5
|
+
import { BaseMediaEngine, mediaCache } from "./BaseMediaEngine.js";
|
|
6
|
+
|
|
7
|
+
const test = baseTest.extend<{}>({});
|
|
8
|
+
|
|
9
|
+
// Test implementation of BaseMediaEngine for testing
|
|
10
|
+
class TestMediaEngine extends BaseMediaEngine {
|
|
11
|
+
fetchMediaSegment = vi.fn();
|
|
12
|
+
public host: EFMedia;
|
|
13
|
+
|
|
14
|
+
constructor(host: EFMedia) {
|
|
15
|
+
super(host);
|
|
16
|
+
this.host = host;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get videoRendition() {
|
|
20
|
+
return {
|
|
21
|
+
trackId: 1,
|
|
22
|
+
src: "test-video.mp4",
|
|
23
|
+
segmentDurationMs: 2000,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get audioRendition() {
|
|
28
|
+
return {
|
|
29
|
+
trackId: 2,
|
|
30
|
+
src: "test-audio.mp4",
|
|
31
|
+
segmentDurationMs: 1000,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("BaseMediaEngine deduplication", () => {
|
|
37
|
+
test("should fetch segment successfully", async ({ expect }) => {
|
|
38
|
+
const host = document.createElement("ef-video") as EFMedia;
|
|
39
|
+
const engine = new TestMediaEngine(host);
|
|
40
|
+
engine.fetchMediaSegment.mockResolvedValue(new ArrayBuffer(1024));
|
|
41
|
+
|
|
42
|
+
const rendition = { trackId: 1, src: "test.mp4" };
|
|
43
|
+
const result = await engine.fetchMediaSegment(1, rendition);
|
|
44
|
+
|
|
45
|
+
expect(result).toEqual(new ArrayBuffer(1024));
|
|
46
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition);
|
|
47
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledTimes(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("should deduplicate concurrent requests for same segment", async ({
|
|
51
|
+
expect,
|
|
52
|
+
}) => {
|
|
53
|
+
const host = document.createElement("ef-video") as EFMedia;
|
|
54
|
+
const engine = new TestMediaEngine(host);
|
|
55
|
+
const mockSegmentData = new ArrayBuffer(1024);
|
|
56
|
+
engine.fetchMediaSegment.mockResolvedValue(mockSegmentData);
|
|
57
|
+
|
|
58
|
+
const rendition = { trackId: 1, src: "test.mp4" };
|
|
59
|
+
|
|
60
|
+
// Make two concurrent requests for the same segment
|
|
61
|
+
const [result1, result2] = await Promise.all([
|
|
62
|
+
engine.fetchMediaSegmentWithDeduplication(1, rendition),
|
|
63
|
+
engine.fetchMediaSegmentWithDeduplication(1, rendition),
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
// Both should return the same result
|
|
67
|
+
expect(result1).toBe(mockSegmentData);
|
|
68
|
+
expect(result2).toBe(mockSegmentData);
|
|
69
|
+
|
|
70
|
+
// But fetchMediaSegment should only be called once due to deduplication
|
|
71
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("should handle different segments as separate requests", async ({
|
|
76
|
+
expect,
|
|
77
|
+
}) => {
|
|
78
|
+
const host = document.createElement("ef-video") as EFMedia;
|
|
79
|
+
const engine = new TestMediaEngine(host);
|
|
80
|
+
const mockSegmentData = new ArrayBuffer(1024);
|
|
81
|
+
engine.fetchMediaSegment.mockResolvedValue(mockSegmentData);
|
|
82
|
+
|
|
83
|
+
const rendition = { trackId: 1, src: "test.mp4" };
|
|
84
|
+
|
|
85
|
+
// Make concurrent requests for different segments
|
|
86
|
+
const [result1, result2] = await Promise.all([
|
|
87
|
+
engine.fetchMediaSegmentWithDeduplication(1, rendition),
|
|
88
|
+
engine.fetchMediaSegmentWithDeduplication(2, rendition),
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
expect(result1).toBe(mockSegmentData);
|
|
92
|
+
expect(result2).toBe(mockSegmentData);
|
|
93
|
+
|
|
94
|
+
// Should call fetchMediaSegment twice for different segments
|
|
95
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledTimes(2);
|
|
96
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition);
|
|
97
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledWith(2, rendition);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("should handle different renditions as separate requests", async ({
|
|
101
|
+
expect,
|
|
102
|
+
}) => {
|
|
103
|
+
const host = document.createElement("ef-video") as EFMedia;
|
|
104
|
+
const engine = new TestMediaEngine(host);
|
|
105
|
+
const mockSegmentData = new ArrayBuffer(1024);
|
|
106
|
+
engine.fetchMediaSegment.mockResolvedValue(mockSegmentData);
|
|
107
|
+
|
|
108
|
+
const rendition1 = { trackId: 1, src: "test1.mp4" };
|
|
109
|
+
const rendition2 = { trackId: 2, src: "test2.mp4" };
|
|
110
|
+
|
|
111
|
+
// Make concurrent requests for same segment but different renditions
|
|
112
|
+
const [result1, result2] = await Promise.all([
|
|
113
|
+
engine.fetchMediaSegmentWithDeduplication(1, rendition1),
|
|
114
|
+
engine.fetchMediaSegmentWithDeduplication(1, rendition2),
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
expect(result1).toBe(mockSegmentData);
|
|
118
|
+
expect(result2).toBe(mockSegmentData);
|
|
119
|
+
|
|
120
|
+
// Should call fetchMediaSegment twice for different renditions
|
|
121
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledTimes(2);
|
|
122
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition1);
|
|
123
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("should propagate errors correctly", async ({ expect }) => {
|
|
127
|
+
const host = document.createElement("ef-video") as EFMedia;
|
|
128
|
+
const engine = new TestMediaEngine(host);
|
|
129
|
+
const error = new Error("Fetch failed");
|
|
130
|
+
engine.fetchMediaSegment.mockRejectedValue(error);
|
|
131
|
+
|
|
132
|
+
const rendition = { trackId: 1, src: "test.mp4" };
|
|
133
|
+
|
|
134
|
+
await expect(
|
|
135
|
+
engine.fetchMediaSegmentWithDeduplication(1, rendition),
|
|
136
|
+
).rejects.toThrow("Fetch failed");
|
|
137
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("should retry failed requests after error", async ({ expect }) => {
|
|
141
|
+
const host = document.createElement("ef-video") as EFMedia;
|
|
142
|
+
const engine = new TestMediaEngine(host);
|
|
143
|
+
const rendition = { trackId: 1, src: "test.mp4" };
|
|
144
|
+
const error = new Error("First attempt failed");
|
|
145
|
+
const mockSegmentData = new ArrayBuffer(1024);
|
|
146
|
+
|
|
147
|
+
// First request fails
|
|
148
|
+
engine.fetchMediaSegment.mockRejectedValueOnce(error);
|
|
149
|
+
await expect(
|
|
150
|
+
engine.fetchMediaSegmentWithDeduplication(1, rendition),
|
|
151
|
+
).rejects.toThrow("First attempt failed");
|
|
152
|
+
|
|
153
|
+
// Second request should succeed (not deduplicated since first failed)
|
|
154
|
+
engine.fetchMediaSegment.mockResolvedValue(mockSegmentData);
|
|
155
|
+
const result = await engine.fetchMediaSegmentWithDeduplication(
|
|
156
|
+
1,
|
|
157
|
+
rendition,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
expect(result).toBe(mockSegmentData);
|
|
161
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledTimes(2);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("should track pending requests correctly", async ({ expect }) => {
|
|
165
|
+
const host = document.createElement("ef-video") as EFMedia;
|
|
166
|
+
const engine = new TestMediaEngine(host);
|
|
167
|
+
const mockSegmentData = new ArrayBuffer(1024);
|
|
168
|
+
engine.fetchMediaSegment.mockResolvedValue(mockSegmentData);
|
|
169
|
+
|
|
170
|
+
const rendition = { trackId: 1, src: "test.mp4" };
|
|
171
|
+
|
|
172
|
+
// Start a request but don't await it
|
|
173
|
+
const promise = engine.fetchMediaSegmentWithDeduplication(1, rendition);
|
|
174
|
+
|
|
175
|
+
// Should detect that segment is being fetched
|
|
176
|
+
expect(engine.isSegmentBeingFetched(1, rendition)).toBe(true);
|
|
177
|
+
expect(engine.getActiveSegmentRequestCount()).toBe(1);
|
|
178
|
+
|
|
179
|
+
// Different segment should not be detected as being fetched
|
|
180
|
+
expect(engine.isSegmentBeingFetched(2, rendition)).toBe(false);
|
|
181
|
+
|
|
182
|
+
await promise.then(() => {
|
|
183
|
+
// After completion, should no longer be detected as being fetched
|
|
184
|
+
expect(engine.isSegmentBeingFetched(1, rendition)).toBe(false);
|
|
185
|
+
expect(engine.getActiveSegmentRequestCount()).toBe(0);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("should cancel all requests correctly", async ({ expect }) => {
|
|
190
|
+
const host = document.createElement("ef-video") as EFMedia;
|
|
191
|
+
const engine = new TestMediaEngine(host);
|
|
192
|
+
const rendition = { trackId: 1, src: "test.mp4" };
|
|
193
|
+
|
|
194
|
+
// Start a request
|
|
195
|
+
engine.fetchMediaSegmentWithDeduplication(1, rendition);
|
|
196
|
+
expect(engine.getActiveSegmentRequestCount()).toBe(1);
|
|
197
|
+
|
|
198
|
+
// Cancel all requests
|
|
199
|
+
engine.cancelAllSegmentRequests();
|
|
200
|
+
expect(engine.getActiveSegmentRequestCount()).toBe(0);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("BaseMediaEngine caching", () => {
|
|
205
|
+
test("should use shared media cache", async ({ expect }) => {
|
|
206
|
+
// Test that the cache is shared across instances
|
|
207
|
+
expect(mediaCache).toBe(mediaCache);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("should have cache size limit", async ({ expect }) => {
|
|
211
|
+
// Test that the cache has the expected size limit
|
|
212
|
+
expect(mediaCache.maxSize).toBe(100 * 1024 * 1024);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("BaseMediaEngine abort signal handling", () => {
|
|
217
|
+
test("should handle abort signals independently in BaseMediaEngine", async ({
|
|
218
|
+
expect,
|
|
219
|
+
}) => {
|
|
220
|
+
// Create a test host that actually has a fetch method
|
|
221
|
+
const host = {
|
|
222
|
+
fetch: vi
|
|
223
|
+
.fn()
|
|
224
|
+
.mockImplementation(
|
|
225
|
+
() =>
|
|
226
|
+
new Promise((resolve) =>
|
|
227
|
+
setTimeout(
|
|
228
|
+
() => resolve({ arrayBuffer: () => new ArrayBuffer(1024) }),
|
|
229
|
+
100,
|
|
230
|
+
),
|
|
231
|
+
),
|
|
232
|
+
),
|
|
233
|
+
} as any;
|
|
234
|
+
|
|
235
|
+
const engine = new TestMediaEngine(host);
|
|
236
|
+
|
|
237
|
+
// Create two abort controllers
|
|
238
|
+
const controller1 = new AbortController();
|
|
239
|
+
const controller2 = new AbortController();
|
|
240
|
+
|
|
241
|
+
// Start two requests with different abort signals to the same URL
|
|
242
|
+
const promise1 = engine.fetchMedia("test-url", controller1.signal);
|
|
243
|
+
const promise2 = engine.fetchMedia("test-url", controller2.signal);
|
|
244
|
+
|
|
245
|
+
// Abort only the first request
|
|
246
|
+
controller1.abort();
|
|
247
|
+
|
|
248
|
+
// First request should fail with AbortError
|
|
249
|
+
await expect(promise1).rejects.toThrow("Aborted");
|
|
250
|
+
|
|
251
|
+
// Second request should still succeed
|
|
252
|
+
const result2 = await promise2;
|
|
253
|
+
expect(result2).toBeInstanceOf(ArrayBuffer);
|
|
254
|
+
expect(result2.byteLength).toBe(1024);
|
|
255
|
+
|
|
256
|
+
// The network request should only be made once due to deduplication
|
|
257
|
+
// This validates our fix: deduplication works but signals are independent
|
|
258
|
+
expect(host.fetch).toHaveBeenCalledTimes(1);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("should handle immediate abort in BaseMediaEngine", async ({
|
|
262
|
+
expect,
|
|
263
|
+
}) => {
|
|
264
|
+
const host = {
|
|
265
|
+
fetch: vi
|
|
266
|
+
.fn()
|
|
267
|
+
.mockResolvedValue({ arrayBuffer: () => new ArrayBuffer(1024) }),
|
|
268
|
+
} as any;
|
|
269
|
+
|
|
270
|
+
const engine = new TestMediaEngine(host);
|
|
271
|
+
|
|
272
|
+
// Create an already aborted signal
|
|
273
|
+
const controller = new AbortController();
|
|
274
|
+
controller.abort();
|
|
275
|
+
|
|
276
|
+
// Request should fail immediately with AbortError
|
|
277
|
+
await expect(
|
|
278
|
+
engine.fetchMedia("test-url", controller.signal),
|
|
279
|
+
).rejects.toThrow("Aborted");
|
|
280
|
+
|
|
281
|
+
// No network request should be made since abort happens in wrapper method
|
|
282
|
+
expect(host.fetch).not.toHaveBeenCalled();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("should maintain deduplication while respecting individual abort signals", async ({
|
|
286
|
+
expect,
|
|
287
|
+
}) => {
|
|
288
|
+
const host = {
|
|
289
|
+
fetch: vi
|
|
290
|
+
.fn()
|
|
291
|
+
.mockResolvedValue({ arrayBuffer: () => new ArrayBuffer(1024) }),
|
|
292
|
+
} as any;
|
|
293
|
+
|
|
294
|
+
const engine = new TestMediaEngine(host);
|
|
295
|
+
|
|
296
|
+
// Clear any existing cache to ensure clean test
|
|
297
|
+
mediaCache.clear();
|
|
298
|
+
|
|
299
|
+
// Make two requests without signals
|
|
300
|
+
const promise1 = engine.fetchMedia("test-url");
|
|
301
|
+
const promise2 = engine.fetchMedia("test-url");
|
|
302
|
+
|
|
303
|
+
// Both should succeed
|
|
304
|
+
const [result1, result2] = await Promise.all([promise1, promise2]);
|
|
305
|
+
expect(result1).toBeInstanceOf(ArrayBuffer);
|
|
306
|
+
expect(result2).toBeInstanceOf(ArrayBuffer);
|
|
307
|
+
|
|
308
|
+
// Should deduplicate to one network request
|
|
309
|
+
expect(host.fetch).toHaveBeenCalledTimes(1);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -7,16 +7,19 @@ import type {
|
|
|
7
7
|
import { SizeAwareLRUCache } from "../../utils/LRUCache.js";
|
|
8
8
|
import type { EFMedia } from "../EFMedia.js";
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
const mediaCache = new SizeAwareLRUCache<string>(100 * 1024 * 1024);
|
|
10
|
+
// Global instances shared across all media engines
|
|
11
|
+
export const mediaCache = new SizeAwareLRUCache<string>(100 * 1024 * 1024); // 100MB cache limit
|
|
12
|
+
export const globalRequestDeduplicator = new RequestDeduplicator();
|
|
12
13
|
|
|
13
14
|
export abstract class BaseMediaEngine {
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
protected host: EFMedia;
|
|
16
|
+
|
|
17
|
+
constructor(host: EFMedia) {
|
|
18
|
+
this.host = host;
|
|
19
|
+
}
|
|
16
20
|
|
|
17
21
|
abstract get videoRendition(): VideoRendition | undefined;
|
|
18
22
|
abstract get audioRendition(): AudioRendition | undefined;
|
|
19
|
-
abstract get host(): EFMedia;
|
|
20
23
|
|
|
21
24
|
getVideoRendition(): VideoRendition {
|
|
22
25
|
if (!this.videoRendition) {
|
|
@@ -42,27 +45,179 @@ export abstract class BaseMediaEngine {
|
|
|
42
45
|
return `${rendition.src}-${rendition.id}-${segmentId}-${rendition.trackId}`;
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Unified fetch method with caching and global deduplication
|
|
50
|
+
* All requests (media, manifest, init segments) go through this method
|
|
51
|
+
*/
|
|
52
|
+
protected async fetchWithCache(
|
|
53
|
+
url: string,
|
|
54
|
+
options: {
|
|
55
|
+
responseType: "arrayBuffer" | "json";
|
|
56
|
+
headers?: Record<string, string>;
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
},
|
|
59
|
+
): Promise<any> {
|
|
60
|
+
const { responseType, headers, signal } = options;
|
|
61
|
+
|
|
62
|
+
// Create cache key that includes URL and headers for proper isolation
|
|
63
|
+
// Note: We don't include signal in cache key as it would prevent proper deduplication
|
|
64
|
+
const cacheKey = headers ? `${url}:${JSON.stringify(headers)}` : url;
|
|
65
|
+
|
|
66
|
+
// Check cache first
|
|
67
|
+
const cached = mediaCache.get(cacheKey);
|
|
68
|
+
if (cached) {
|
|
69
|
+
// If we have a cached promise, we need to handle the caller's abort signal
|
|
70
|
+
// without affecting the underlying request that other instances might be using
|
|
71
|
+
if (signal) {
|
|
72
|
+
return this.handleAbortForCachedRequest(cached, signal);
|
|
73
|
+
}
|
|
74
|
+
return cached;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Use global deduplicator to prevent concurrent requests for the same resource
|
|
78
|
+
// Note: We do NOT pass the signal to the deduplicator - each caller manages their own abort
|
|
79
|
+
const promise = globalRequestDeduplicator.executeRequest(
|
|
80
|
+
cacheKey,
|
|
81
|
+
async () => {
|
|
82
|
+
try {
|
|
83
|
+
// Make the fetch request WITHOUT the signal - let each caller handle their own abort
|
|
84
|
+
// This prevents one instance's abort from affecting other instances using the shared cache
|
|
85
|
+
const response = await this.host.fetch(url, { headers });
|
|
86
|
+
|
|
87
|
+
if (responseType === "json") {
|
|
88
|
+
return response.json();
|
|
89
|
+
}
|
|
90
|
+
return response.arrayBuffer();
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// If the request was aborted, don't cache the error
|
|
93
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
94
|
+
// Remove from cache so other requests can retry
|
|
95
|
+
mediaCache.delete(cacheKey);
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Cache the promise (not the result) to handle concurrent requests
|
|
103
|
+
mediaCache.set(cacheKey, promise);
|
|
104
|
+
|
|
105
|
+
// Handle the case where the promise might be aborted
|
|
106
|
+
promise.catch((error) => {
|
|
107
|
+
// If the request was aborted, remove it from cache to prevent corrupted data
|
|
108
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
109
|
+
mediaCache.delete(cacheKey);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// If the caller has a signal, handle abort logic without affecting the underlying request
|
|
114
|
+
if (signal) {
|
|
115
|
+
return this.handleAbortForCachedRequest(promise, signal);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return promise;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Handles abort logic for a cached request without affecting the underlying fetch
|
|
123
|
+
* This allows multiple instances to share the same cached request while each
|
|
124
|
+
* manages their own abort behavior
|
|
125
|
+
*/
|
|
126
|
+
private handleAbortForCachedRequest<T>(
|
|
127
|
+
promise: Promise<T>,
|
|
128
|
+
signal: AbortSignal,
|
|
129
|
+
): Promise<T> {
|
|
130
|
+
// If signal is already aborted, reject immediately
|
|
131
|
+
if (signal.aborted) {
|
|
132
|
+
throw new DOMException("Aborted", "AbortError");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Return a promise that respects the caller's abort signal
|
|
136
|
+
// but doesn't affect the underlying cached request
|
|
137
|
+
return Promise.race([
|
|
138
|
+
promise,
|
|
139
|
+
new Promise<never>((_, reject) => {
|
|
140
|
+
signal.addEventListener("abort", () => {
|
|
141
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
142
|
+
});
|
|
143
|
+
}),
|
|
144
|
+
]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Public wrapper methods that delegate to fetchWithCache
|
|
148
|
+
async fetchMedia(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
|
|
149
|
+
// Check abort signal immediately before any processing
|
|
150
|
+
if (signal?.aborted) {
|
|
151
|
+
throw new DOMException("Aborted", "AbortError");
|
|
152
|
+
}
|
|
153
|
+
return this.fetchWithCache(url, { responseType: "arrayBuffer", signal });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async fetchManifest(url: string, signal?: AbortSignal): Promise<any> {
|
|
157
|
+
// Check abort signal immediately before any processing
|
|
158
|
+
if (signal?.aborted) {
|
|
159
|
+
throw new DOMException("Aborted", "AbortError");
|
|
160
|
+
}
|
|
161
|
+
return this.fetchWithCache(url, { responseType: "json", signal });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async fetchMediaWithHeaders(
|
|
165
|
+
url: string,
|
|
166
|
+
headers: Record<string, string>,
|
|
167
|
+
signal?: AbortSignal,
|
|
168
|
+
): Promise<ArrayBuffer> {
|
|
169
|
+
// Check abort signal immediately before any processing
|
|
170
|
+
if (signal?.aborted) {
|
|
171
|
+
throw new DOMException("Aborted", "AbortError");
|
|
172
|
+
}
|
|
173
|
+
return this.fetchWithCache(url, {
|
|
174
|
+
responseType: "arrayBuffer",
|
|
175
|
+
headers,
|
|
176
|
+
signal,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Legacy methods for backward compatibility
|
|
181
|
+
async fetchMediaCache(
|
|
182
|
+
url: string,
|
|
183
|
+
signal?: AbortSignal,
|
|
184
|
+
): Promise<ArrayBuffer> {
|
|
185
|
+
return this.fetchMedia(url, signal);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async fetchManifestCache(url: string, signal?: AbortSignal): Promise<any> {
|
|
189
|
+
return this.fetchManifest(url, signal);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async fetchMediaCacheWithHeaders(
|
|
193
|
+
url: string,
|
|
194
|
+
headers: Record<string, string>,
|
|
195
|
+
signal?: AbortSignal,
|
|
196
|
+
): Promise<ArrayBuffer> {
|
|
197
|
+
return this.fetchMediaWithHeaders(url, headers, signal);
|
|
198
|
+
}
|
|
199
|
+
|
|
45
200
|
/**
|
|
46
201
|
* Abstract method for actual segment fetching - implemented by subclasses
|
|
47
202
|
*/
|
|
48
|
-
abstract
|
|
203
|
+
abstract fetchMediaSegment(
|
|
49
204
|
segmentId: number,
|
|
50
205
|
rendition: { trackId: number | undefined; src: string },
|
|
51
206
|
): Promise<ArrayBuffer>;
|
|
52
207
|
|
|
53
208
|
/**
|
|
54
209
|
* Fetch media segment with built-in deduplication
|
|
55
|
-
*
|
|
210
|
+
* Now uses global deduplication for all requests
|
|
56
211
|
*/
|
|
57
|
-
async
|
|
212
|
+
async fetchMediaSegmentWithDeduplication(
|
|
58
213
|
segmentId: number,
|
|
59
214
|
rendition: { trackId: number | undefined; src: string },
|
|
60
215
|
_signal?: AbortSignal,
|
|
61
216
|
): Promise<ArrayBuffer> {
|
|
62
217
|
const cacheKey = this.getSegmentCacheKey(segmentId, rendition);
|
|
63
218
|
|
|
64
|
-
return
|
|
65
|
-
return this.
|
|
219
|
+
return globalRequestDeduplicator.executeRequest(cacheKey, async () => {
|
|
220
|
+
return this.fetchMediaSegment(segmentId, rendition);
|
|
66
221
|
});
|
|
67
222
|
}
|
|
68
223
|
|
|
@@ -74,59 +229,21 @@ export abstract class BaseMediaEngine {
|
|
|
74
229
|
rendition: { src: string; trackId: number | undefined },
|
|
75
230
|
): boolean {
|
|
76
231
|
const cacheKey = this.getSegmentCacheKey(segmentId, rendition);
|
|
77
|
-
return
|
|
232
|
+
return globalRequestDeduplicator.isPending(cacheKey);
|
|
78
233
|
}
|
|
79
234
|
|
|
80
235
|
/**
|
|
81
236
|
* Get count of active segment requests (for debugging/monitoring)
|
|
82
237
|
*/
|
|
83
238
|
getActiveSegmentRequestCount(): number {
|
|
84
|
-
return
|
|
239
|
+
return globalRequestDeduplicator.getPendingCount();
|
|
85
240
|
}
|
|
86
241
|
|
|
87
242
|
/**
|
|
88
243
|
* Cancel all active segment requests (for cleanup)
|
|
89
244
|
*/
|
|
90
245
|
cancelAllSegmentRequests(): void {
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async fetchMediaCache(mediaUrl: string) {
|
|
95
|
-
const cached = mediaCache.get(mediaUrl);
|
|
96
|
-
if (cached) {
|
|
97
|
-
return cached;
|
|
98
|
-
}
|
|
99
|
-
const promise = this.host
|
|
100
|
-
.fetch(mediaUrl)
|
|
101
|
-
.then((response) => response.arrayBuffer());
|
|
102
|
-
mediaCache.set(mediaUrl, promise);
|
|
103
|
-
return promise;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Enhanced caching method that supports custom headers (e.g., Range requests)
|
|
108
|
-
* Cache key includes both URL and headers for proper cache isolation
|
|
109
|
-
*/
|
|
110
|
-
async fetchMediaCacheWithHeaders(
|
|
111
|
-
mediaUrl: string,
|
|
112
|
-
headers?: Record<string, string>,
|
|
113
|
-
signal?: AbortSignal,
|
|
114
|
-
) {
|
|
115
|
-
// Create a cache key that includes both URL and headers
|
|
116
|
-
const cacheKey = headers
|
|
117
|
-
? `${mediaUrl}:${JSON.stringify(headers)}`
|
|
118
|
-
: mediaUrl;
|
|
119
|
-
|
|
120
|
-
const cached = mediaCache.get(cacheKey);
|
|
121
|
-
if (cached) {
|
|
122
|
-
return cached;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const promise = this.host
|
|
126
|
-
.fetch(mediaUrl, { headers, signal })
|
|
127
|
-
.then((response) => response.arrayBuffer());
|
|
128
|
-
mediaCache.set(cacheKey, promise);
|
|
129
|
-
return promise;
|
|
246
|
+
globalRequestDeduplicator.clear();
|
|
130
247
|
}
|
|
131
248
|
|
|
132
249
|
/**
|
|
@@ -109,15 +109,10 @@ describe("JitMediaEngine", () => {
|
|
|
109
109
|
|
|
110
110
|
test("returns undefined audio rendition when none available", ({
|
|
111
111
|
urlGenerator,
|
|
112
|
-
emptyManifestResponse,
|
|
113
112
|
host,
|
|
114
113
|
expect,
|
|
115
114
|
}) => {
|
|
116
|
-
const engine = new JitMediaEngine(
|
|
117
|
-
host,
|
|
118
|
-
urlGenerator,
|
|
119
|
-
emptyManifestResponse,
|
|
120
|
-
);
|
|
115
|
+
const engine = new JitMediaEngine(host, urlGenerator);
|
|
121
116
|
|
|
122
117
|
expect(engine.audioRendition).toBeUndefined();
|
|
123
118
|
});
|
|
@@ -138,15 +133,10 @@ describe("JitMediaEngine", () => {
|
|
|
138
133
|
|
|
139
134
|
test("returns undefined video rendition when none available", ({
|
|
140
135
|
urlGenerator,
|
|
141
|
-
emptyManifestResponse,
|
|
142
136
|
host,
|
|
143
137
|
expect,
|
|
144
138
|
}) => {
|
|
145
|
-
const engine = new JitMediaEngine(
|
|
146
|
-
host,
|
|
147
|
-
urlGenerator,
|
|
148
|
-
emptyManifestResponse,
|
|
149
|
-
);
|
|
139
|
+
const engine = new JitMediaEngine(host, urlGenerator);
|
|
150
140
|
|
|
151
141
|
expect(engine.videoRendition).toBeUndefined();
|
|
152
142
|
});
|
|
@@ -10,18 +10,19 @@ import type { EFMedia } from "../EFMedia.js";
|
|
|
10
10
|
import { BaseMediaEngine } from "./BaseMediaEngine";
|
|
11
11
|
|
|
12
12
|
export class JitMediaEngine extends BaseMediaEngine implements MediaEngine {
|
|
13
|
+
private urlGenerator: UrlGenerator;
|
|
14
|
+
private data: ManifestResponse = {} as ManifestResponse;
|
|
15
|
+
|
|
13
16
|
static async fetch(host: EFMedia, urlGenerator: UrlGenerator, url: string) {
|
|
14
|
-
const
|
|
15
|
-
const data =
|
|
16
|
-
|
|
17
|
+
const engine = new JitMediaEngine(host, urlGenerator);
|
|
18
|
+
const data = await engine.fetchManifest(url);
|
|
19
|
+
engine.data = data;
|
|
20
|
+
return engine;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
|
-
constructor(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
private data: ManifestResponse,
|
|
23
|
-
) {
|
|
24
|
-
super();
|
|
23
|
+
constructor(host: EFMedia, urlGenerator: UrlGenerator) {
|
|
24
|
+
super(host);
|
|
25
|
+
this.urlGenerator = urlGenerator;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
get durationMs() {
|
|
@@ -33,9 +34,13 @@ export class JitMediaEngine extends BaseMediaEngine implements MediaEngine {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
get audioRendition(): AudioRendition | undefined {
|
|
36
|
-
|
|
37
|
+
if (!this.data.audioRenditions || this.data.audioRenditions.length === 0) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
37
40
|
|
|
41
|
+
const rendition = this.data.audioRenditions[0];
|
|
38
42
|
if (!rendition) return undefined;
|
|
43
|
+
|
|
39
44
|
return {
|
|
40
45
|
id: rendition.id as RenditionId,
|
|
41
46
|
trackId: undefined,
|
|
@@ -46,9 +51,13 @@ export class JitMediaEngine extends BaseMediaEngine implements MediaEngine {
|
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
get videoRendition(): VideoRendition | undefined {
|
|
49
|
-
|
|
54
|
+
if (!this.data.videoRenditions || this.data.videoRenditions.length === 0) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
50
57
|
|
|
58
|
+
const rendition = this.data.videoRenditions[0];
|
|
51
59
|
if (!rendition) return undefined;
|
|
60
|
+
|
|
52
61
|
return {
|
|
53
62
|
id: rendition.id as RenditionId,
|
|
54
63
|
trackId: undefined,
|
|
@@ -74,12 +83,12 @@ export class JitMediaEngine extends BaseMediaEngine implements MediaEngine {
|
|
|
74
83
|
rendition.id,
|
|
75
84
|
this,
|
|
76
85
|
);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return
|
|
86
|
+
|
|
87
|
+
// Use unified fetch method
|
|
88
|
+
return this.fetchMedia(url, signal);
|
|
80
89
|
}
|
|
81
90
|
|
|
82
|
-
async
|
|
91
|
+
async fetchMediaSegment(
|
|
83
92
|
segmentId: number,
|
|
84
93
|
rendition: { id?: RenditionId; trackId: number | undefined; src: string },
|
|
85
94
|
) {
|
|
@@ -91,7 +100,7 @@ export class JitMediaEngine extends BaseMediaEngine implements MediaEngine {
|
|
|
91
100
|
rendition.id,
|
|
92
101
|
this,
|
|
93
102
|
);
|
|
94
|
-
return this.
|
|
103
|
+
return this.fetchMedia(url);
|
|
95
104
|
}
|
|
96
105
|
|
|
97
106
|
computeSegmentId(
|