rx-player 4.5.0-dev.2026012302 → 4.5.0-dev.2026031700

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 (180) hide show
  1. package/CHANGELOG.md +21 -3
  2. package/VERSION +1 -1
  3. package/dist/commonjs/__GENERATED_CODE/embedded_dash_wasm.d.ts.map +1 -1
  4. package/dist/commonjs/__GENERATED_CODE/embedded_dash_wasm.js +1 -1
  5. package/dist/commonjs/__GENERATED_CODE/embedded_worker.d.ts.map +1 -1
  6. package/dist/commonjs/__GENERATED_CODE/embedded_worker.js +1 -1
  7. package/dist/commonjs/compat/should_seek_at_zero.d.ts +12 -0
  8. package/dist/commonjs/compat/should_seek_at_zero.d.ts.map +1 -0
  9. package/dist/commonjs/compat/should_seek_at_zero.js +23 -0
  10. package/dist/commonjs/core/cmcd/cmcd_data_builder.js +2 -2
  11. package/dist/commonjs/core/entry/FreezeResolver.js +2 -2
  12. package/dist/commonjs/core/entry/content_preparer.d.ts +1 -6
  13. package/dist/commonjs/core/entry/content_preparer.d.ts.map +1 -1
  14. package/dist/commonjs/core/entry/content_preparer.js +2 -125
  15. package/dist/commonjs/core/entry/content_time_boundaries_observer.d.ts.map +1 -1
  16. package/dist/commonjs/core/entry/content_time_boundaries_observer.js +8 -0
  17. package/dist/commonjs/core/entry/core_entry.d.ts +1 -1
  18. package/dist/commonjs/core/entry/core_entry.d.ts.map +1 -1
  19. package/dist/commonjs/core/entry/core_entry.js +2 -7
  20. package/dist/commonjs/core/entry/core_text_displayer_interface.d.ts.map +1 -1
  21. package/dist/commonjs/core/entry/core_text_displayer_interface.js +3 -1
  22. package/dist/commonjs/core/entry/index.d.ts +1 -1
  23. package/dist/commonjs/core/entry/index.d.ts.map +1 -1
  24. package/dist/commonjs/core/entry/utils.d.ts +64 -0
  25. package/dist/commonjs/core/entry/utils.d.ts.map +1 -1
  26. package/dist/commonjs/core/entry/utils.js +165 -0
  27. package/dist/commonjs/core/fetchers/segment/segment_queue.d.ts +5 -6
  28. package/dist/commonjs/core/fetchers/segment/segment_queue.d.ts.map +1 -1
  29. package/dist/commonjs/core/fetchers/thumbnails/thumbnail_fetcher.d.ts.map +1 -1
  30. package/dist/commonjs/core/fetchers/thumbnails/thumbnail_fetcher.js +34 -30
  31. package/dist/commonjs/core/stream/representation/encryption_data_notifier.d.ts +70 -0
  32. package/dist/commonjs/core/stream/representation/encryption_data_notifier.d.ts.map +1 -0
  33. package/dist/commonjs/core/stream/representation/encryption_data_notifier.js +107 -0
  34. package/dist/commonjs/core/stream/representation/representation_stream.d.ts.map +1 -1
  35. package/dist/commonjs/core/stream/representation/representation_stream.js +12 -60
  36. package/dist/commonjs/core/stream/representation/utils/get_needed_segments.js +1 -1
  37. package/dist/commonjs/default_config.d.ts +5 -0
  38. package/dist/commonjs/default_config.d.ts.map +1 -1
  39. package/dist/commonjs/default_config.js +5 -0
  40. package/dist/commonjs/experimental/features/worker/index.d.ts +2 -1
  41. package/dist/commonjs/experimental/features/worker/index.d.ts.map +1 -1
  42. package/dist/commonjs/experimental/features/worker/index.js +4 -3
  43. package/dist/commonjs/main_thread/api/public_api.d.ts.map +1 -1
  44. package/dist/commonjs/main_thread/api/public_api.js +28 -4
  45. package/dist/commonjs/main_thread/init/directfile_content_initializer.d.ts.map +1 -1
  46. package/dist/commonjs/main_thread/init/directfile_content_initializer.js +23 -1
  47. package/dist/commonjs/main_thread/init/utils/initial_seek_and_play.d.ts.map +1 -1
  48. package/dist/commonjs/main_thread/init/utils/initial_seek_and_play.js +3 -1
  49. package/dist/commonjs/manifest/classes/index.d.ts +2 -1
  50. package/dist/commonjs/manifest/classes/index.d.ts.map +1 -1
  51. package/dist/commonjs/manifest/classes/representation.d.ts +1 -1
  52. package/dist/commonjs/manifest/classes/representation.d.ts.map +1 -1
  53. package/dist/commonjs/manifest/classes/representation.js +2 -2
  54. package/dist/commonjs/manifest/classes/representation_index/types.d.ts +2 -2
  55. package/dist/commonjs/parsers/texttracks/ttml/html/apply_line_height.d.ts.map +1 -1
  56. package/dist/commonjs/parsers/texttracks/ttml/html/apply_line_height.js +16 -1
  57. package/dist/commonjs/utils/base64.d.ts +15 -1
  58. package/dist/commonjs/utils/base64.d.ts.map +1 -1
  59. package/dist/commonjs/utils/base64.js +6 -0
  60. package/dist/commonjs/utils/ranges.d.ts +1 -1
  61. package/dist/commonjs/utils/ranges.js +1 -1
  62. package/dist/es2017/__GENERATED_CODE/embedded_dash_wasm.d.ts.map +1 -1
  63. package/dist/es2017/__GENERATED_CODE/embedded_dash_wasm.js +1 -1
  64. package/dist/es2017/__GENERATED_CODE/embedded_worker.d.ts.map +1 -1
  65. package/dist/es2017/__GENERATED_CODE/embedded_worker.js +1 -1
  66. package/dist/es2017/compat/should_seek_at_zero.d.ts +12 -0
  67. package/dist/es2017/compat/should_seek_at_zero.d.ts.map +1 -0
  68. package/dist/es2017/compat/should_seek_at_zero.js +20 -0
  69. package/dist/es2017/core/cmcd/cmcd_data_builder.js +2 -2
  70. package/dist/es2017/core/entry/FreezeResolver.js +1 -1
  71. package/dist/es2017/core/entry/content_preparer.d.ts +1 -6
  72. package/dist/es2017/core/entry/content_preparer.d.ts.map +1 -1
  73. package/dist/es2017/core/entry/content_preparer.js +1 -76
  74. package/dist/es2017/core/entry/content_time_boundaries_observer.d.ts.map +1 -1
  75. package/dist/es2017/core/entry/content_time_boundaries_observer.js +8 -0
  76. package/dist/es2017/core/entry/core_entry.d.ts +1 -1
  77. package/dist/es2017/core/entry/core_entry.d.ts.map +1 -1
  78. package/dist/es2017/core/entry/core_entry.js +2 -7
  79. package/dist/es2017/core/entry/core_text_displayer_interface.d.ts.map +1 -1
  80. package/dist/es2017/core/entry/core_text_displayer_interface.js +3 -1
  81. package/dist/es2017/core/entry/index.d.ts +1 -1
  82. package/dist/es2017/core/entry/index.d.ts.map +1 -1
  83. package/dist/es2017/core/entry/utils.d.ts +64 -0
  84. package/dist/es2017/core/entry/utils.d.ts.map +1 -1
  85. package/dist/es2017/core/entry/utils.js +98 -0
  86. package/dist/es2017/core/fetchers/segment/segment_queue.d.ts +5 -6
  87. package/dist/es2017/core/fetchers/segment/segment_queue.d.ts.map +1 -1
  88. package/dist/es2017/core/fetchers/thumbnails/thumbnail_fetcher.d.ts.map +1 -1
  89. package/dist/es2017/core/fetchers/thumbnails/thumbnail_fetcher.js +21 -16
  90. package/dist/es2017/core/stream/representation/encryption_data_notifier.d.ts +70 -0
  91. package/dist/es2017/core/stream/representation/encryption_data_notifier.d.ts.map +1 -0
  92. package/dist/es2017/core/stream/representation/encryption_data_notifier.js +80 -0
  93. package/dist/es2017/core/stream/representation/representation_stream.d.ts.map +1 -1
  94. package/dist/es2017/core/stream/representation/representation_stream.js +10 -38
  95. package/dist/es2017/core/stream/representation/utils/get_needed_segments.js +1 -1
  96. package/dist/es2017/default_config.d.ts +5 -0
  97. package/dist/es2017/default_config.d.ts.map +1 -1
  98. package/dist/es2017/default_config.js +5 -0
  99. package/dist/es2017/experimental/features/worker/index.d.ts +2 -1
  100. package/dist/es2017/experimental/features/worker/index.d.ts.map +1 -1
  101. package/dist/es2017/experimental/features/worker/index.js +2 -3
  102. package/dist/es2017/main_thread/api/public_api.d.ts.map +1 -1
  103. package/dist/es2017/main_thread/api/public_api.js +28 -4
  104. package/dist/es2017/main_thread/init/directfile_content_initializer.d.ts.map +1 -1
  105. package/dist/es2017/main_thread/init/directfile_content_initializer.js +23 -1
  106. package/dist/es2017/main_thread/init/utils/initial_seek_and_play.d.ts.map +1 -1
  107. package/dist/es2017/main_thread/init/utils/initial_seek_and_play.js +3 -1
  108. package/dist/es2017/manifest/classes/index.d.ts +2 -1
  109. package/dist/es2017/manifest/classes/index.d.ts.map +1 -1
  110. package/dist/es2017/manifest/classes/representation.d.ts +1 -1
  111. package/dist/es2017/manifest/classes/representation.d.ts.map +1 -1
  112. package/dist/es2017/manifest/classes/representation.js +1 -1
  113. package/dist/es2017/manifest/classes/representation_index/types.d.ts +2 -2
  114. package/dist/es2017/parsers/texttracks/ttml/html/apply_line_height.d.ts.map +1 -1
  115. package/dist/es2017/parsers/texttracks/ttml/html/apply_line_height.js +16 -1
  116. package/dist/es2017/utils/base64.d.ts +15 -1
  117. package/dist/es2017/utils/base64.d.ts.map +1 -1
  118. package/dist/es2017/utils/base64.js +6 -0
  119. package/dist/es2017/utils/ranges.d.ts +1 -1
  120. package/dist/es2017/utils/ranges.js +1 -1
  121. package/dist/mpd-parser.wasm +0 -0
  122. package/dist/worker.js +8 -8
  123. package/package.json +26 -23
  124. package/src/__GENERATED_CODE/embedded_dash_wasm.ts +1 -1
  125. package/src/__GENERATED_CODE/embedded_worker.ts +1 -1
  126. package/src/compat/should_seek_at_zero.ts +23 -0
  127. package/src/core/adaptive/__tests__/adaptive_representation_selector.test.ts +734 -0
  128. package/src/core/adaptive/__tests__/guess_based_chooser.test.ts +793 -0
  129. package/src/core/adaptive/__tests__/network_analyzer.test.ts +514 -0
  130. package/src/core/adaptive/utils/__tests__/bandwidth_estimator.test.ts +117 -0
  131. package/src/core/adaptive/utils/__tests__/last_estimate_storage.test.ts +76 -0
  132. package/src/core/adaptive/utils/__tests__/pending_requests_store.test.ts +288 -0
  133. package/src/core/adaptive/utils/__tests__/representation_score_calculator.test.ts +249 -0
  134. package/src/core/cmcd/__tests__/cmcd_data_builder.test.ts +464 -0
  135. package/src/core/cmcd/cmcd_data_builder.ts +2 -2
  136. package/src/core/entry/FreezeResolver.ts +1 -1
  137. package/src/core/entry/__tests__/FreezeResolver.test.ts +542 -0
  138. package/src/core/entry/__tests__/content_time_boundaries_observer.test.ts +867 -0
  139. package/src/core/entry/__tests__/core_text_displayer_interface.test.ts +205 -0
  140. package/src/core/entry/__tests__/get_buffered_data_per_media_buffer.test.ts +95 -0
  141. package/src/core/entry/__tests__/track_choice_setter.test.ts +288 -0
  142. package/src/core/entry/__tests__/utils.test.ts +309 -0
  143. package/src/core/entry/content_preparer.ts +7 -118
  144. package/src/core/entry/content_time_boundaries_observer.ts +10 -0
  145. package/src/core/entry/core_entry.ts +7 -14
  146. package/src/core/entry/core_text_displayer_interface.ts +3 -1
  147. package/src/core/entry/index.ts +1 -1
  148. package/src/core/entry/utils.ts +151 -0
  149. package/src/core/fetchers/__tests__/cdn_prioritizer.test.ts +166 -0
  150. package/src/core/fetchers/manifest/__tests__/manifest_fetcher.test.ts +803 -0
  151. package/src/core/fetchers/segment/__tests__/segment_fetcher.test.ts +872 -0
  152. package/src/core/fetchers/segment/__tests__/segment_queue.test.ts +557 -0
  153. package/src/core/fetchers/segment/segment_queue.ts +5 -6
  154. package/src/core/fetchers/thumbnails/__tests__/thumbnail_fetcher.test.ts +435 -0
  155. package/src/core/fetchers/thumbnails/thumbnail_fetcher.ts +28 -20
  156. package/src/core/fetchers/utils/__tests__/schedule_request.test.ts +331 -0
  157. package/src/core/stream/adaptation/__tests__/get_representations_switch_strategy.test.ts +593 -0
  158. package/src/core/stream/period/utils/__tests__/get_adaptation_switch_strategy.test.ts +646 -0
  159. package/src/core/stream/representation/__tests__/encryption_data_notifier.test.ts +531 -0
  160. package/src/core/stream/representation/encryption_data_notifier.ts +122 -0
  161. package/src/core/stream/representation/representation_stream.ts +11 -49
  162. package/src/core/stream/representation/utils/__tests__/append_segment_to_buffer.test.ts +321 -0
  163. package/src/core/stream/representation/utils/__tests__/check_for_discontinuity.test.ts +513 -0
  164. package/src/core/stream/representation/utils/__tests__/get_segment_priority.test.ts +132 -0
  165. package/src/core/stream/representation/utils/__tests__/push_init_segment.test.ts +209 -0
  166. package/src/core/stream/representation/utils/__tests__/push_media_segment.test.ts +343 -0
  167. package/src/core/stream/representation/utils/get_needed_segments.ts +1 -1
  168. package/src/default_config.ts +6 -0
  169. package/src/experimental/features/worker/index.ts +2 -4
  170. package/src/main_thread/api/public_api.ts +33 -4
  171. package/src/main_thread/init/directfile_content_initializer.ts +26 -1
  172. package/src/main_thread/init/utils/initial_seek_and_play.ts +5 -1
  173. package/src/manifest/classes/index.ts +2 -0
  174. package/src/manifest/classes/representation.ts +2 -1
  175. package/src/manifest/classes/representation_index/types.ts +2 -2
  176. package/src/mse/__tests__/main_media_source_interface.test.ts +846 -0
  177. package/src/parsers/texttracks/ttml/html/apply_line_height.ts +16 -1
  178. package/src/utils/__tests__/base64.test.ts +159 -191
  179. package/src/utils/base64.ts +30 -1
  180. package/src/utils/ranges.ts +1 -1
@@ -0,0 +1,435 @@
1
+ import type { Mock } from "vitest";
2
+ import { describe, it, expect, vi, afterEach } from "vitest";
3
+ import type { ICdnMetadata } from "../../../../parsers/manifest";
4
+ import type {
5
+ IRequestedData,
6
+ IThumbnailLoader,
7
+ IThumbnailParser,
8
+ } from "../../../../transports";
9
+ import type { CancellationSignal } from "../../../../utils/task_canceller";
10
+ import TaskCanceller, { CancellationError } from "../../../../utils/task_canceller";
11
+ import type CdnPrioritizer from "../../cdn_prioritizer";
12
+ import type { IBackoffSettings } from "../../utils/schedule_request";
13
+ import createThumbnailFetcher from "../thumbnail_fetcher";
14
+
15
+ /* eslint-disable @typescript-eslint/no-explicit-any */
16
+ /* eslint-disable @typescript-eslint/no-unsafe-argument */
17
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
18
+
19
+ const {
20
+ mockConfig,
21
+ mockLog,
22
+ mockScheduleRequestWithCdns,
23
+ mockFormatError,
24
+ mockErrorSelector,
25
+ } = vi.hoisted(() => {
26
+ return {
27
+ mockConfig: {
28
+ getCurrent: vi.fn(() => ({
29
+ DEFAULT_MAX_THUMBNAIL_REQUESTS_RETRY_ON_ERROR: 3,
30
+ DEFAULT_THUMBNAIL_REQUEST_TIMEOUT: 10000,
31
+ DEFAULT_THUMBNAIL_CONNECTION_TIMEOUT: 5000,
32
+ INITIAL_BACKOFF_DELAY_BASE: { REGULAR: 200 },
33
+ MAX_BACKOFF_DELAY_BASE: { REGULAR: 3000 },
34
+ })),
35
+ },
36
+ mockLog: {
37
+ debug: vi.fn(),
38
+ warn: vi.fn(),
39
+ error: vi.fn(),
40
+ },
41
+ mockScheduleRequestWithCdns: vi.fn(
42
+ (
43
+ cdns: ICdnMetadata[] | null,
44
+ _cdnPrioritizer: CdnPrioritizer | null,
45
+ performRequest: (
46
+ cdn: ICdnMetadata | null,
47
+ cancellationSignal: CancellationSignal,
48
+ ) => Promise<IRequestedData<ArrayBuffer>>,
49
+ _options: IBackoffSettings,
50
+ globalCancellationSignal: CancellationSignal,
51
+ ): Promise<IRequestedData<ArrayBuffer>> => {
52
+ if (globalCancellationSignal.isCancelled()) {
53
+ return Promise.reject(globalCancellationSignal.cancellationError);
54
+ }
55
+ return performRequest(cdns?.[0] ?? null, new TaskCanceller("test task").signal);
56
+ },
57
+ ),
58
+ mockFormatError: vi.fn((err: unknown): any => err),
59
+ mockErrorSelector: vi.fn((err: unknown): any => err),
60
+ };
61
+ });
62
+
63
+ vi.mock("../../../../config", () => ({ default: mockConfig }));
64
+ vi.mock("../../../../log", () => ({ default: mockLog }));
65
+ vi.mock("../../utils/schedule_request", () => ({
66
+ scheduleRequestWithCdns: mockScheduleRequestWithCdns,
67
+ }));
68
+ vi.mock("../../../../errors", () => ({ formatError: mockFormatError }));
69
+ vi.mock("../../utils/error_selector", () => ({ default: mockErrorSelector }));
70
+
71
+ describe("createThumbnailFetcher", () => {
72
+ afterEach(() => {
73
+ vi.resetAllMocks();
74
+ });
75
+
76
+ it("returns a function (fetchThumbnail)", () => {
77
+ const fetcher = createThumbnailFetcher(makePipeline(), null);
78
+ expect(typeof fetcher).toBe("function");
79
+ });
80
+
81
+ describe("successful fetch", () => {
82
+ it("calls loadThumbnail and parseThumbnail, returns parsed result", async () => {
83
+ const pipeline = makePipeline(
84
+ () =>
85
+ Promise.resolve({
86
+ responseData: new Uint8Array([0, 1, 2]).buffer,
87
+ size: 3,
88
+ requestDuration: 1000,
89
+ }),
90
+ (d) => ({ data: d, mimeType: "image/png", thumbnails: [] }),
91
+ );
92
+ pipeline.loadThumbnail.mockResolvedValue({
93
+ responseData: new Uint8Array([7, 7, 7]).buffer,
94
+ size: 3,
95
+ requestDuration: 500,
96
+ });
97
+
98
+ const fetcher = createThumbnailFetcher(pipeline, null);
99
+ const canceller = new TaskCanceller("test");
100
+ const ctx = makeThumbnailContext();
101
+
102
+ const result = await fetcher(ctx, canceller.signal);
103
+ expect(result).toEqual({
104
+ data: new Uint8Array([7, 7, 7]).buffer,
105
+ mimeType: "image/png",
106
+ thumbnails: [],
107
+ });
108
+ expect(pipeline.parseThumbnail).toHaveBeenCalledTimes(1);
109
+ });
110
+ });
111
+
112
+ describe("request deduplication", () => {
113
+ it("shares the same promise when the same thumbnail is requested twice concurrently", async () => {
114
+ const pipeline = makePipeline();
115
+ let resolveRequest!: (v: IRequestedData<ArrayBuffer>) => void;
116
+ mockScheduleRequestWithCdns.mockReturnValue(
117
+ new Promise((res) => {
118
+ resolveRequest = res;
119
+ }),
120
+ );
121
+ pipeline.parseThumbnail.mockReturnValue({
122
+ mimeType: "image/gif",
123
+ data: new Uint8Array([4, 2, 2]).buffer,
124
+ thumbnails: [],
125
+ });
126
+
127
+ const fetcher = createThumbnailFetcher(pipeline, null);
128
+ const canceller1 = new TaskCanceller("test1");
129
+ const canceller2 = new TaskCanceller("test2");
130
+ const ctx = makeThumbnailContext();
131
+
132
+ const p1 = fetcher(ctx, canceller1.signal);
133
+ const p2 = fetcher(ctx, canceller2.signal);
134
+
135
+ // Only one network call should have been made
136
+ expect(mockScheduleRequestWithCdns).toHaveBeenCalledTimes(1);
137
+
138
+ resolveRequest({
139
+ responseData: new Uint8Array([5, 6, 7]).buffer,
140
+ size: 3,
141
+ requestDuration: 900,
142
+ });
143
+
144
+ const [r1, r2] = await Promise.all([p1, p2]);
145
+ expect(r1).toEqual({
146
+ mimeType: "image/gif",
147
+ data: new Uint8Array([4, 2, 2]).buffer,
148
+ thumbnails: [],
149
+ });
150
+ expect(r2).toEqual({
151
+ mimeType: "image/gif",
152
+ data: new Uint8Array([4, 2, 2]).buffer,
153
+ thumbnails: [],
154
+ });
155
+ });
156
+
157
+ it("makes a new request when thumbnails differ", async () => {
158
+ const pipeline = makePipeline();
159
+ mockScheduleRequestWithCdns.mockResolvedValue({
160
+ responseData: new Uint8Array([7, 8, 7]).buffer,
161
+ size: 3,
162
+ requestDuration: 500,
163
+ });
164
+
165
+ const fetcher = createThumbnailFetcher(pipeline, null);
166
+ const canceller1 = new TaskCanceller("test1");
167
+ const canceller2 = new TaskCanceller("test2");
168
+
169
+ // Fetch sequentially so there is no overlap
170
+ await fetcher(makeThumbnailContext({ segmentId: "seg-1" }), canceller1.signal);
171
+ await fetcher(makeThumbnailContext({ segmentId: "seg-2" }), canceller2.signal);
172
+
173
+ expect(mockScheduleRequestWithCdns).toHaveBeenCalledTimes(2);
174
+ });
175
+ });
176
+
177
+ describe("cancellation", () => {
178
+ it("rejects with CancellationError when the signal is already cancelled on entry", async () => {
179
+ const pipeline = makePipeline();
180
+ let resolveRequest!: (v: IRequestedData<ArrayBuffer>) => void;
181
+ mockScheduleRequestWithCdns.mockReturnValue(
182
+ new Promise((res) => {
183
+ resolveRequest = res;
184
+ }),
185
+ );
186
+
187
+ const fetcher = createThumbnailFetcher(pipeline, null);
188
+ const canceller = new TaskCanceller("test");
189
+ const ctx = makeThumbnailContext();
190
+
191
+ const fetchPromise = fetcher(ctx, canceller.signal);
192
+ canceller.cancel("cancelled");
193
+ resolveRequest({
194
+ responseData: new Uint8Array([9, 7, 7]).buffer,
195
+ size: 3,
196
+ requestDuration: 500,
197
+ });
198
+
199
+ await expect(fetchPromise).rejects.toBeInstanceOf(CancellationError);
200
+ });
201
+
202
+ it("cancels the underlying request when all callers cancel", async () => {
203
+ const pipeline = makePipeline();
204
+ let rejectRequest!: (e: unknown) => void;
205
+ mockScheduleRequestWithCdns.mockReturnValue(
206
+ new Promise((_res, rej) => {
207
+ rejectRequest = rej;
208
+ }),
209
+ );
210
+
211
+ const fetcher = createThumbnailFetcher(pipeline, null);
212
+ const canceller1 = new TaskCanceller("test1");
213
+ const canceller2 = new TaskCanceller("test2");
214
+ const ctx = makeThumbnailContext();
215
+
216
+ const p1 = fetcher(ctx, canceller1.signal);
217
+ const p2 = fetcher(ctx, canceller2.signal);
218
+
219
+ // Only one network request made
220
+ expect(mockScheduleRequestWithCdns).toHaveBeenCalledTimes(1);
221
+
222
+ canceller1.cancel("cancel1");
223
+ // Request should still be live because p2 is still pending
224
+ canceller2.cancel("cancel2");
225
+
226
+ // Trigger the underlying rejection to settle the promises
227
+ const cancelErr = new CancellationError("test task", "aborted");
228
+ rejectRequest(cancelErr);
229
+
230
+ await expect(p1).rejects.toBeInstanceOf(CancellationError);
231
+ await expect(p2).rejects.toBeInstanceOf(CancellationError);
232
+ });
233
+
234
+ it("keeps the request alive when only one of two callers cancels", async () => {
235
+ const pipeline = makePipeline();
236
+ let resolveRequest!: (v: IRequestedData<ArrayBuffer>) => void;
237
+ mockScheduleRequestWithCdns.mockReturnValue(
238
+ new Promise((res) => {
239
+ resolveRequest = res;
240
+ }),
241
+ );
242
+
243
+ const fetcher = createThumbnailFetcher(pipeline, null);
244
+ const canceller1 = new TaskCanceller("test1");
245
+ const canceller2 = new TaskCanceller("test2");
246
+ const ctx = makeThumbnailContext();
247
+
248
+ const p1 = fetcher(ctx, canceller1.signal);
249
+ const p2 = fetcher(ctx, canceller2.signal);
250
+
251
+ canceller1.cancel("only first cancels");
252
+ resolveRequest({
253
+ responseData: new Uint8Array([4, 7, 7]).buffer,
254
+ size: 3,
255
+ requestDuration: 500,
256
+ });
257
+
258
+ // p2 should still resolve
259
+ const result = await p2;
260
+ expect(result).toEqual({
261
+ data: new Uint8Array([4, 7, 7]).buffer,
262
+
263
+ // Default mocked characteristics
264
+ mimeType: "image/png",
265
+ thumbnails: [
266
+ {
267
+ height: 720,
268
+ width: 1280,
269
+ offsetX: 0,
270
+ offsetY: 0,
271
+ start: 0,
272
+ end: Number.MAX_SAFE_INTEGER,
273
+ },
274
+ ],
275
+ });
276
+
277
+ // p1 should reject
278
+ await expect(p1).rejects.toBeInstanceOf(CancellationError);
279
+ });
280
+ });
281
+
282
+ describe("error handling", () => {
283
+ it("throws a formatted error when the network request fails with a non-cancellation error", async () => {
284
+ const networkError = new Error("network failure");
285
+ const formattedError = new Error("formatted request error");
286
+ mockScheduleRequestWithCdns.mockRejectedValue(networkError);
287
+ mockErrorSelector.mockReturnValue(formattedError);
288
+
289
+ const pipeline = makePipeline();
290
+ const fetcher = createThumbnailFetcher(pipeline, null);
291
+ const canceller = new TaskCanceller("test");
292
+
293
+ await expect(fetcher(makeThumbnailContext(), canceller.signal)).rejects.toBe(
294
+ formattedError,
295
+ );
296
+ expect(mockErrorSelector).toHaveBeenCalledWith(networkError);
297
+ });
298
+
299
+ it("throws a formatted error when parseThumbnail throws", async () => {
300
+ const parseError = new Error("parse boom");
301
+ const formattedParseError = new Error("formatted parse error");
302
+ const pipeline = makePipeline();
303
+ pipeline.loadThumbnail.mockResolvedValue({
304
+ responseData: new Uint8Array([0, 1, 2]).buffer,
305
+ size: 3,
306
+ requestDuration: 1100,
307
+ });
308
+ pipeline.parseThumbnail.mockImplementation(() => {
309
+ throw parseError;
310
+ });
311
+ mockFormatError.mockReturnValue(formattedParseError);
312
+
313
+ const fetcher = createThumbnailFetcher(pipeline, null);
314
+ const canceller = new TaskCanceller("test");
315
+
316
+ await expect(fetcher(makeThumbnailContext(), canceller.signal)).rejects.toBe(
317
+ formattedParseError,
318
+ );
319
+ expect(mockFormatError).toHaveBeenCalledWith(
320
+ parseError,
321
+ expect.objectContaining({
322
+ defaultCode: "PIPELINE_PARSE_ERROR",
323
+ }),
324
+ );
325
+ });
326
+
327
+ it("propagates CancellationError from scheduleRequestWithCdns without wrapping", async () => {
328
+ const cancelErr = new CancellationError("test task", "aborted");
329
+ mockScheduleRequestWithCdns.mockRejectedValue(cancelErr);
330
+
331
+ const pipeline = makePipeline();
332
+ const fetcher = createThumbnailFetcher(pipeline, null);
333
+ const canceller = new TaskCanceller("test");
334
+
335
+ await expect(fetcher(makeThumbnailContext(), canceller.signal)).rejects.toBe(
336
+ cancelErr,
337
+ );
338
+ // errorSelector should not be called for CancellationError
339
+ expect(mockErrorSelector).not.toHaveBeenCalled();
340
+ });
341
+ });
342
+
343
+ describe("retry logging", () => {
344
+ it("calls onRetry and logs a warning when the request retries", async () => {
345
+ const retryError = new Error("retry me");
346
+ const formattedRetryError = new Error("formatted retry");
347
+ mockErrorSelector.mockReturnValue(formattedRetryError);
348
+
349
+ mockScheduleRequestWithCdns.mockImplementation(
350
+ async (
351
+ _cdns: ICdnMetadata[] | null,
352
+ _cdnPrioritizer: CdnPrioritizer | null,
353
+ _performRequest: (
354
+ cdn: ICdnMetadata | null,
355
+ cancellationSignal: CancellationSignal,
356
+ ) => Promise<IRequestedData<ArrayBuffer>>,
357
+ options: IBackoffSettings,
358
+ _globalCancellationSignal: CancellationSignal,
359
+ ) => {
360
+ // Simulate the retry callback being invoked before eventual failure
361
+ options.onRetry?.(retryError);
362
+ throw retryError;
363
+ },
364
+ );
365
+ mockErrorSelector.mockReturnValue(formattedRetryError);
366
+
367
+ const pipeline = makePipeline();
368
+ const fetcher = createThumbnailFetcher(pipeline, null);
369
+ const canceller = new TaskCanceller("test");
370
+
371
+ await expect(
372
+ fetcher(makeThumbnailContext(), canceller.signal),
373
+ ).rejects.toBeDefined();
374
+ expect(mockLog.warn).toHaveBeenCalledWith(
375
+ "Thumbnails",
376
+ "Thumbnail request retry ",
377
+ expect.any(Object),
378
+ formattedRetryError,
379
+ );
380
+ });
381
+ });
382
+ });
383
+
384
+ /**
385
+ * Construct `pipeline` argument for a ThumbnailFetcher
386
+ */
387
+ function makePipeline(
388
+ loadThumbnailImpl?: IThumbnailLoader,
389
+ parseThumbnailImpl?: IThumbnailParser,
390
+ ): {
391
+ loadThumbnail: Mock<IThumbnailLoader>;
392
+ parseThumbnail: Mock<IThumbnailParser>;
393
+ } {
394
+ return {
395
+ loadThumbnail: vi.fn(
396
+ loadThumbnailImpl ??
397
+ (() =>
398
+ Promise.resolve({
399
+ responseData: new Uint8Array([0, 1, 2]).buffer,
400
+ requestDuration: undefined,
401
+ url: undefined,
402
+ sendingTime: undefined,
403
+ receivedTime: undefined,
404
+ size: undefined,
405
+ })),
406
+ ),
407
+ parseThumbnail: vi.fn(
408
+ parseThumbnailImpl ??
409
+ ((data: ArrayBuffer) => ({
410
+ data,
411
+ mimeType: "image/png",
412
+ thumbnails: [
413
+ {
414
+ height: 720,
415
+ width: 1280,
416
+ offsetX: 0,
417
+ offsetY: 0,
418
+ start: 0,
419
+ end: Number.MAX_SAFE_INTEGER,
420
+ },
421
+ ],
422
+ })),
423
+ ),
424
+ };
425
+ }
426
+
427
+ function makeThumbnailContext(
428
+ overrides: { segmentId?: string; trackId?: string; periodId?: string } = {},
429
+ ): any {
430
+ return {
431
+ segment: { id: overrides.segmentId ?? "seg-1", time: 0 },
432
+ track: { id: overrides.trackId ?? "track-1", cdnMetadata: [] },
433
+ period: { id: overrides.periodId ?? "period-1" },
434
+ };
435
+ }
@@ -5,6 +5,7 @@ import type { ISegment, IThumbnailTrack } from "../../../manifest";
5
5
  import type { ICdnMetadata } from "../../../parsers/manifest";
6
6
  import type { IPeriod } from "../../../public_types";
7
7
  import type {
8
+ IRequestedData,
8
9
  IThumbnailLoader,
9
10
  IThumbnailLoaderOptions,
10
11
  IThumbnailPipeline,
@@ -46,7 +47,7 @@ export default function createThumbnailFetcher(
46
47
 
47
48
  interface IPendingThumbnailRequestInfo {
48
49
  /** Promise behind the thumbnail request. */
49
- promise: Promise<IThumbnailResponse>;
50
+ promise: Promise<IRequestedData<ArrayBuffer>>;
50
51
  /**
51
52
  * Multiple caller might share the same request promise.
52
53
  * This reference counter keeps track of the number of caller that are
@@ -86,6 +87,8 @@ export default function createThumbnailFetcher(
86
87
  ): Promise<IThumbnailResponse> {
87
88
  cancellationSignal.register(onCancellation);
88
89
 
90
+ const { segment: thumbnail, track: thumbnailTrack } = thumbnailContext;
91
+
89
92
  let currRequestInfo: IPendingThumbnailRequestInfo;
90
93
 
91
94
  // First check if we're not requesting again the last thumbnail
@@ -113,10 +116,12 @@ export default function createThumbnailFetcher(
113
116
  throw err;
114
117
  }
115
118
  cancellationSignal.deregister(onCancellation);
116
- return response;
117
- }
118
119
 
119
- const { segment: thumbnail, track: thumbnailTrack } = thumbnailContext;
120
+ if (cancellationSignal.isCancelled()) {
121
+ throw cancellationSignal.cancellationError;
122
+ }
123
+ return parseThumbnailStep(response);
124
+ }
120
125
 
121
126
  // NOTE: For now, as multiple `fetchThumbnail` calls might rely on the
122
127
  // same request, it is difficult to let different request options
@@ -144,7 +149,7 @@ export default function createThumbnailFetcher(
144
149
  * as several calls might share the same request.
145
150
  */
146
151
  const requestCanceller = new TaskCanceller("Thumbnail request");
147
- const fetchPromise = doFetch();
152
+ const fetchPromise = fetchThumbnailStep();
148
153
  currRequestInfo = {
149
154
  thumbnailContext,
150
155
  promise: fetchPromise,
@@ -157,37 +162,37 @@ export default function createThumbnailFetcher(
157
162
  pendingRequestsInfo.splice(currRequestIdx, 1);
158
163
  }
159
164
  };
165
+ let fetchResult;
160
166
  try {
161
- const fetchResult = await fetchPromise;
162
- clearRequestInfo();
163
- return fetchResult;
167
+ fetchResult = await fetchPromise;
164
168
  } catch (err) {
165
169
  clearRequestInfo();
170
+ cancellationSignal.deregister(onCancellation);
166
171
  throw err;
167
172
  }
168
173
 
169
- async function doFetch() {
174
+ clearRequestInfo();
175
+ cancellationSignal.deregister(onCancellation);
176
+ if (cancellationSignal.isCancelled()) {
177
+ throw cancellationSignal.cancellationError;
178
+ }
179
+ return parseThumbnailStep(fetchResult);
180
+
181
+ async function fetchThumbnailStep(): Promise<IRequestedData<ArrayBuffer>> {
170
182
  log.debug("Thumbnails", "Beginning thumbnail request", { time: thumbnail.time });
171
- let res;
172
183
  try {
173
- res = await scheduleRequestWithCdns(
184
+ const res = await scheduleRequestWithCdns(
174
185
  thumbnailTrack.cdnMetadata,
175
186
  cdnPrioritizer,
176
187
  callLoaderWithUrl,
177
188
  objectAssign({ onRetry }, requestOptions),
178
189
  requestCanceller.signal,
179
190
  );
180
-
181
- if (cancellationSignal.isCancelled()) {
182
- return Promise.reject(cancellationSignal.cancellationError);
183
- }
184
-
185
191
  log.debug("Thumbnails", "Thumbnail request ended with success", {
186
192
  time: thumbnail.time,
187
193
  });
188
- cancellationSignal.deregister(onCancellation);
194
+ return res;
189
195
  } catch (err) {
190
- cancellationSignal.deregister(onCancellation);
191
196
  if (err instanceof CancellationError) {
192
197
  log.debug("Thumbnails", "Thumbnail request aborted", { time: thumbnail.time });
193
198
  throw err;
@@ -195,13 +200,16 @@ export default function createThumbnailFetcher(
195
200
  log.debug("Thumbnails", "Thumbnail request failed", { time: thumbnail.time });
196
201
  throw errorSelector(err);
197
202
  }
203
+ }
198
204
 
205
+ function parseThumbnailStep(
206
+ rawResponse: IRequestedData<ArrayBuffer>,
207
+ ): IThumbnailResponse {
199
208
  try {
200
- const parsed = pipeline.parseThumbnail(res.responseData, {
209
+ return pipeline.parseThumbnail(rawResponse.responseData, {
201
210
  thumbnail,
202
211
  thumbnailTrack,
203
212
  });
204
- return parsed;
205
213
  } catch (error) {
206
214
  throw formatError(error, {
207
215
  defaultCode: "PIPELINE_PARSE_ERROR",