@xhub-short/adapters 0.1.0-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/README.md +1 -0
- package/dist/index.d.ts +1954 -0
- package/dist/index.js +3238 -0
- package/package.json +43 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3238 @@
|
|
|
1
|
+
// src/data/mock.ts
|
|
2
|
+
var MOCK_VIDEOS = [
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
4
|
+
// HLS Videos (for testing hls.js integration)
|
|
5
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
6
|
+
{
|
|
7
|
+
id: "video-1",
|
|
8
|
+
source: {
|
|
9
|
+
url: "https://peertube.teknix.services/static/streaming-playlists/hls/dd8de71d-0b75-4677-a1a2-6f60e673bee4/465faffa-6d08-4f34-ae40-691cc904ce7b-master.m3u8",
|
|
10
|
+
type: "hls"
|
|
11
|
+
},
|
|
12
|
+
poster: "https://images.unsplash.com/photo-1536240478700-b869070f9279?w=400&h=600&fit=crop",
|
|
13
|
+
duration: 634,
|
|
14
|
+
title: "HLS Test Stream - Big Buck Bunny",
|
|
15
|
+
author: {
|
|
16
|
+
id: "author-1",
|
|
17
|
+
name: "Mux Test Streams",
|
|
18
|
+
avatar: "https://i.pravatar.cc/150?u=author1",
|
|
19
|
+
isVerified: true
|
|
20
|
+
},
|
|
21
|
+
stats: {
|
|
22
|
+
views: 1234567,
|
|
23
|
+
likes: 98765,
|
|
24
|
+
comments: 4321,
|
|
25
|
+
shares: 876
|
|
26
|
+
},
|
|
27
|
+
isLiked: false,
|
|
28
|
+
isFollowing: false,
|
|
29
|
+
createdAt: "2024-01-15T10:30:00Z",
|
|
30
|
+
hashtags: ["hls", "streaming", "test"]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "video-2",
|
|
34
|
+
source: {
|
|
35
|
+
url: "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8",
|
|
36
|
+
type: "hls"
|
|
37
|
+
},
|
|
38
|
+
poster: "https://images.unsplash.com/photo-1478737270239-2f02b77fc618?w=400&h=600&fit=crop",
|
|
39
|
+
duration: 120,
|
|
40
|
+
title: "Apple HLS Sample - Advanced Stream",
|
|
41
|
+
author: {
|
|
42
|
+
id: "author-2",
|
|
43
|
+
name: "Apple Developer",
|
|
44
|
+
avatar: "https://i.pravatar.cc/150?u=author2",
|
|
45
|
+
isVerified: true
|
|
46
|
+
},
|
|
47
|
+
stats: {
|
|
48
|
+
views: 876543,
|
|
49
|
+
likes: 54321,
|
|
50
|
+
comments: 2109,
|
|
51
|
+
shares: 543
|
|
52
|
+
},
|
|
53
|
+
isLiked: true,
|
|
54
|
+
isFollowing: true,
|
|
55
|
+
createdAt: "2024-01-14T15:45:00Z",
|
|
56
|
+
hashtags: ["apple", "hls", "fmp4"]
|
|
57
|
+
},
|
|
58
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
59
|
+
// MP4 Videos
|
|
60
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
61
|
+
{
|
|
62
|
+
id: "video-3",
|
|
63
|
+
source: {
|
|
64
|
+
url: "https://peertube.teknix.services/static/streaming-playlists/hls/ea58b245-b3bf-4958-b2f0-b31f8113d142/83f1bb91-e76a-4dcd-8034-2ec37cb70ead-master.m3u8",
|
|
65
|
+
type: "hls"
|
|
66
|
+
},
|
|
67
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerBlazes.jpg",
|
|
68
|
+
duration: 15,
|
|
69
|
+
title: "For Bigger Blazes (MP4)",
|
|
70
|
+
author: {
|
|
71
|
+
id: "author-3",
|
|
72
|
+
name: "Google Chrome",
|
|
73
|
+
avatar: "https://i.pravatar.cc/150?u=author3",
|
|
74
|
+
isVerified: true
|
|
75
|
+
},
|
|
76
|
+
stats: {
|
|
77
|
+
views: 543210,
|
|
78
|
+
likes: 32109,
|
|
79
|
+
comments: 1543,
|
|
80
|
+
shares: 321
|
|
81
|
+
},
|
|
82
|
+
isLiked: false,
|
|
83
|
+
isFollowing: false,
|
|
84
|
+
createdAt: "2024-01-13T09:20:00Z",
|
|
85
|
+
hashtags: ["chrome", "blazes"]
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "video-4",
|
|
89
|
+
source: {
|
|
90
|
+
url: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4",
|
|
91
|
+
type: "mp4"
|
|
92
|
+
},
|
|
93
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerEscapes.jpg",
|
|
94
|
+
duration: 15,
|
|
95
|
+
title: "For Bigger Escapes",
|
|
96
|
+
author: {
|
|
97
|
+
id: "author-3",
|
|
98
|
+
name: "Google Chrome",
|
|
99
|
+
avatar: "https://i.pravatar.cc/150?u=author3",
|
|
100
|
+
isVerified: true
|
|
101
|
+
},
|
|
102
|
+
stats: {
|
|
103
|
+
views: 432109,
|
|
104
|
+
likes: 21098,
|
|
105
|
+
comments: 987,
|
|
106
|
+
shares: 210
|
|
107
|
+
},
|
|
108
|
+
isLiked: false,
|
|
109
|
+
isFollowing: false,
|
|
110
|
+
createdAt: "2024-01-12T14:10:00Z",
|
|
111
|
+
hashtags: ["chrome", "escapes"]
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "video-5",
|
|
115
|
+
source: {
|
|
116
|
+
url: "https://peertube.teknix.services/static/streaming-playlists/hls/003a41a3-25c1-419b-9548-7a1597adc85f/9cc93564-c9c9-4107-ae12-cd22d0db8046-master.m3u8",
|
|
117
|
+
type: "hls"
|
|
118
|
+
},
|
|
119
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerFun.jpg",
|
|
120
|
+
duration: 60,
|
|
121
|
+
title: "For Bigger Fun",
|
|
122
|
+
author: {
|
|
123
|
+
id: "author-3",
|
|
124
|
+
name: "Google Chrome",
|
|
125
|
+
avatar: "https://i.pravatar.cc/150?u=author3",
|
|
126
|
+
isVerified: true
|
|
127
|
+
},
|
|
128
|
+
stats: {
|
|
129
|
+
views: 321098,
|
|
130
|
+
likes: 10987,
|
|
131
|
+
comments: 654,
|
|
132
|
+
shares: 109
|
|
133
|
+
},
|
|
134
|
+
isLiked: true,
|
|
135
|
+
isFollowing: false,
|
|
136
|
+
createdAt: "2024-01-11T11:05:00Z",
|
|
137
|
+
hashtags: ["chrome", "fun"]
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: "video-6",
|
|
141
|
+
source: {
|
|
142
|
+
url: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4",
|
|
143
|
+
type: "mp4"
|
|
144
|
+
},
|
|
145
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerJoyrides.jpg",
|
|
146
|
+
duration: 15,
|
|
147
|
+
title: "For Bigger Joyrides",
|
|
148
|
+
author: {
|
|
149
|
+
id: "author-4",
|
|
150
|
+
name: "GoPro Adventures",
|
|
151
|
+
avatar: "https://i.pravatar.cc/150?u=author4",
|
|
152
|
+
isVerified: true
|
|
153
|
+
},
|
|
154
|
+
stats: {
|
|
155
|
+
views: 892345,
|
|
156
|
+
likes: 67890,
|
|
157
|
+
comments: 3456,
|
|
158
|
+
shares: 789
|
|
159
|
+
},
|
|
160
|
+
isLiked: false,
|
|
161
|
+
isFollowing: true,
|
|
162
|
+
createdAt: "2024-01-10T16:45:00Z",
|
|
163
|
+
hashtags: ["adventure", "joyride", "travel"]
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: "video-7",
|
|
167
|
+
source: {
|
|
168
|
+
url: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4",
|
|
169
|
+
type: "mp4"
|
|
170
|
+
},
|
|
171
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerMeltdowns.jpg",
|
|
172
|
+
duration: 15,
|
|
173
|
+
title: "For Bigger Meltdowns",
|
|
174
|
+
author: {
|
|
175
|
+
id: "author-5",
|
|
176
|
+
name: "Ice Cream Factory",
|
|
177
|
+
avatar: "https://i.pravatar.cc/150?u=author5",
|
|
178
|
+
isVerified: false
|
|
179
|
+
},
|
|
180
|
+
stats: {
|
|
181
|
+
views: 234567,
|
|
182
|
+
likes: 12345,
|
|
183
|
+
comments: 567,
|
|
184
|
+
shares: 123
|
|
185
|
+
},
|
|
186
|
+
isLiked: true,
|
|
187
|
+
isFollowing: false,
|
|
188
|
+
createdAt: "2024-01-09T12:30:00Z",
|
|
189
|
+
hashtags: ["satisfying", "icecream", "asmr"]
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
id: "video-8",
|
|
193
|
+
source: {
|
|
194
|
+
url: "https://storage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4",
|
|
195
|
+
type: "mp4"
|
|
196
|
+
},
|
|
197
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/Sintel.jpg",
|
|
198
|
+
duration: 888,
|
|
199
|
+
title: "Sintel - Epic Fantasy Short Film",
|
|
200
|
+
author: {
|
|
201
|
+
id: "author-1",
|
|
202
|
+
name: "Blender Foundation",
|
|
203
|
+
avatar: "https://i.pravatar.cc/150?u=author1",
|
|
204
|
+
isVerified: true
|
|
205
|
+
},
|
|
206
|
+
stats: {
|
|
207
|
+
views: 2567890,
|
|
208
|
+
likes: 189012,
|
|
209
|
+
comments: 8901,
|
|
210
|
+
shares: 2345
|
|
211
|
+
},
|
|
212
|
+
isLiked: false,
|
|
213
|
+
isFollowing: false,
|
|
214
|
+
createdAt: "2024-01-08T09:15:00Z",
|
|
215
|
+
hashtags: ["fantasy", "animation", "sintel", "blender"]
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: "video-9",
|
|
219
|
+
source: {
|
|
220
|
+
url: "https://storage.googleapis.com/gtv-videos-bucket/sample/SubaruOutbackOnStreetAndDirt.mp4",
|
|
221
|
+
type: "mp4"
|
|
222
|
+
},
|
|
223
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/SubaruOutbackOnStreetAndDirt.jpg",
|
|
224
|
+
duration: 596,
|
|
225
|
+
title: "Subaru Outback - Street & Dirt",
|
|
226
|
+
author: {
|
|
227
|
+
id: "author-6",
|
|
228
|
+
name: "Car Reviews Daily",
|
|
229
|
+
avatar: "https://i.pravatar.cc/150?u=author6",
|
|
230
|
+
isVerified: true
|
|
231
|
+
},
|
|
232
|
+
stats: {
|
|
233
|
+
views: 456789,
|
|
234
|
+
likes: 34567,
|
|
235
|
+
comments: 1234,
|
|
236
|
+
shares: 456
|
|
237
|
+
},
|
|
238
|
+
isLiked: false,
|
|
239
|
+
isFollowing: true,
|
|
240
|
+
createdAt: "2024-01-07T14:20:00Z",
|
|
241
|
+
hashtags: ["cars", "subaru", "offroad", "review"]
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: "video-10",
|
|
245
|
+
source: {
|
|
246
|
+
url: "https://storage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4",
|
|
247
|
+
type: "mp4"
|
|
248
|
+
},
|
|
249
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/TearsOfSteel.jpg",
|
|
250
|
+
duration: 734,
|
|
251
|
+
title: "Tears of Steel - Sci-Fi Drama",
|
|
252
|
+
author: {
|
|
253
|
+
id: "author-1",
|
|
254
|
+
name: "Blender Foundation",
|
|
255
|
+
avatar: "https://i.pravatar.cc/150?u=author1",
|
|
256
|
+
isVerified: true
|
|
257
|
+
},
|
|
258
|
+
stats: {
|
|
259
|
+
views: 1876543,
|
|
260
|
+
likes: 145678,
|
|
261
|
+
comments: 6789,
|
|
262
|
+
shares: 1890
|
|
263
|
+
},
|
|
264
|
+
isLiked: true,
|
|
265
|
+
isFollowing: false,
|
|
266
|
+
createdAt: "2024-01-06T11:10:00Z",
|
|
267
|
+
hashtags: ["scifi", "drama", "blender", "vfx"]
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
id: "video-11",
|
|
271
|
+
source: {
|
|
272
|
+
url: "https://storage.googleapis.com/gtv-videos-bucket/sample/VolkswagenGTIReview.mp4",
|
|
273
|
+
type: "mp4"
|
|
274
|
+
},
|
|
275
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/VolkswagenGTIReview.jpg",
|
|
276
|
+
duration: 596,
|
|
277
|
+
title: "VW GTI Review - Hot Hatch King",
|
|
278
|
+
author: {
|
|
279
|
+
id: "author-6",
|
|
280
|
+
name: "Car Reviews Daily",
|
|
281
|
+
avatar: "https://i.pravatar.cc/150?u=author6",
|
|
282
|
+
isVerified: true
|
|
283
|
+
},
|
|
284
|
+
stats: {
|
|
285
|
+
views: 567890,
|
|
286
|
+
likes: 45678,
|
|
287
|
+
comments: 2345,
|
|
288
|
+
shares: 567
|
|
289
|
+
},
|
|
290
|
+
isLiked: false,
|
|
291
|
+
isFollowing: true,
|
|
292
|
+
createdAt: "2024-01-05T17:35:00Z",
|
|
293
|
+
hashtags: ["cars", "vw", "gti", "hothatch"]
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: "video-12",
|
|
297
|
+
source: {
|
|
298
|
+
url: "https://storage.googleapis.com/gtv-videos-bucket/sample/WeAreGoingOnBullrun.mp4",
|
|
299
|
+
type: "mp4"
|
|
300
|
+
},
|
|
301
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/WeAreGoingOnBullrun.jpg",
|
|
302
|
+
duration: 596,
|
|
303
|
+
title: "We Are Going On Bullrun! \u{1F3CE}\uFE0F",
|
|
304
|
+
author: {
|
|
305
|
+
id: "author-7",
|
|
306
|
+
name: "Rally Life",
|
|
307
|
+
avatar: "https://i.pravatar.cc/150?u=author7",
|
|
308
|
+
isVerified: false
|
|
309
|
+
},
|
|
310
|
+
stats: {
|
|
311
|
+
views: 345678,
|
|
312
|
+
likes: 23456,
|
|
313
|
+
comments: 890,
|
|
314
|
+
shares: 234
|
|
315
|
+
},
|
|
316
|
+
isLiked: true,
|
|
317
|
+
isFollowing: false,
|
|
318
|
+
createdAt: "2024-01-04T08:50:00Z",
|
|
319
|
+
hashtags: ["rally", "racing", "bullrun", "supercars"]
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
id: "video-13",
|
|
323
|
+
source: {
|
|
324
|
+
url: "https://storage.googleapis.com/gtv-videos-bucket/sample/WhatCarCanYouGetForAGrand.mp4",
|
|
325
|
+
type: "mp4"
|
|
326
|
+
},
|
|
327
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/WhatCarCanYouGetForAGrand.jpg",
|
|
328
|
+
duration: 596,
|
|
329
|
+
title: "What Car Can You Get For $1000?",
|
|
330
|
+
author: {
|
|
331
|
+
id: "author-8",
|
|
332
|
+
name: "Budget Car Tips",
|
|
333
|
+
avatar: "https://i.pravatar.cc/150?u=author8",
|
|
334
|
+
isVerified: false
|
|
335
|
+
},
|
|
336
|
+
stats: {
|
|
337
|
+
views: 789012,
|
|
338
|
+
likes: 56789,
|
|
339
|
+
comments: 3456,
|
|
340
|
+
shares: 890
|
|
341
|
+
},
|
|
342
|
+
isLiked: false,
|
|
343
|
+
isFollowing: false,
|
|
344
|
+
createdAt: "2024-01-03T13:25:00Z",
|
|
345
|
+
hashtags: ["budget", "usedcars", "tips", "bargain"]
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
id: "video-14",
|
|
349
|
+
source: {
|
|
350
|
+
url: "https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
|
|
351
|
+
type: "mp4"
|
|
352
|
+
},
|
|
353
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg",
|
|
354
|
+
duration: 596,
|
|
355
|
+
title: "Behind The Scenes: Big Buck Bunny",
|
|
356
|
+
author: {
|
|
357
|
+
id: "author-9",
|
|
358
|
+
name: "3D Animation Pro",
|
|
359
|
+
avatar: "https://i.pravatar.cc/150?u=author9",
|
|
360
|
+
isVerified: true
|
|
361
|
+
},
|
|
362
|
+
stats: {
|
|
363
|
+
views: 456123,
|
|
364
|
+
likes: 34512,
|
|
365
|
+
comments: 1567,
|
|
366
|
+
shares: 412
|
|
367
|
+
},
|
|
368
|
+
isLiked: false,
|
|
369
|
+
isFollowing: true,
|
|
370
|
+
createdAt: "2024-01-02T10:40:00Z",
|
|
371
|
+
hashtags: ["bts", "animation", "3d", "making"]
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
id: "video-15",
|
|
375
|
+
source: {
|
|
376
|
+
url: "https://storage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4",
|
|
377
|
+
type: "mp4"
|
|
378
|
+
},
|
|
379
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/ElephantsDream.jpg",
|
|
380
|
+
duration: 653,
|
|
381
|
+
title: "Making of Elephant's Dream",
|
|
382
|
+
author: {
|
|
383
|
+
id: "author-9",
|
|
384
|
+
name: "3D Animation Pro",
|
|
385
|
+
avatar: "https://i.pravatar.cc/150?u=author9",
|
|
386
|
+
isVerified: true
|
|
387
|
+
},
|
|
388
|
+
stats: {
|
|
389
|
+
views: 234890,
|
|
390
|
+
likes: 19876,
|
|
391
|
+
comments: 876,
|
|
392
|
+
shares: 234
|
|
393
|
+
},
|
|
394
|
+
isLiked: true,
|
|
395
|
+
isFollowing: true,
|
|
396
|
+
createdAt: "2024-01-01T15:15:00Z",
|
|
397
|
+
hashtags: ["tutorial", "animation", "blender", "makingof"]
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
id: "video-16",
|
|
401
|
+
source: {
|
|
402
|
+
url: "https://storage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4",
|
|
403
|
+
type: "mp4"
|
|
404
|
+
},
|
|
405
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/Sintel.jpg",
|
|
406
|
+
duration: 888,
|
|
407
|
+
title: "Sintel Character Design Breakdown",
|
|
408
|
+
author: {
|
|
409
|
+
id: "author-10",
|
|
410
|
+
name: "Character Artist Hub",
|
|
411
|
+
avatar: "https://i.pravatar.cc/150?u=author10",
|
|
412
|
+
isVerified: true
|
|
413
|
+
},
|
|
414
|
+
stats: {
|
|
415
|
+
views: 678901,
|
|
416
|
+
likes: 45678,
|
|
417
|
+
comments: 2134,
|
|
418
|
+
shares: 567
|
|
419
|
+
},
|
|
420
|
+
isLiked: false,
|
|
421
|
+
isFollowing: false,
|
|
422
|
+
createdAt: "2023-12-31T18:00:00Z",
|
|
423
|
+
hashtags: ["characterdesign", "sintel", "tutorial", "art"]
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
id: "video-17",
|
|
427
|
+
source: {
|
|
428
|
+
url: "https://storage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4",
|
|
429
|
+
type: "mp4"
|
|
430
|
+
},
|
|
431
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/TearsOfSteel.jpg",
|
|
432
|
+
duration: 734,
|
|
433
|
+
title: "VFX Breakdown: Tears of Steel",
|
|
434
|
+
author: {
|
|
435
|
+
id: "author-11",
|
|
436
|
+
name: "VFX Masters",
|
|
437
|
+
avatar: "https://i.pravatar.cc/150?u=author11",
|
|
438
|
+
isVerified: true
|
|
439
|
+
},
|
|
440
|
+
stats: {
|
|
441
|
+
views: 890123,
|
|
442
|
+
likes: 67890,
|
|
443
|
+
comments: 3456,
|
|
444
|
+
shares: 789
|
|
445
|
+
},
|
|
446
|
+
isLiked: true,
|
|
447
|
+
isFollowing: true,
|
|
448
|
+
createdAt: "2023-12-30T09:30:00Z",
|
|
449
|
+
hashtags: ["vfx", "breakdown", "compositing", "cgi"]
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
id: "video-18",
|
|
453
|
+
source: {
|
|
454
|
+
url: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4",
|
|
455
|
+
type: "mp4"
|
|
456
|
+
},
|
|
457
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerJoyrides.jpg",
|
|
458
|
+
duration: 15,
|
|
459
|
+
title: "POV: Ultimate Road Trip \u{1F304}",
|
|
460
|
+
author: {
|
|
461
|
+
id: "author-12",
|
|
462
|
+
name: "Travel Vlogger",
|
|
463
|
+
avatar: "https://i.pravatar.cc/150?u=author12",
|
|
464
|
+
isVerified: false
|
|
465
|
+
},
|
|
466
|
+
stats: {
|
|
467
|
+
views: 123456,
|
|
468
|
+
likes: 9876,
|
|
469
|
+
comments: 432,
|
|
470
|
+
shares: 123
|
|
471
|
+
},
|
|
472
|
+
isLiked: false,
|
|
473
|
+
isFollowing: false,
|
|
474
|
+
createdAt: "2023-12-29T14:45:00Z",
|
|
475
|
+
hashtags: ["pov", "roadtrip", "travel", "wanderlust"]
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
id: "video-19",
|
|
479
|
+
source: {
|
|
480
|
+
url: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
|
|
481
|
+
type: "mp4"
|
|
482
|
+
},
|
|
483
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerBlazes.jpg",
|
|
484
|
+
duration: 15,
|
|
485
|
+
title: "Satisfying Fire Compilation \u{1F525}",
|
|
486
|
+
author: {
|
|
487
|
+
id: "author-13",
|
|
488
|
+
name: "Oddly Satisfying",
|
|
489
|
+
avatar: "https://i.pravatar.cc/150?u=author13",
|
|
490
|
+
isVerified: true
|
|
491
|
+
},
|
|
492
|
+
stats: {
|
|
493
|
+
views: 567890,
|
|
494
|
+
likes: 45678,
|
|
495
|
+
comments: 1234,
|
|
496
|
+
shares: 456
|
|
497
|
+
},
|
|
498
|
+
isLiked: true,
|
|
499
|
+
isFollowing: false,
|
|
500
|
+
createdAt: "2023-12-28T11:20:00Z",
|
|
501
|
+
hashtags: ["satisfying", "fire", "asmr", "relaxing"]
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
id: "video-20",
|
|
505
|
+
source: {
|
|
506
|
+
url: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4",
|
|
507
|
+
type: "mp4"
|
|
508
|
+
},
|
|
509
|
+
poster: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerMeltdowns.jpg",
|
|
510
|
+
duration: 15,
|
|
511
|
+
title: "Chocolate Factory Tour \u{1F36B}",
|
|
512
|
+
author: {
|
|
513
|
+
id: "author-14",
|
|
514
|
+
name: "Food Explorer",
|
|
515
|
+
avatar: "https://i.pravatar.cc/150?u=author14",
|
|
516
|
+
isVerified: false
|
|
517
|
+
},
|
|
518
|
+
stats: {
|
|
519
|
+
views: 345678,
|
|
520
|
+
likes: 23456,
|
|
521
|
+
comments: 876,
|
|
522
|
+
shares: 234
|
|
523
|
+
},
|
|
524
|
+
isLiked: false,
|
|
525
|
+
isFollowing: true,
|
|
526
|
+
createdAt: "2023-12-27T16:55:00Z",
|
|
527
|
+
hashtags: ["food", "chocolate", "factory", "tour"]
|
|
528
|
+
}
|
|
529
|
+
];
|
|
530
|
+
var MockDataAdapter = class {
|
|
531
|
+
constructor(options = {}) {
|
|
532
|
+
this.videos = options.videos ?? MOCK_VIDEOS;
|
|
533
|
+
this.pageSize = options.pageSize ?? 3;
|
|
534
|
+
this.delay = options.delay ?? 300;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Fetch a page of mock videos
|
|
538
|
+
*/
|
|
539
|
+
async fetchFeed(cursor) {
|
|
540
|
+
await this.simulateDelay();
|
|
541
|
+
const offset = cursor ? Number.parseInt(cursor, 10) : 0;
|
|
542
|
+
const start = offset;
|
|
543
|
+
const end = start + this.pageSize;
|
|
544
|
+
const items = this.videos.slice(start, end);
|
|
545
|
+
const hasMore = end < this.videos.length;
|
|
546
|
+
const nextCursor = hasMore ? String(end) : null;
|
|
547
|
+
return {
|
|
548
|
+
items,
|
|
549
|
+
nextCursor,
|
|
550
|
+
hasMore
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Get a single video by ID
|
|
555
|
+
*/
|
|
556
|
+
async getVideoDetail(id) {
|
|
557
|
+
await this.simulateDelay();
|
|
558
|
+
const video = this.videos.find((v) => v.id === id);
|
|
559
|
+
if (!video) {
|
|
560
|
+
throw new Error(`Video not found: ${id}`);
|
|
561
|
+
}
|
|
562
|
+
return video;
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Prefetch videos (no-op for mock)
|
|
566
|
+
*/
|
|
567
|
+
async prefetch(_ids) {
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Simulate network delay
|
|
571
|
+
*/
|
|
572
|
+
async simulateDelay() {
|
|
573
|
+
if (this.delay > 0) {
|
|
574
|
+
await new Promise((resolve) => setTimeout(resolve, this.delay));
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
// src/logger/mock.ts
|
|
580
|
+
var LOG_LEVEL_PRIORITY = {
|
|
581
|
+
debug: 0,
|
|
582
|
+
info: 1,
|
|
583
|
+
warn: 2,
|
|
584
|
+
error: 3
|
|
585
|
+
};
|
|
586
|
+
var MockLoggerAdapter = class {
|
|
587
|
+
constructor(options = {}) {
|
|
588
|
+
this.buffer = [];
|
|
589
|
+
this.maxSize = options.bufferSize ?? 50;
|
|
590
|
+
this.minLevel = options.minLevel ?? "debug";
|
|
591
|
+
this.consoleOutput = options.consoleOutput ?? false;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Log a single entry to the circular buffer
|
|
595
|
+
*
|
|
596
|
+
* @param entry - Log entry to record
|
|
597
|
+
*/
|
|
598
|
+
log(entry) {
|
|
599
|
+
if (LOG_LEVEL_PRIORITY[entry.level] < LOG_LEVEL_PRIORITY[this.minLevel]) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
this.buffer.push(entry);
|
|
603
|
+
if (this.buffer.length > this.maxSize) {
|
|
604
|
+
this.buffer.shift();
|
|
605
|
+
}
|
|
606
|
+
if (this.consoleOutput) {
|
|
607
|
+
this.outputToConsole(entry);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Flush log entries (called on error for context)
|
|
612
|
+
*
|
|
613
|
+
* @param entries - Array of log entries to flush
|
|
614
|
+
*/
|
|
615
|
+
flush(entries) {
|
|
616
|
+
if (this.consoleOutput) {
|
|
617
|
+
for (const entry of entries) {
|
|
618
|
+
this.outputToConsole(entry);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Log debug message
|
|
624
|
+
*
|
|
625
|
+
* @param message - Debug message
|
|
626
|
+
* @param context - Optional context data
|
|
627
|
+
*/
|
|
628
|
+
debug(message, context) {
|
|
629
|
+
this.log({
|
|
630
|
+
level: "debug",
|
|
631
|
+
message,
|
|
632
|
+
timestamp: Date.now(),
|
|
633
|
+
context
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Log info message
|
|
638
|
+
*
|
|
639
|
+
* @param message - Info message
|
|
640
|
+
* @param context - Optional context data
|
|
641
|
+
*/
|
|
642
|
+
info(message, context) {
|
|
643
|
+
this.log({
|
|
644
|
+
level: "info",
|
|
645
|
+
message,
|
|
646
|
+
timestamp: Date.now(),
|
|
647
|
+
context
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Log warning message
|
|
652
|
+
*
|
|
653
|
+
* @param message - Warning message
|
|
654
|
+
* @param context - Optional context data
|
|
655
|
+
*/
|
|
656
|
+
warn(message, context) {
|
|
657
|
+
this.log({
|
|
658
|
+
level: "warn",
|
|
659
|
+
message,
|
|
660
|
+
timestamp: Date.now(),
|
|
661
|
+
context
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Log error message
|
|
666
|
+
*
|
|
667
|
+
* @param message - Error message
|
|
668
|
+
* @param error - Optional Error object
|
|
669
|
+
* @param context - Optional context data
|
|
670
|
+
*/
|
|
671
|
+
error(message, error, context) {
|
|
672
|
+
this.log({
|
|
673
|
+
level: "error",
|
|
674
|
+
message,
|
|
675
|
+
timestamp: Date.now(),
|
|
676
|
+
context,
|
|
677
|
+
stack: error?.stack
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
// ═══════════════════════════════════════════════════════════════
|
|
681
|
+
// TESTING HELPERS (not part of ILogger interface)
|
|
682
|
+
// ═══════════════════════════════════════════════════════════════
|
|
683
|
+
/**
|
|
684
|
+
* Get current buffer contents (for testing)
|
|
685
|
+
*
|
|
686
|
+
* @returns Copy of current log buffer
|
|
687
|
+
*/
|
|
688
|
+
getBuffer() {
|
|
689
|
+
return [...this.buffer];
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Get buffer size (for testing)
|
|
693
|
+
*
|
|
694
|
+
* @returns Number of entries in buffer
|
|
695
|
+
*/
|
|
696
|
+
getBufferSize() {
|
|
697
|
+
return this.buffer.length;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Clear the buffer (for testing)
|
|
701
|
+
*/
|
|
702
|
+
clearBuffer() {
|
|
703
|
+
this.buffer = [];
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Get entries by level (for testing)
|
|
707
|
+
*
|
|
708
|
+
* @param level - Log level to filter by
|
|
709
|
+
* @returns Entries matching the level
|
|
710
|
+
*/
|
|
711
|
+
getEntriesByLevel(level) {
|
|
712
|
+
return this.buffer.filter((entry) => entry.level === level);
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Force flush and return buffer contents
|
|
716
|
+
*
|
|
717
|
+
* @returns All entries in buffer before clearing
|
|
718
|
+
*/
|
|
719
|
+
forceFlush() {
|
|
720
|
+
const entries = [...this.buffer];
|
|
721
|
+
this.flush(entries);
|
|
722
|
+
this.buffer = [];
|
|
723
|
+
return entries;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Output entry to console (when consoleOutput is enabled)
|
|
727
|
+
*/
|
|
728
|
+
outputToConsole(entry) {
|
|
729
|
+
const timestamp = new Date(entry.timestamp).toISOString();
|
|
730
|
+
const prefix = `[${timestamp}] [${entry.level.toUpperCase()}]`;
|
|
731
|
+
switch (entry.level) {
|
|
732
|
+
case "debug":
|
|
733
|
+
console.debug(prefix, entry.message, entry.context ?? "");
|
|
734
|
+
break;
|
|
735
|
+
case "info":
|
|
736
|
+
console.info(prefix, entry.message, entry.context ?? "");
|
|
737
|
+
break;
|
|
738
|
+
case "warn":
|
|
739
|
+
console.warn(prefix, entry.message, entry.context ?? "");
|
|
740
|
+
break;
|
|
741
|
+
case "error":
|
|
742
|
+
console.error(prefix, entry.message, entry.context ?? "", entry.stack ?? "");
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
// src/storage/mock.ts
|
|
749
|
+
var MockStorageAdapter = class {
|
|
750
|
+
constructor(options = {}) {
|
|
751
|
+
this.store = /* @__PURE__ */ new Map();
|
|
752
|
+
this.delay = options.delay ?? 0;
|
|
753
|
+
this.errorRate = options.errorRate ?? 0;
|
|
754
|
+
this.simulateQuotaError = options.simulateQuotaError ?? false;
|
|
755
|
+
if (options.initialData) {
|
|
756
|
+
for (const [key, value] of Object.entries(options.initialData)) {
|
|
757
|
+
this.store.set(key, JSON.stringify(value));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Get a value from storage
|
|
763
|
+
*
|
|
764
|
+
* @param key - Storage key
|
|
765
|
+
* @returns Promise resolving to the stored value or null if not found
|
|
766
|
+
*/
|
|
767
|
+
async get(key) {
|
|
768
|
+
await this.simulateDelay();
|
|
769
|
+
this.maybeThrowError();
|
|
770
|
+
const value = this.store.get(key);
|
|
771
|
+
if (value === void 0) {
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
try {
|
|
775
|
+
return JSON.parse(value);
|
|
776
|
+
} catch {
|
|
777
|
+
return null;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Set a value in storage
|
|
782
|
+
*
|
|
783
|
+
* @param key - Storage key
|
|
784
|
+
* @param value - Value to store (will be serialized)
|
|
785
|
+
* @throws DOMException if simulateQuotaError is enabled
|
|
786
|
+
*/
|
|
787
|
+
async set(key, value) {
|
|
788
|
+
await this.simulateDelay();
|
|
789
|
+
this.maybeThrowError();
|
|
790
|
+
if (this.simulateQuotaError) {
|
|
791
|
+
throw new DOMException("QuotaExceededError", "QuotaExceededError");
|
|
792
|
+
}
|
|
793
|
+
this.store.set(key, JSON.stringify(value));
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Remove a value from storage
|
|
797
|
+
*
|
|
798
|
+
* @param key - Storage key to remove
|
|
799
|
+
*/
|
|
800
|
+
async remove(key) {
|
|
801
|
+
await this.simulateDelay();
|
|
802
|
+
this.maybeThrowError();
|
|
803
|
+
this.store.delete(key);
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Clear all SDK-related storage
|
|
807
|
+
*/
|
|
808
|
+
async clear() {
|
|
809
|
+
await this.simulateDelay();
|
|
810
|
+
this.maybeThrowError();
|
|
811
|
+
this.store.clear();
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Get all keys in storage
|
|
815
|
+
*
|
|
816
|
+
* @returns Promise resolving to array of storage keys
|
|
817
|
+
*/
|
|
818
|
+
async keys() {
|
|
819
|
+
await this.simulateDelay();
|
|
820
|
+
this.maybeThrowError();
|
|
821
|
+
return Array.from(this.store.keys());
|
|
822
|
+
}
|
|
823
|
+
// ═══════════════════════════════════════════════════════════════
|
|
824
|
+
// TESTING HELPERS (not part of IStorage interface)
|
|
825
|
+
// ═══════════════════════════════════════════════════════════════
|
|
826
|
+
/**
|
|
827
|
+
* Get storage size (for testing)
|
|
828
|
+
*
|
|
829
|
+
* @returns Number of entries in storage
|
|
830
|
+
*/
|
|
831
|
+
getSize() {
|
|
832
|
+
return this.store.size;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Check if key exists (for testing)
|
|
836
|
+
*
|
|
837
|
+
* @param key - Storage key to check
|
|
838
|
+
* @returns Whether key exists
|
|
839
|
+
*/
|
|
840
|
+
has(key) {
|
|
841
|
+
return this.store.has(key);
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Get raw storage Map (for testing)
|
|
845
|
+
*
|
|
846
|
+
* @returns Copy of internal storage map
|
|
847
|
+
*/
|
|
848
|
+
getRawStore() {
|
|
849
|
+
return new Map(this.store);
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Simulate network/processing delay
|
|
853
|
+
*/
|
|
854
|
+
async simulateDelay() {
|
|
855
|
+
if (this.delay > 0) {
|
|
856
|
+
await new Promise((resolve) => setTimeout(resolve, this.delay));
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Maybe throw error based on error rate
|
|
861
|
+
*/
|
|
862
|
+
maybeThrowError() {
|
|
863
|
+
if (this.errorRate > 0 && Math.random() < this.errorRate) {
|
|
864
|
+
throw new Error("Storage operation failed");
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
var SESSION_SNAPSHOT_KEY = "sv-session-snapshot";
|
|
869
|
+
var MockSessionStorageAdapter = class extends MockStorageAdapter {
|
|
870
|
+
constructor(options = {}) {
|
|
871
|
+
super(options);
|
|
872
|
+
this.maxSnapshotAge = options.maxSnapshotAge ?? 24 * 60 * 60 * 1e3;
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Save session snapshot for state restoration
|
|
876
|
+
*
|
|
877
|
+
* @param snapshot - Session state to persist
|
|
878
|
+
*/
|
|
879
|
+
async saveSnapshot(snapshot) {
|
|
880
|
+
await this.set(SESSION_SNAPSHOT_KEY, snapshot);
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Load the most recent session snapshot
|
|
884
|
+
*
|
|
885
|
+
* @returns Promise resolving to snapshot or null if none exists
|
|
886
|
+
*/
|
|
887
|
+
async loadSnapshot() {
|
|
888
|
+
const snapshot = await this.get(SESSION_SNAPSHOT_KEY);
|
|
889
|
+
if (!snapshot) {
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
const age = Date.now() - snapshot.savedAt;
|
|
893
|
+
if (age > this.maxSnapshotAge) {
|
|
894
|
+
await this.clearSnapshot();
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
return snapshot;
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Clear session snapshot
|
|
901
|
+
*/
|
|
902
|
+
async clearSnapshot() {
|
|
903
|
+
await this.remove(SESSION_SNAPSHOT_KEY);
|
|
904
|
+
}
|
|
905
|
+
// ═══════════════════════════════════════════════════════════════
|
|
906
|
+
// TESTING HELPERS
|
|
907
|
+
// ═══════════════════════════════════════════════════════════════
|
|
908
|
+
/**
|
|
909
|
+
* Check if snapshot exists (for testing)
|
|
910
|
+
*
|
|
911
|
+
* @returns Whether snapshot exists
|
|
912
|
+
*/
|
|
913
|
+
hasSnapshot() {
|
|
914
|
+
return this.has(SESSION_SNAPSHOT_KEY);
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Get snapshot age in ms (for testing)
|
|
918
|
+
*
|
|
919
|
+
* @returns Age in ms or null if no snapshot
|
|
920
|
+
*/
|
|
921
|
+
async getSnapshotAge() {
|
|
922
|
+
const snapshot = await this.get(SESSION_SNAPSHOT_KEY);
|
|
923
|
+
if (!snapshot) {
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
return Date.now() - snapshot.savedAt;
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
// src/interaction/mock.ts
|
|
931
|
+
var commentIdCounter = 0;
|
|
932
|
+
var MockInteractionAdapter = class {
|
|
933
|
+
constructor(options = {}) {
|
|
934
|
+
this.likedVideos = /* @__PURE__ */ new Set();
|
|
935
|
+
this.followedAuthors = /* @__PURE__ */ new Set();
|
|
936
|
+
this.likedComments = /* @__PURE__ */ new Set();
|
|
937
|
+
this.comments = /* @__PURE__ */ new Map();
|
|
938
|
+
this.delay = options.delay ?? 0;
|
|
939
|
+
this.errorRate = options.errorRate ?? 0;
|
|
940
|
+
this.failLike = options.failLike ?? false;
|
|
941
|
+
this.failUnlike = options.failUnlike ?? false;
|
|
942
|
+
this.failFollow = options.failFollow ?? false;
|
|
943
|
+
this.failUnfollow = options.failUnfollow ?? false;
|
|
944
|
+
this.failComment = options.failComment ?? false;
|
|
945
|
+
this.failDeleteComment = options.failDeleteComment ?? false;
|
|
946
|
+
if (options.initialLikedVideos) {
|
|
947
|
+
for (const id of options.initialLikedVideos) {
|
|
948
|
+
this.likedVideos.add(id);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
if (options.initialFollowedAuthors) {
|
|
952
|
+
for (const id of options.initialFollowedAuthors) {
|
|
953
|
+
this.followedAuthors.add(id);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (options.initialLikedComments) {
|
|
957
|
+
for (const id of options.initialLikedComments) {
|
|
958
|
+
this.likedComments.add(id);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Like a video
|
|
964
|
+
*
|
|
965
|
+
* @param videoId - ID of the video to like
|
|
966
|
+
* @throws Error if failLike is enabled or random error occurs
|
|
967
|
+
*/
|
|
968
|
+
async like(videoId) {
|
|
969
|
+
await this.simulateDelay();
|
|
970
|
+
this.maybeThrowError();
|
|
971
|
+
if (this.failLike) {
|
|
972
|
+
throw new Error("Like failed - server error");
|
|
973
|
+
}
|
|
974
|
+
this.likedVideos.add(videoId);
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Remove like from a video
|
|
978
|
+
*
|
|
979
|
+
* @param videoId - ID of the video to unlike
|
|
980
|
+
* @throws Error if failUnlike is enabled or random error occurs
|
|
981
|
+
*/
|
|
982
|
+
async unlike(videoId) {
|
|
983
|
+
await this.simulateDelay();
|
|
984
|
+
this.maybeThrowError();
|
|
985
|
+
if (this.failUnlike) {
|
|
986
|
+
throw new Error("Unlike failed - server error");
|
|
987
|
+
}
|
|
988
|
+
this.likedVideos.delete(videoId);
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Follow a video author
|
|
992
|
+
*
|
|
993
|
+
* @param authorId - ID of the author to follow
|
|
994
|
+
* @throws Error if failFollow is enabled or random error occurs
|
|
995
|
+
*/
|
|
996
|
+
async follow(authorId) {
|
|
997
|
+
await this.simulateDelay();
|
|
998
|
+
this.maybeThrowError();
|
|
999
|
+
if (this.failFollow) {
|
|
1000
|
+
throw new Error("Follow failed - server error");
|
|
1001
|
+
}
|
|
1002
|
+
this.followedAuthors.add(authorId);
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Unfollow a video author
|
|
1006
|
+
*
|
|
1007
|
+
* @param authorId - ID of the author to unfollow
|
|
1008
|
+
* @throws Error if failUnfollow is enabled or random error occurs
|
|
1009
|
+
*/
|
|
1010
|
+
async unfollow(authorId) {
|
|
1011
|
+
await this.simulateDelay();
|
|
1012
|
+
this.maybeThrowError();
|
|
1013
|
+
if (this.failUnfollow) {
|
|
1014
|
+
throw new Error("Unfollow failed - server error");
|
|
1015
|
+
}
|
|
1016
|
+
this.followedAuthors.delete(authorId);
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Post a comment on a video
|
|
1020
|
+
*
|
|
1021
|
+
* @param _videoId - ID of the video to comment on (unused in mock)
|
|
1022
|
+
* @param text - Comment text content
|
|
1023
|
+
* @returns Promise resolving to the created comment
|
|
1024
|
+
* @throws Error if failComment is enabled or random error occurs
|
|
1025
|
+
*/
|
|
1026
|
+
async comment(_videoId, text) {
|
|
1027
|
+
await this.simulateDelay();
|
|
1028
|
+
this.maybeThrowError();
|
|
1029
|
+
if (this.failComment) {
|
|
1030
|
+
throw new Error("Comment failed - server error");
|
|
1031
|
+
}
|
|
1032
|
+
const newComment = {
|
|
1033
|
+
id: `comment-${++commentIdCounter}`,
|
|
1034
|
+
text,
|
|
1035
|
+
author: {
|
|
1036
|
+
id: "mock-user",
|
|
1037
|
+
name: "Mock User",
|
|
1038
|
+
avatar: "https://i.pravatar.cc/150?u=mock",
|
|
1039
|
+
isVerified: false
|
|
1040
|
+
},
|
|
1041
|
+
likes: 0,
|
|
1042
|
+
isLiked: false,
|
|
1043
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1044
|
+
replyCount: 0
|
|
1045
|
+
};
|
|
1046
|
+
this.comments.set(newComment.id, newComment);
|
|
1047
|
+
return newComment;
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Delete a comment
|
|
1051
|
+
*
|
|
1052
|
+
* @param commentId - ID of the comment to delete
|
|
1053
|
+
* @throws Error if comment not found, failDeleteComment is enabled, or random error occurs
|
|
1054
|
+
*/
|
|
1055
|
+
async deleteComment(commentId) {
|
|
1056
|
+
await this.simulateDelay();
|
|
1057
|
+
this.maybeThrowError();
|
|
1058
|
+
if (this.failDeleteComment) {
|
|
1059
|
+
throw new Error("Delete comment failed - server error");
|
|
1060
|
+
}
|
|
1061
|
+
if (!this.comments.has(commentId)) {
|
|
1062
|
+
throw new Error(`Comment not found: ${commentId}`);
|
|
1063
|
+
}
|
|
1064
|
+
this.comments.delete(commentId);
|
|
1065
|
+
this.likedComments.delete(commentId);
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Like a comment
|
|
1069
|
+
*
|
|
1070
|
+
* @param commentId - ID of the comment to like
|
|
1071
|
+
*/
|
|
1072
|
+
async likeComment(commentId) {
|
|
1073
|
+
await this.simulateDelay();
|
|
1074
|
+
this.maybeThrowError();
|
|
1075
|
+
this.likedComments.add(commentId);
|
|
1076
|
+
const comment = this.comments.get(commentId);
|
|
1077
|
+
if (comment) {
|
|
1078
|
+
comment.isLiked = true;
|
|
1079
|
+
comment.likes += 1;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Unlike a comment
|
|
1084
|
+
*
|
|
1085
|
+
* @param commentId - ID of the comment to unlike
|
|
1086
|
+
*/
|
|
1087
|
+
async unlikeComment(commentId) {
|
|
1088
|
+
await this.simulateDelay();
|
|
1089
|
+
this.maybeThrowError();
|
|
1090
|
+
this.likedComments.delete(commentId);
|
|
1091
|
+
const comment = this.comments.get(commentId);
|
|
1092
|
+
if (comment?.isLiked) {
|
|
1093
|
+
comment.isLiked = false;
|
|
1094
|
+
comment.likes = Math.max(0, comment.likes - 1);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Share a video (tracking only)
|
|
1099
|
+
*
|
|
1100
|
+
* @param _videoId - ID of the video being shared
|
|
1101
|
+
* @param _platform - Optional platform identifier
|
|
1102
|
+
*/
|
|
1103
|
+
async share(_videoId, _platform) {
|
|
1104
|
+
await this.simulateDelay();
|
|
1105
|
+
this.maybeThrowError();
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Report a video
|
|
1109
|
+
*
|
|
1110
|
+
* @param _videoId - ID of the video to report
|
|
1111
|
+
* @param _reason - Report reason code
|
|
1112
|
+
* @param _description - Optional additional description
|
|
1113
|
+
*/
|
|
1114
|
+
async report(_videoId, _reason, _description) {
|
|
1115
|
+
await this.simulateDelay();
|
|
1116
|
+
this.maybeThrowError();
|
|
1117
|
+
}
|
|
1118
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1119
|
+
// TESTING HELPERS (not part of IInteraction interface)
|
|
1120
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1121
|
+
/**
|
|
1122
|
+
* Check if video is liked (for testing)
|
|
1123
|
+
*
|
|
1124
|
+
* @param videoId - Video ID to check
|
|
1125
|
+
* @returns Whether video is liked
|
|
1126
|
+
*/
|
|
1127
|
+
isVideoLiked(videoId) {
|
|
1128
|
+
return this.likedVideos.has(videoId);
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Check if author is followed (for testing)
|
|
1132
|
+
*
|
|
1133
|
+
* @param authorId - Author ID to check
|
|
1134
|
+
* @returns Whether author is followed
|
|
1135
|
+
*/
|
|
1136
|
+
isAuthorFollowed(authorId) {
|
|
1137
|
+
return this.followedAuthors.has(authorId);
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Check if comment is liked (for testing)
|
|
1141
|
+
*
|
|
1142
|
+
* @param commentId - Comment ID to check
|
|
1143
|
+
* @returns Whether comment is liked
|
|
1144
|
+
*/
|
|
1145
|
+
isCommentLiked(commentId) {
|
|
1146
|
+
return this.likedComments.has(commentId);
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Get all liked video IDs (for testing)
|
|
1150
|
+
*
|
|
1151
|
+
* @returns Array of liked video IDs
|
|
1152
|
+
*/
|
|
1153
|
+
getLikedVideos() {
|
|
1154
|
+
return Array.from(this.likedVideos);
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Get all followed author IDs (for testing)
|
|
1158
|
+
*
|
|
1159
|
+
* @returns Array of followed author IDs
|
|
1160
|
+
*/
|
|
1161
|
+
getFollowedAuthors() {
|
|
1162
|
+
return Array.from(this.followedAuthors);
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Get all comments (for testing)
|
|
1166
|
+
*
|
|
1167
|
+
* @returns Map of comment ID to Comment
|
|
1168
|
+
*/
|
|
1169
|
+
getComments() {
|
|
1170
|
+
return new Map(this.comments);
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Get a specific comment (for testing)
|
|
1174
|
+
*
|
|
1175
|
+
* @param commentId - Comment ID
|
|
1176
|
+
* @returns Comment or undefined
|
|
1177
|
+
*/
|
|
1178
|
+
getComment(commentId) {
|
|
1179
|
+
return this.comments.get(commentId);
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Reset all state (for testing)
|
|
1183
|
+
*/
|
|
1184
|
+
reset() {
|
|
1185
|
+
this.likedVideos.clear();
|
|
1186
|
+
this.followedAuthors.clear();
|
|
1187
|
+
this.likedComments.clear();
|
|
1188
|
+
this.comments.clear();
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Simulate network/processing delay
|
|
1192
|
+
*/
|
|
1193
|
+
async simulateDelay() {
|
|
1194
|
+
if (this.delay > 0) {
|
|
1195
|
+
await new Promise((resolve) => setTimeout(resolve, this.delay));
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Maybe throw error based on error rate
|
|
1200
|
+
*/
|
|
1201
|
+
maybeThrowError() {
|
|
1202
|
+
if (this.errorRate > 0 && Math.random() < this.errorRate) {
|
|
1203
|
+
throw new Error("Interaction failed - random error");
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
};
|
|
1207
|
+
|
|
1208
|
+
// src/analytics/mock.ts
|
|
1209
|
+
var MockAnalyticsAdapter = class {
|
|
1210
|
+
constructor(options = {}) {
|
|
1211
|
+
this.eventQueue = [];
|
|
1212
|
+
this.flushedEvents = [];
|
|
1213
|
+
this.userId = null;
|
|
1214
|
+
this.userProperties = {};
|
|
1215
|
+
this.batchSize = options.batchSize ?? 10;
|
|
1216
|
+
this.autoFlush = options.autoFlush ?? false;
|
|
1217
|
+
this.flushDelay = options.flushDelay ?? 0;
|
|
1218
|
+
this.errorRate = options.errorRate ?? 0;
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Track an analytics event
|
|
1222
|
+
*
|
|
1223
|
+
* @param event - Event to track
|
|
1224
|
+
*/
|
|
1225
|
+
track(event) {
|
|
1226
|
+
this.eventQueue.push(event);
|
|
1227
|
+
if (this.autoFlush && this.eventQueue.length >= this.batchSize) {
|
|
1228
|
+
this.flush().catch(() => {
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Flush queued events immediately
|
|
1234
|
+
*
|
|
1235
|
+
* @returns Promise that resolves when flush is complete
|
|
1236
|
+
*/
|
|
1237
|
+
async flush() {
|
|
1238
|
+
if (this.eventQueue.length === 0) {
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
if (this.flushDelay > 0) {
|
|
1242
|
+
await new Promise((resolve) => setTimeout(resolve, this.flushDelay));
|
|
1243
|
+
}
|
|
1244
|
+
if (this.errorRate > 0 && Math.random() < this.errorRate) {
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
const events = [...this.eventQueue];
|
|
1248
|
+
this.flushedEvents.push(events);
|
|
1249
|
+
this.eventQueue = [];
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Track video view duration
|
|
1253
|
+
*
|
|
1254
|
+
* @param videoId - ID of the video
|
|
1255
|
+
* @param duration - Watch duration in seconds
|
|
1256
|
+
* @param totalDuration - Total video duration in seconds
|
|
1257
|
+
*/
|
|
1258
|
+
trackViewDuration(videoId, duration, totalDuration) {
|
|
1259
|
+
this.track({
|
|
1260
|
+
type: "video_view",
|
|
1261
|
+
videoId,
|
|
1262
|
+
timestamp: Date.now(),
|
|
1263
|
+
data: {
|
|
1264
|
+
duration,
|
|
1265
|
+
totalDuration,
|
|
1266
|
+
percentage: totalDuration > 0 ? duration / totalDuration * 100 : 0
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Track video completion
|
|
1272
|
+
*
|
|
1273
|
+
* @param videoId - ID of the video
|
|
1274
|
+
* @param watchTime - Total time watched in seconds
|
|
1275
|
+
* @param loops - Number of times video looped
|
|
1276
|
+
*/
|
|
1277
|
+
trackCompletion(videoId, watchTime, loops) {
|
|
1278
|
+
this.track({
|
|
1279
|
+
type: "video_complete",
|
|
1280
|
+
videoId,
|
|
1281
|
+
timestamp: Date.now(),
|
|
1282
|
+
data: {
|
|
1283
|
+
watchTime,
|
|
1284
|
+
loops
|
|
1285
|
+
}
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Set user context for analytics
|
|
1290
|
+
*
|
|
1291
|
+
* @param userId - Optional user ID (null for anonymous)
|
|
1292
|
+
* @param properties - Optional user properties
|
|
1293
|
+
*/
|
|
1294
|
+
setUser(userId, properties) {
|
|
1295
|
+
this.userId = userId;
|
|
1296
|
+
this.userProperties = properties ?? {};
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Get current queue size
|
|
1300
|
+
*
|
|
1301
|
+
* @returns Number of events in queue
|
|
1302
|
+
*/
|
|
1303
|
+
getQueueSize() {
|
|
1304
|
+
return this.eventQueue.length;
|
|
1305
|
+
}
|
|
1306
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1307
|
+
// TESTING HELPERS (not part of IAnalytics interface)
|
|
1308
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1309
|
+
/**
|
|
1310
|
+
* Get all tracked events (current queue)
|
|
1311
|
+
*
|
|
1312
|
+
* @returns Copy of current event queue
|
|
1313
|
+
*/
|
|
1314
|
+
getTrackedEvents() {
|
|
1315
|
+
return [...this.eventQueue];
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Get all flushed event batches
|
|
1319
|
+
*
|
|
1320
|
+
* @returns Array of flushed event batches
|
|
1321
|
+
*/
|
|
1322
|
+
getFlushedBatches() {
|
|
1323
|
+
return [...this.flushedEvents];
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Get total flushed events count
|
|
1327
|
+
*
|
|
1328
|
+
* @returns Total number of flushed events
|
|
1329
|
+
*/
|
|
1330
|
+
getTotalFlushedCount() {
|
|
1331
|
+
return this.flushedEvents.reduce((sum, batch) => sum + batch.length, 0);
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Get events by type (from current queue)
|
|
1335
|
+
*
|
|
1336
|
+
* @param type - Event type to filter by
|
|
1337
|
+
* @returns Events matching the type
|
|
1338
|
+
*/
|
|
1339
|
+
getEventsByType(type) {
|
|
1340
|
+
return this.eventQueue.filter((event) => event.type === type);
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Get events for a specific video (from current queue)
|
|
1344
|
+
*
|
|
1345
|
+
* @param videoId - Video ID to filter by
|
|
1346
|
+
* @returns Events for the video
|
|
1347
|
+
*/
|
|
1348
|
+
getEventsByVideoId(videoId) {
|
|
1349
|
+
return this.eventQueue.filter((event) => event.videoId === videoId);
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Get current user context
|
|
1353
|
+
*
|
|
1354
|
+
* @returns User ID and properties
|
|
1355
|
+
*/
|
|
1356
|
+
getUserContext() {
|
|
1357
|
+
return {
|
|
1358
|
+
userId: this.userId,
|
|
1359
|
+
properties: { ...this.userProperties }
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Check if specific event type was tracked
|
|
1364
|
+
*
|
|
1365
|
+
* @param type - Event type to check
|
|
1366
|
+
* @returns Whether event type was tracked
|
|
1367
|
+
*/
|
|
1368
|
+
hasTracked(type) {
|
|
1369
|
+
return this.eventQueue.some((event) => event.type === type);
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Get last tracked event
|
|
1373
|
+
*
|
|
1374
|
+
* @returns Last event or undefined
|
|
1375
|
+
*/
|
|
1376
|
+
getLastEvent() {
|
|
1377
|
+
return this.eventQueue[this.eventQueue.length - 1];
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Reset all state
|
|
1381
|
+
*/
|
|
1382
|
+
reset() {
|
|
1383
|
+
this.eventQueue = [];
|
|
1384
|
+
this.flushedEvents = [];
|
|
1385
|
+
this.userId = null;
|
|
1386
|
+
this.userProperties = {};
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Clear only the event queue (keep flushed history)
|
|
1390
|
+
*/
|
|
1391
|
+
clearQueue() {
|
|
1392
|
+
this.eventQueue = [];
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
// src/network/mock.ts
|
|
1397
|
+
var MockNetworkAdapter = class {
|
|
1398
|
+
constructor(options = {}) {
|
|
1399
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
1400
|
+
this.currentType = options.initialType ?? "wifi";
|
|
1401
|
+
this.downlink = options.initialDownlink ?? 10;
|
|
1402
|
+
this.rtt = options.initialRtt ?? 50;
|
|
1403
|
+
this.metered = options.isMetered ?? false;
|
|
1404
|
+
this.online = options.isOnline ?? true;
|
|
1405
|
+
}
|
|
1406
|
+
async getNetworkType() {
|
|
1407
|
+
return this.currentType;
|
|
1408
|
+
}
|
|
1409
|
+
async getNetworkQuality() {
|
|
1410
|
+
return {
|
|
1411
|
+
type: this.currentType,
|
|
1412
|
+
downlink: this.downlink,
|
|
1413
|
+
rtt: this.rtt,
|
|
1414
|
+
isMetered: this.metered
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
onNetworkChange(callback) {
|
|
1418
|
+
this.listeners.add(callback);
|
|
1419
|
+
return () => {
|
|
1420
|
+
this.listeners.delete(callback);
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
async isOnline() {
|
|
1424
|
+
return this.online;
|
|
1425
|
+
}
|
|
1426
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1427
|
+
// MOCK CONTROL METHODS (for testing)
|
|
1428
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1429
|
+
/**
|
|
1430
|
+
* Set network type and notify listeners
|
|
1431
|
+
*/
|
|
1432
|
+
setNetworkType(type) {
|
|
1433
|
+
if (this.currentType !== type) {
|
|
1434
|
+
this.currentType = type;
|
|
1435
|
+
this.notifyListeners();
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Set downlink speed in Mbps
|
|
1440
|
+
*/
|
|
1441
|
+
setDownlink(mbps) {
|
|
1442
|
+
this.downlink = mbps;
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Set RTT in milliseconds
|
|
1446
|
+
*/
|
|
1447
|
+
setRtt(ms) {
|
|
1448
|
+
this.rtt = ms;
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Set metered status (e.g., cellular data)
|
|
1452
|
+
*/
|
|
1453
|
+
setMetered(metered) {
|
|
1454
|
+
this.metered = metered;
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Set online/offline status
|
|
1458
|
+
*/
|
|
1459
|
+
setOnline(online) {
|
|
1460
|
+
this.online = online;
|
|
1461
|
+
if (!online) {
|
|
1462
|
+
this.currentType = "offline";
|
|
1463
|
+
this.notifyListeners();
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Simulate network change sequence for testing
|
|
1468
|
+
*/
|
|
1469
|
+
async simulateNetworkChanges(sequence, delayMs = 100) {
|
|
1470
|
+
for (const type of sequence) {
|
|
1471
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1472
|
+
this.setNetworkType(type);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Reset to default state
|
|
1477
|
+
*/
|
|
1478
|
+
reset() {
|
|
1479
|
+
this.currentType = "wifi";
|
|
1480
|
+
this.downlink = 10;
|
|
1481
|
+
this.rtt = 50;
|
|
1482
|
+
this.metered = false;
|
|
1483
|
+
this.online = true;
|
|
1484
|
+
this.listeners.clear();
|
|
1485
|
+
}
|
|
1486
|
+
notifyListeners() {
|
|
1487
|
+
for (const listener of this.listeners) {
|
|
1488
|
+
listener(this.currentType);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
};
|
|
1492
|
+
|
|
1493
|
+
// src/video-loader/mock.ts
|
|
1494
|
+
var MockVideoLoader = class {
|
|
1495
|
+
constructor(options = {}) {
|
|
1496
|
+
this.preloads = /* @__PURE__ */ new Map();
|
|
1497
|
+
this.preloadDelayMs = options.preloadDelayMs ?? 100;
|
|
1498
|
+
this.shouldSucceed = options.shouldSucceed ?? true;
|
|
1499
|
+
this.errorMessage = options.errorMessage ?? "Mock preload failed";
|
|
1500
|
+
this.bytesPerPreload = options.bytesPerPreload ?? 5e5;
|
|
1501
|
+
}
|
|
1502
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1503
|
+
// IVideoLoader Implementation
|
|
1504
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1505
|
+
async preload(_videoId, _source, _config) {
|
|
1506
|
+
const abortController = new AbortController();
|
|
1507
|
+
this.preloads.set(_videoId, {
|
|
1508
|
+
status: "loading",
|
|
1509
|
+
loadedBytes: 0,
|
|
1510
|
+
abortController
|
|
1511
|
+
});
|
|
1512
|
+
try {
|
|
1513
|
+
await new Promise((resolve, reject) => {
|
|
1514
|
+
const timeoutId = setTimeout(resolve, this.preloadDelayMs);
|
|
1515
|
+
abortController.signal.addEventListener("abort", () => {
|
|
1516
|
+
clearTimeout(timeoutId);
|
|
1517
|
+
reject(new Error("Preload cancelled"));
|
|
1518
|
+
});
|
|
1519
|
+
});
|
|
1520
|
+
if (abortController.signal.aborted) {
|
|
1521
|
+
return {
|
|
1522
|
+
videoId: _videoId,
|
|
1523
|
+
status: "idle",
|
|
1524
|
+
loadedBytes: 0
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
if (this.shouldSucceed) {
|
|
1528
|
+
this.preloads.set(_videoId, {
|
|
1529
|
+
status: "ready",
|
|
1530
|
+
loadedBytes: this.bytesPerPreload
|
|
1531
|
+
});
|
|
1532
|
+
return {
|
|
1533
|
+
videoId: _videoId,
|
|
1534
|
+
status: "ready",
|
|
1535
|
+
loadedBytes: this.bytesPerPreload
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
const error = new Error(this.errorMessage);
|
|
1539
|
+
this.preloads.set(_videoId, {
|
|
1540
|
+
status: "error",
|
|
1541
|
+
loadedBytes: 0,
|
|
1542
|
+
error
|
|
1543
|
+
});
|
|
1544
|
+
return {
|
|
1545
|
+
videoId: _videoId,
|
|
1546
|
+
status: "error",
|
|
1547
|
+
error
|
|
1548
|
+
};
|
|
1549
|
+
} catch (e) {
|
|
1550
|
+
if (e instanceof Error && e.message === "Preload cancelled") {
|
|
1551
|
+
this.preloads.set(_videoId, {
|
|
1552
|
+
status: "idle",
|
|
1553
|
+
loadedBytes: 0
|
|
1554
|
+
});
|
|
1555
|
+
return {
|
|
1556
|
+
videoId: _videoId,
|
|
1557
|
+
status: "idle",
|
|
1558
|
+
loadedBytes: 0
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
throw e;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
cancelPreload(videoId) {
|
|
1565
|
+
const state = this.preloads.get(videoId);
|
|
1566
|
+
if (state?.abortController) {
|
|
1567
|
+
state.abortController.abort();
|
|
1568
|
+
}
|
|
1569
|
+
this.preloads.set(videoId, {
|
|
1570
|
+
status: "idle",
|
|
1571
|
+
loadedBytes: 0
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
isPreloaded(videoId) {
|
|
1575
|
+
return this.preloads.get(videoId)?.status === "ready";
|
|
1576
|
+
}
|
|
1577
|
+
getPreloadStatus(videoId) {
|
|
1578
|
+
return this.preloads.get(videoId)?.status ?? "idle";
|
|
1579
|
+
}
|
|
1580
|
+
clearPreload(videoId) {
|
|
1581
|
+
this.preloads.delete(videoId);
|
|
1582
|
+
}
|
|
1583
|
+
clearAll() {
|
|
1584
|
+
this.preloads.clear();
|
|
1585
|
+
}
|
|
1586
|
+
getTotalPreloadedBytes() {
|
|
1587
|
+
let total = 0;
|
|
1588
|
+
for (const state of this.preloads.values()) {
|
|
1589
|
+
if (state.status === "ready") {
|
|
1590
|
+
total += state.loadedBytes;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
return total;
|
|
1594
|
+
}
|
|
1595
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1596
|
+
// MOCK CONTROL METHODS (for testing)
|
|
1597
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1598
|
+
/**
|
|
1599
|
+
* Set whether preloads should succeed
|
|
1600
|
+
*/
|
|
1601
|
+
setShouldSucceed(shouldSucceed) {
|
|
1602
|
+
this.shouldSucceed = shouldSucceed;
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Set preload delay in milliseconds
|
|
1606
|
+
*/
|
|
1607
|
+
setPreloadDelay(ms) {
|
|
1608
|
+
this.preloadDelayMs = ms;
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Set error message for failed preloads
|
|
1612
|
+
*/
|
|
1613
|
+
setErrorMessage(message) {
|
|
1614
|
+
this.errorMessage = message;
|
|
1615
|
+
}
|
|
1616
|
+
/**
|
|
1617
|
+
* Get all tracked preloads (for testing assertions)
|
|
1618
|
+
*/
|
|
1619
|
+
getPreloads() {
|
|
1620
|
+
return new Map(this.preloads);
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Reset all state
|
|
1624
|
+
*/
|
|
1625
|
+
reset() {
|
|
1626
|
+
this.preloads.clear();
|
|
1627
|
+
this.shouldSucceed = true;
|
|
1628
|
+
this.preloadDelayMs = 100;
|
|
1629
|
+
this.bytesPerPreload = 5e5;
|
|
1630
|
+
this.errorMessage = "Mock preload failed";
|
|
1631
|
+
}
|
|
1632
|
+
};
|
|
1633
|
+
var MockPosterLoader = class {
|
|
1634
|
+
constructor(preloadDelayMs = 50) {
|
|
1635
|
+
this.posterCache = /* @__PURE__ */ new Set();
|
|
1636
|
+
this.preloadDelayMs = preloadDelayMs;
|
|
1637
|
+
}
|
|
1638
|
+
async preload(url) {
|
|
1639
|
+
await new Promise((resolve) => setTimeout(resolve, this.preloadDelayMs));
|
|
1640
|
+
this.posterCache.add(url);
|
|
1641
|
+
}
|
|
1642
|
+
isCached(url) {
|
|
1643
|
+
return this.posterCache.has(url);
|
|
1644
|
+
}
|
|
1645
|
+
clearCache() {
|
|
1646
|
+
this.posterCache.clear();
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* Set preload delay in milliseconds
|
|
1650
|
+
*/
|
|
1651
|
+
setPreloadDelay(ms) {
|
|
1652
|
+
this.preloadDelayMs = ms;
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Reset all state
|
|
1656
|
+
*/
|
|
1657
|
+
reset() {
|
|
1658
|
+
this.posterCache.clear();
|
|
1659
|
+
this.preloadDelayMs = 50;
|
|
1660
|
+
}
|
|
1661
|
+
};
|
|
1662
|
+
|
|
1663
|
+
// src/preset/adapters/RESTAnalyticsAdapter.ts
|
|
1664
|
+
var DEFAULT_BATCH_SIZE = 10;
|
|
1665
|
+
var DEFAULT_FLUSH_INTERVAL = 3e4;
|
|
1666
|
+
var RESTAnalyticsAdapter = class {
|
|
1667
|
+
constructor(config) {
|
|
1668
|
+
// Event queue
|
|
1669
|
+
this.queue = [];
|
|
1670
|
+
// Flush interval timer
|
|
1671
|
+
this.flushTimer = null;
|
|
1672
|
+
// User context
|
|
1673
|
+
this.userId = null;
|
|
1674
|
+
this.userProperties = {};
|
|
1675
|
+
this.httpClient = config.httpClient;
|
|
1676
|
+
this.endpoint = config.endpoints.batch;
|
|
1677
|
+
this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
1678
|
+
this.logger = config.logger;
|
|
1679
|
+
const interval = config.flushInterval ?? DEFAULT_FLUSH_INTERVAL;
|
|
1680
|
+
this.flushTimer = setInterval(() => this.flush(), interval);
|
|
1681
|
+
}
|
|
1682
|
+
/**
|
|
1683
|
+
* Track an analytics event
|
|
1684
|
+
* Fire-and-forget - doesn't throw
|
|
1685
|
+
*/
|
|
1686
|
+
track(event) {
|
|
1687
|
+
try {
|
|
1688
|
+
const enrichedEvent = {
|
|
1689
|
+
...event,
|
|
1690
|
+
userId: this.userId,
|
|
1691
|
+
userProperties: this.userProperties
|
|
1692
|
+
};
|
|
1693
|
+
this.queue.push(enrichedEvent);
|
|
1694
|
+
if (this.queue.length >= this.batchSize) {
|
|
1695
|
+
this.flush().catch(() => {
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
} catch (error) {
|
|
1699
|
+
this.logger?.warn("[RESTAnalyticsAdapter] Failed to track event", { error });
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
/**
|
|
1703
|
+
* Flush queued events
|
|
1704
|
+
*/
|
|
1705
|
+
async flush() {
|
|
1706
|
+
if (this.queue.length === 0) {
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
const events = [...this.queue];
|
|
1710
|
+
this.queue = [];
|
|
1711
|
+
try {
|
|
1712
|
+
if (this.trySendBeacon(events)) {
|
|
1713
|
+
this.logger?.debug("[RESTAnalyticsAdapter] Events sent via sendBeacon", {
|
|
1714
|
+
count: events.length
|
|
1715
|
+
});
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
await this.httpClient.request({
|
|
1719
|
+
method: "POST",
|
|
1720
|
+
path: this.endpoint,
|
|
1721
|
+
body: { events }
|
|
1722
|
+
});
|
|
1723
|
+
this.logger?.debug("[RESTAnalyticsAdapter] Events sent via HTTP", {
|
|
1724
|
+
count: events.length
|
|
1725
|
+
});
|
|
1726
|
+
} catch (error) {
|
|
1727
|
+
if (this.queue.length < this.batchSize * 3) {
|
|
1728
|
+
this.queue = [...events, ...this.queue];
|
|
1729
|
+
}
|
|
1730
|
+
this.logger?.warn("[RESTAnalyticsAdapter] Failed to flush events", { error });
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Track video view duration (heartbeat)
|
|
1735
|
+
*/
|
|
1736
|
+
trackViewDuration(videoId, duration, totalDuration) {
|
|
1737
|
+
this.track({
|
|
1738
|
+
type: "video_view",
|
|
1739
|
+
videoId,
|
|
1740
|
+
timestamp: Date.now(),
|
|
1741
|
+
data: {
|
|
1742
|
+
watchTime: duration,
|
|
1743
|
+
totalDuration,
|
|
1744
|
+
percentage: totalDuration > 0 ? Math.round(duration / totalDuration * 100) : 0
|
|
1745
|
+
}
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* Track video completion
|
|
1750
|
+
*/
|
|
1751
|
+
trackCompletion(videoId, watchTime, loops) {
|
|
1752
|
+
this.track({
|
|
1753
|
+
type: "video_complete",
|
|
1754
|
+
videoId,
|
|
1755
|
+
timestamp: Date.now(),
|
|
1756
|
+
data: {
|
|
1757
|
+
watchTime,
|
|
1758
|
+
loops
|
|
1759
|
+
}
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
/**
|
|
1763
|
+
* Set user context
|
|
1764
|
+
*/
|
|
1765
|
+
setUser(userId, properties) {
|
|
1766
|
+
this.userId = userId;
|
|
1767
|
+
this.userProperties = properties ?? {};
|
|
1768
|
+
}
|
|
1769
|
+
/**
|
|
1770
|
+
* Get current queue size (for debugging)
|
|
1771
|
+
*/
|
|
1772
|
+
getQueueSize() {
|
|
1773
|
+
return this.queue.length;
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Cleanup - stop flush interval
|
|
1777
|
+
*/
|
|
1778
|
+
destroy() {
|
|
1779
|
+
if (this.flushTimer) {
|
|
1780
|
+
clearInterval(this.flushTimer);
|
|
1781
|
+
this.flushTimer = null;
|
|
1782
|
+
}
|
|
1783
|
+
this.flush().catch(() => {
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Try to send via sendBeacon (for reliability on page unload)
|
|
1788
|
+
*/
|
|
1789
|
+
trySendBeacon(events) {
|
|
1790
|
+
if (typeof navigator === "undefined" || !navigator.sendBeacon) {
|
|
1791
|
+
return false;
|
|
1792
|
+
}
|
|
1793
|
+
try {
|
|
1794
|
+
const url = this.buildFullUrl();
|
|
1795
|
+
const data = JSON.stringify({ events });
|
|
1796
|
+
const blob = new Blob([data], { type: "application/json" });
|
|
1797
|
+
return navigator.sendBeacon(url, blob);
|
|
1798
|
+
} catch {
|
|
1799
|
+
return false;
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* Build full URL for sendBeacon
|
|
1804
|
+
*/
|
|
1805
|
+
buildFullUrl() {
|
|
1806
|
+
return this.endpoint.startsWith("http") ? this.endpoint : `${window.location.origin}${this.endpoint}`;
|
|
1807
|
+
}
|
|
1808
|
+
};
|
|
1809
|
+
function createNoOpAnalyticsAdapter() {
|
|
1810
|
+
return {
|
|
1811
|
+
track: () => {
|
|
1812
|
+
},
|
|
1813
|
+
flush: async () => {
|
|
1814
|
+
},
|
|
1815
|
+
trackViewDuration: () => {
|
|
1816
|
+
},
|
|
1817
|
+
trackCompletion: () => {
|
|
1818
|
+
},
|
|
1819
|
+
setUser: () => {
|
|
1820
|
+
},
|
|
1821
|
+
getQueueSize: () => 0
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// src/preset/adapters/RESTDataAdapter.ts
|
|
1826
|
+
var RESTDataAdapter = class {
|
|
1827
|
+
constructor(config) {
|
|
1828
|
+
this.httpClient = config.httpClient;
|
|
1829
|
+
this.endpoints = config.endpoints;
|
|
1830
|
+
this.transforms = config.transforms;
|
|
1831
|
+
this.pagination = config.pagination ?? { cursor: "cursor", limit: "limit" };
|
|
1832
|
+
this.logger = config.logger;
|
|
1833
|
+
}
|
|
1834
|
+
/**
|
|
1835
|
+
* Fetch feed with pagination
|
|
1836
|
+
*/
|
|
1837
|
+
async fetchFeed(cursor) {
|
|
1838
|
+
try {
|
|
1839
|
+
const params = {
|
|
1840
|
+
[this.pagination.cursor]: cursor ?? null,
|
|
1841
|
+
[this.pagination.limit]: 10
|
|
1842
|
+
};
|
|
1843
|
+
const response = await this.httpClient.request({
|
|
1844
|
+
method: "GET",
|
|
1845
|
+
path: this.endpoints.list,
|
|
1846
|
+
params
|
|
1847
|
+
});
|
|
1848
|
+
const feedData = this.transforms.feedResponse(response);
|
|
1849
|
+
const items = feedData.items.map((item) => {
|
|
1850
|
+
try {
|
|
1851
|
+
return this.transforms.videoItem(item);
|
|
1852
|
+
} catch (error) {
|
|
1853
|
+
this.logger?.error("[RESTDataAdapter] Failed to transform video item", error);
|
|
1854
|
+
return this.createFallbackVideoItem(item);
|
|
1855
|
+
}
|
|
1856
|
+
});
|
|
1857
|
+
return {
|
|
1858
|
+
items,
|
|
1859
|
+
nextCursor: feedData.nextCursor,
|
|
1860
|
+
hasMore: feedData.hasMore
|
|
1861
|
+
};
|
|
1862
|
+
} catch (error) {
|
|
1863
|
+
this.logger?.error("[RESTDataAdapter] fetchFeed failed", error);
|
|
1864
|
+
throw error;
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
/**
|
|
1868
|
+
* Get video detail by ID
|
|
1869
|
+
*/
|
|
1870
|
+
async getVideoDetail(id) {
|
|
1871
|
+
try {
|
|
1872
|
+
const response = await this.httpClient.request({
|
|
1873
|
+
method: "GET",
|
|
1874
|
+
path: this.endpoints.detail,
|
|
1875
|
+
pathParams: { id }
|
|
1876
|
+
});
|
|
1877
|
+
const videoData = this.unwrapResponse(response);
|
|
1878
|
+
return this.transforms.videoItem(videoData);
|
|
1879
|
+
} catch (error) {
|
|
1880
|
+
this.logger?.error("[RESTDataAdapter] getVideoDetail failed", error);
|
|
1881
|
+
throw error;
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Optional: Prefetch videos
|
|
1886
|
+
* This is a no-op by default, can be overridden if API supports batch fetch
|
|
1887
|
+
*/
|
|
1888
|
+
async prefetch(ids) {
|
|
1889
|
+
this.logger?.debug("[RESTDataAdapter] prefetch called", { ids });
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Unwrap response if wrapped in data/result field
|
|
1893
|
+
*/
|
|
1894
|
+
unwrapResponse(response) {
|
|
1895
|
+
if (!response || typeof response !== "object") {
|
|
1896
|
+
return response;
|
|
1897
|
+
}
|
|
1898
|
+
const obj = response;
|
|
1899
|
+
if (obj.data !== void 0) return obj.data;
|
|
1900
|
+
if (obj.result !== void 0) return obj.result;
|
|
1901
|
+
if (obj.video !== void 0) return obj.video;
|
|
1902
|
+
if (obj.item !== void 0) return obj.item;
|
|
1903
|
+
return response;
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Create fallback video item when transform fails
|
|
1907
|
+
*/
|
|
1908
|
+
createFallbackVideoItem(data) {
|
|
1909
|
+
const obj = data ?? {};
|
|
1910
|
+
return {
|
|
1911
|
+
id: String(obj.id ?? obj.video_id ?? `fallback-${Date.now()}`),
|
|
1912
|
+
source: {
|
|
1913
|
+
url: String(obj.video_url ?? obj.url ?? ""),
|
|
1914
|
+
type: "mp4"
|
|
1915
|
+
},
|
|
1916
|
+
duration: 0,
|
|
1917
|
+
author: {
|
|
1918
|
+
id: "unknown",
|
|
1919
|
+
name: "Unknown"
|
|
1920
|
+
},
|
|
1921
|
+
stats: {
|
|
1922
|
+
views: 0,
|
|
1923
|
+
likes: 0,
|
|
1924
|
+
comments: 0,
|
|
1925
|
+
shares: 0
|
|
1926
|
+
},
|
|
1927
|
+
isLiked: false,
|
|
1928
|
+
isFollowing: false,
|
|
1929
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
};
|
|
1933
|
+
|
|
1934
|
+
// src/preset/adapters/RESTInteractionAdapter.ts
|
|
1935
|
+
var RESTInteractionAdapter = class {
|
|
1936
|
+
constructor(config) {
|
|
1937
|
+
this.httpClient = config.httpClient;
|
|
1938
|
+
this.endpoints = config.endpoints;
|
|
1939
|
+
this.logger = config.logger;
|
|
1940
|
+
}
|
|
1941
|
+
/**
|
|
1942
|
+
* Like a video
|
|
1943
|
+
*/
|
|
1944
|
+
async like(videoId) {
|
|
1945
|
+
try {
|
|
1946
|
+
await this.httpClient.request({
|
|
1947
|
+
method: "POST",
|
|
1948
|
+
path: this.endpoints.like,
|
|
1949
|
+
pathParams: { id: videoId }
|
|
1950
|
+
});
|
|
1951
|
+
} catch (error) {
|
|
1952
|
+
this.logger?.error("[RESTInteractionAdapter] like failed", error);
|
|
1953
|
+
throw error;
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
/**
|
|
1957
|
+
* Unlike a video
|
|
1958
|
+
*/
|
|
1959
|
+
async unlike(videoId) {
|
|
1960
|
+
try {
|
|
1961
|
+
await this.httpClient.request({
|
|
1962
|
+
method: "DELETE",
|
|
1963
|
+
path: this.endpoints.unlike,
|
|
1964
|
+
pathParams: { id: videoId }
|
|
1965
|
+
});
|
|
1966
|
+
} catch (error) {
|
|
1967
|
+
this.logger?.error("[RESTInteractionAdapter] unlike failed", error);
|
|
1968
|
+
throw error;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
/**
|
|
1972
|
+
* Follow an author
|
|
1973
|
+
*/
|
|
1974
|
+
async follow(authorId) {
|
|
1975
|
+
try {
|
|
1976
|
+
await this.httpClient.request({
|
|
1977
|
+
method: "POST",
|
|
1978
|
+
path: this.endpoints.follow,
|
|
1979
|
+
pathParams: { id: authorId }
|
|
1980
|
+
});
|
|
1981
|
+
} catch (error) {
|
|
1982
|
+
this.logger?.error("[RESTInteractionAdapter] follow failed", error);
|
|
1983
|
+
throw error;
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Unfollow an author
|
|
1988
|
+
*/
|
|
1989
|
+
async unfollow(authorId) {
|
|
1990
|
+
try {
|
|
1991
|
+
await this.httpClient.request({
|
|
1992
|
+
method: "DELETE",
|
|
1993
|
+
path: this.endpoints.unfollow,
|
|
1994
|
+
pathParams: { id: authorId }
|
|
1995
|
+
});
|
|
1996
|
+
} catch (error) {
|
|
1997
|
+
this.logger?.error("[RESTInteractionAdapter] unfollow failed", error);
|
|
1998
|
+
throw error;
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
/**
|
|
2002
|
+
* Post a comment
|
|
2003
|
+
*/
|
|
2004
|
+
async comment(videoId, text) {
|
|
2005
|
+
try {
|
|
2006
|
+
const response = await this.httpClient.request({
|
|
2007
|
+
method: "POST",
|
|
2008
|
+
path: this.endpoints.comment,
|
|
2009
|
+
pathParams: { id: videoId },
|
|
2010
|
+
body: { text }
|
|
2011
|
+
});
|
|
2012
|
+
return this.transformComment(response);
|
|
2013
|
+
} catch (error) {
|
|
2014
|
+
this.logger?.error("[RESTInteractionAdapter] comment failed", error);
|
|
2015
|
+
throw error;
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
/**
|
|
2019
|
+
* Delete a comment
|
|
2020
|
+
*/
|
|
2021
|
+
async deleteComment(commentId) {
|
|
2022
|
+
try {
|
|
2023
|
+
await this.httpClient.request({
|
|
2024
|
+
method: "DELETE",
|
|
2025
|
+
path: this.endpoints.deleteComment,
|
|
2026
|
+
pathParams: { id: commentId }
|
|
2027
|
+
});
|
|
2028
|
+
} catch (error) {
|
|
2029
|
+
this.logger?.error("[RESTInteractionAdapter] deleteComment failed", error);
|
|
2030
|
+
throw error;
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
/**
|
|
2034
|
+
* Like a comment
|
|
2035
|
+
*/
|
|
2036
|
+
async likeComment(commentId) {
|
|
2037
|
+
this.logger?.debug("[RESTInteractionAdapter] likeComment not implemented", { commentId });
|
|
2038
|
+
}
|
|
2039
|
+
/**
|
|
2040
|
+
* Unlike a comment
|
|
2041
|
+
*/
|
|
2042
|
+
async unlikeComment(commentId) {
|
|
2043
|
+
this.logger?.debug("[RESTInteractionAdapter] unlikeComment not implemented", { commentId });
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Share a video (optional tracking)
|
|
2047
|
+
*/
|
|
2048
|
+
async share(videoId, platform) {
|
|
2049
|
+
if (!this.endpoints.share) {
|
|
2050
|
+
this.logger?.debug("[RESTInteractionAdapter] share endpoint not configured");
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
try {
|
|
2054
|
+
await this.httpClient.request({
|
|
2055
|
+
method: "POST",
|
|
2056
|
+
path: this.endpoints.share,
|
|
2057
|
+
pathParams: { id: videoId },
|
|
2058
|
+
body: platform ? { platform } : void 0
|
|
2059
|
+
});
|
|
2060
|
+
} catch (error) {
|
|
2061
|
+
this.logger?.warn("[RESTInteractionAdapter] share tracking failed", { error });
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* Transform API comment response to Comment type
|
|
2066
|
+
*/
|
|
2067
|
+
transformComment(response) {
|
|
2068
|
+
const obj = this.unwrapResponse(response);
|
|
2069
|
+
const user = obj.user ?? {};
|
|
2070
|
+
const author = obj.author ?? {};
|
|
2071
|
+
return {
|
|
2072
|
+
id: String(obj.id ?? obj.comment_id ?? ""),
|
|
2073
|
+
text: String(obj.text ?? obj.content ?? obj.body ?? ""),
|
|
2074
|
+
author: {
|
|
2075
|
+
id: String(user.id ?? author.id ?? obj.user_id ?? ""),
|
|
2076
|
+
name: String(user.display_name ?? user.name ?? author.name ?? "Unknown"),
|
|
2077
|
+
avatar: user.avatar ?? author.avatar
|
|
2078
|
+
},
|
|
2079
|
+
likes: Number(obj.like_count ?? obj.likes ?? 0),
|
|
2080
|
+
isLiked: Boolean(obj.is_liked ?? obj.liked ?? false),
|
|
2081
|
+
createdAt: String(obj.created_at ?? obj.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()),
|
|
2082
|
+
replyCount: Number(obj.reply_count ?? obj.replies ?? 0)
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
/**
|
|
2086
|
+
* Unwrap response if wrapped
|
|
2087
|
+
*/
|
|
2088
|
+
unwrapResponse(response) {
|
|
2089
|
+
if (!response || typeof response !== "object") return response;
|
|
2090
|
+
const obj = response;
|
|
2091
|
+
if (obj.data !== void 0) return obj.data;
|
|
2092
|
+
if (obj.comment !== void 0) return obj.comment;
|
|
2093
|
+
if (obj.result !== void 0) return obj.result;
|
|
2094
|
+
return response;
|
|
2095
|
+
}
|
|
2096
|
+
};
|
|
2097
|
+
|
|
2098
|
+
// src/preset/types.ts
|
|
2099
|
+
var DEFAULT_RETRY_CONFIG = {
|
|
2100
|
+
maxRetries: 3,
|
|
2101
|
+
retryDelay: 1e3,
|
|
2102
|
+
retryOn: [408, 429, 500, 502, 503, 504],
|
|
2103
|
+
exponentialBackoff: true
|
|
2104
|
+
};
|
|
2105
|
+
var DEFAULT_REQUEST_CONFIG = {
|
|
2106
|
+
defaultParams: {},
|
|
2107
|
+
timeout: 1e4,
|
|
2108
|
+
retry: DEFAULT_RETRY_CONFIG,
|
|
2109
|
+
pagination: {
|
|
2110
|
+
cursor: "cursor",
|
|
2111
|
+
limit: "limit"
|
|
2112
|
+
}
|
|
2113
|
+
};
|
|
2114
|
+
|
|
2115
|
+
// src/preset/http/client.ts
|
|
2116
|
+
var RetryableError = class extends Error {
|
|
2117
|
+
constructor(status, message) {
|
|
2118
|
+
super(message);
|
|
2119
|
+
this.status = status;
|
|
2120
|
+
this.name = "RetryableError";
|
|
2121
|
+
}
|
|
2122
|
+
};
|
|
2123
|
+
var HttpError = class extends Error {
|
|
2124
|
+
constructor(status, message, body) {
|
|
2125
|
+
super(message);
|
|
2126
|
+
this.status = status;
|
|
2127
|
+
this.body = body;
|
|
2128
|
+
this.name = "HttpError";
|
|
2129
|
+
}
|
|
2130
|
+
};
|
|
2131
|
+
var HttpClient = class {
|
|
2132
|
+
constructor(config) {
|
|
2133
|
+
// Token refresh deduplication
|
|
2134
|
+
this.isRefreshing = false;
|
|
2135
|
+
this.refreshPromise = null;
|
|
2136
|
+
this.config = config;
|
|
2137
|
+
const mergedRetry = {
|
|
2138
|
+
maxRetries: config.request?.retry?.maxRetries ?? DEFAULT_RETRY_CONFIG.maxRetries,
|
|
2139
|
+
retryDelay: config.request?.retry?.retryDelay ?? DEFAULT_RETRY_CONFIG.retryDelay,
|
|
2140
|
+
retryOn: config.request?.retry?.retryOn ?? DEFAULT_RETRY_CONFIG.retryOn,
|
|
2141
|
+
exponentialBackoff: config.request?.retry?.exponentialBackoff ?? DEFAULT_RETRY_CONFIG.exponentialBackoff
|
|
2142
|
+
};
|
|
2143
|
+
this.requestConfig = {
|
|
2144
|
+
defaultParams: config.request?.defaultParams ?? DEFAULT_REQUEST_CONFIG.defaultParams,
|
|
2145
|
+
timeout: config.request?.timeout ?? DEFAULT_REQUEST_CONFIG.timeout,
|
|
2146
|
+
retry: mergedRetry,
|
|
2147
|
+
pagination: {
|
|
2148
|
+
cursor: config.request?.pagination?.cursor ?? DEFAULT_REQUEST_CONFIG.pagination.cursor,
|
|
2149
|
+
limit: config.request?.pagination?.limit ?? DEFAULT_REQUEST_CONFIG.pagination.limit
|
|
2150
|
+
}
|
|
2151
|
+
};
|
|
2152
|
+
this.retryConfig = mergedRetry;
|
|
2153
|
+
}
|
|
2154
|
+
/**
|
|
2155
|
+
* Make HTTP request with auth and retry
|
|
2156
|
+
*/
|
|
2157
|
+
async request(options) {
|
|
2158
|
+
const { method, path, params, body, pathParams } = options;
|
|
2159
|
+
let url = this.buildUrl(path, pathParams);
|
|
2160
|
+
if (params) {
|
|
2161
|
+
url = this.appendQueryParams(url, params);
|
|
2162
|
+
}
|
|
2163
|
+
const headers = await this.buildHeaders();
|
|
2164
|
+
return this.executeWithRetry({ url, method, headers, body });
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* Build full URL from path and path params
|
|
2168
|
+
*/
|
|
2169
|
+
buildUrl(path, pathParams) {
|
|
2170
|
+
let finalPath = path;
|
|
2171
|
+
if (pathParams) {
|
|
2172
|
+
for (const [key, value] of Object.entries(pathParams)) {
|
|
2173
|
+
finalPath = finalPath.replace(`:${key}`, encodeURIComponent(value));
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
return `${this.config.baseUrl}${finalPath}`;
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Append query params to URL
|
|
2180
|
+
*/
|
|
2181
|
+
appendQueryParams(url, params) {
|
|
2182
|
+
const searchParams = new URLSearchParams();
|
|
2183
|
+
for (const [key, value] of Object.entries(this.requestConfig.defaultParams)) {
|
|
2184
|
+
searchParams.set(key, value);
|
|
2185
|
+
}
|
|
2186
|
+
for (const [key, value] of Object.entries(params)) {
|
|
2187
|
+
if (value != null) {
|
|
2188
|
+
searchParams.set(key, String(value));
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
const queryString = searchParams.toString();
|
|
2192
|
+
return queryString ? `${url}?${queryString}` : url;
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* Build headers with auth token
|
|
2196
|
+
*/
|
|
2197
|
+
async buildHeaders() {
|
|
2198
|
+
const headers = {
|
|
2199
|
+
"Content-Type": "application/json",
|
|
2200
|
+
Accept: "application/json",
|
|
2201
|
+
...this.config.auth.headers
|
|
2202
|
+
};
|
|
2203
|
+
const token = await this.config.auth.getAccessToken();
|
|
2204
|
+
if (token) {
|
|
2205
|
+
const headerName = this.config.auth.tokenHeader ?? "Authorization";
|
|
2206
|
+
const prefix = this.config.auth.tokenPrefix ?? "Bearer";
|
|
2207
|
+
headers[headerName] = prefix ? `${prefix} ${token}` : token;
|
|
2208
|
+
}
|
|
2209
|
+
return headers;
|
|
2210
|
+
}
|
|
2211
|
+
/**
|
|
2212
|
+
* Execute request with retry logic
|
|
2213
|
+
*/
|
|
2214
|
+
async executeWithRetry(request) {
|
|
2215
|
+
let lastError = null;
|
|
2216
|
+
for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
|
|
2217
|
+
try {
|
|
2218
|
+
const response = await this.doFetch(request);
|
|
2219
|
+
return await this.handleResponse(response, request);
|
|
2220
|
+
} catch (error) {
|
|
2221
|
+
lastError = error;
|
|
2222
|
+
if (this.shouldRetry(error, attempt)) {
|
|
2223
|
+
await this.waitBeforeRetry(attempt, request.url, error);
|
|
2224
|
+
continue;
|
|
2225
|
+
}
|
|
2226
|
+
throw error;
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
throw lastError ?? new Error("Request failed");
|
|
2230
|
+
}
|
|
2231
|
+
/**
|
|
2232
|
+
* Handle response status and parse result
|
|
2233
|
+
*/
|
|
2234
|
+
async handleResponse(response, request) {
|
|
2235
|
+
if (response.status === 401) {
|
|
2236
|
+
return this.handleUnauthorized(request);
|
|
2237
|
+
}
|
|
2238
|
+
if (this.retryConfig.retryOn.includes(response.status)) {
|
|
2239
|
+
const errorText = await response.text();
|
|
2240
|
+
throw new RetryableError(response.status, errorText);
|
|
2241
|
+
}
|
|
2242
|
+
if (!response.ok) {
|
|
2243
|
+
const errorBody = await this.safeParseJson(response);
|
|
2244
|
+
throw new HttpError(
|
|
2245
|
+
response.status,
|
|
2246
|
+
`Request failed with status ${response.status}`,
|
|
2247
|
+
errorBody
|
|
2248
|
+
);
|
|
2249
|
+
}
|
|
2250
|
+
return this.parseResponse(response);
|
|
2251
|
+
}
|
|
2252
|
+
/**
|
|
2253
|
+
* Handle 401 unauthorized - attempt token refresh
|
|
2254
|
+
*/
|
|
2255
|
+
async handleUnauthorized(request) {
|
|
2256
|
+
const newToken = await this.refreshTokenIfNeeded();
|
|
2257
|
+
if (newToken) {
|
|
2258
|
+
this.updateAuthHeader(request.headers, newToken);
|
|
2259
|
+
const retryResponse = await this.doFetch(request);
|
|
2260
|
+
return this.parseResponse(retryResponse);
|
|
2261
|
+
}
|
|
2262
|
+
this.config.auth.onAuthError?.({ status: 401, message: "Authentication failed" });
|
|
2263
|
+
throw new HttpError(401, "Authentication failed");
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Update auth header with new token
|
|
2267
|
+
*/
|
|
2268
|
+
updateAuthHeader(headers, token) {
|
|
2269
|
+
const headerName = this.config.auth.tokenHeader ?? "Authorization";
|
|
2270
|
+
const prefix = this.config.auth.tokenPrefix ?? "Bearer";
|
|
2271
|
+
headers[headerName] = prefix ? `${prefix} ${token}` : token;
|
|
2272
|
+
}
|
|
2273
|
+
/**
|
|
2274
|
+
* Check if error should trigger retry
|
|
2275
|
+
*/
|
|
2276
|
+
shouldRetry(error, attempt) {
|
|
2277
|
+
return error instanceof RetryableError && attempt < this.retryConfig.maxRetries;
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
2280
|
+
* Wait before retry with backoff
|
|
2281
|
+
*/
|
|
2282
|
+
async waitBeforeRetry(attempt, url, error) {
|
|
2283
|
+
const delay = this.retryConfig.exponentialBackoff ? this.retryConfig.retryDelay * 2 ** attempt : this.retryConfig.retryDelay;
|
|
2284
|
+
this.config.logger?.warn(
|
|
2285
|
+
`[HttpClient] Retrying (${attempt + 1}/${this.retryConfig.maxRetries}) after ${delay}ms`,
|
|
2286
|
+
{ url, status: error.status }
|
|
2287
|
+
);
|
|
2288
|
+
await this.sleep(delay);
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* Execute fetch with timeout
|
|
2292
|
+
*/
|
|
2293
|
+
async doFetch(request) {
|
|
2294
|
+
const controller = new AbortController();
|
|
2295
|
+
const timeoutId = setTimeout(() => controller.abort(), this.requestConfig.timeout);
|
|
2296
|
+
try {
|
|
2297
|
+
return await fetch(request.url, {
|
|
2298
|
+
method: request.method,
|
|
2299
|
+
headers: request.headers,
|
|
2300
|
+
body: request.body ? JSON.stringify(request.body) : void 0,
|
|
2301
|
+
signal: controller.signal
|
|
2302
|
+
});
|
|
2303
|
+
} catch (error) {
|
|
2304
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
2305
|
+
throw new HttpError(408, "Request timeout");
|
|
2306
|
+
}
|
|
2307
|
+
throw error;
|
|
2308
|
+
} finally {
|
|
2309
|
+
clearTimeout(timeoutId);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
/**
|
|
2313
|
+
* Parse response JSON
|
|
2314
|
+
*/
|
|
2315
|
+
async parseResponse(response) {
|
|
2316
|
+
const text = await response.text();
|
|
2317
|
+
if (!text) {
|
|
2318
|
+
return {};
|
|
2319
|
+
}
|
|
2320
|
+
try {
|
|
2321
|
+
return JSON.parse(text);
|
|
2322
|
+
} catch {
|
|
2323
|
+
return { data: text };
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
/**
|
|
2327
|
+
* Safely parse JSON from response
|
|
2328
|
+
*/
|
|
2329
|
+
async safeParseJson(response) {
|
|
2330
|
+
try {
|
|
2331
|
+
return await response.json();
|
|
2332
|
+
} catch {
|
|
2333
|
+
return null;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
/**
|
|
2337
|
+
* Refresh token with deduplication
|
|
2338
|
+
*/
|
|
2339
|
+
async refreshTokenIfNeeded() {
|
|
2340
|
+
if (!this.config.auth.refreshToken) {
|
|
2341
|
+
return null;
|
|
2342
|
+
}
|
|
2343
|
+
if (this.isRefreshing && this.refreshPromise) {
|
|
2344
|
+
return this.refreshPromise;
|
|
2345
|
+
}
|
|
2346
|
+
this.isRefreshing = true;
|
|
2347
|
+
this.refreshPromise = this.config.auth.refreshToken().finally(() => {
|
|
2348
|
+
this.isRefreshing = false;
|
|
2349
|
+
this.refreshPromise = null;
|
|
2350
|
+
});
|
|
2351
|
+
try {
|
|
2352
|
+
return await this.refreshPromise;
|
|
2353
|
+
} catch (error) {
|
|
2354
|
+
this.config.logger?.error("[HttpClient] Token refresh failed", error);
|
|
2355
|
+
return null;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
/**
|
|
2359
|
+
* Sleep utility
|
|
2360
|
+
*/
|
|
2361
|
+
sleep(ms) {
|
|
2362
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2363
|
+
}
|
|
2364
|
+
};
|
|
2365
|
+
|
|
2366
|
+
// src/preset/transforms/defaults.ts
|
|
2367
|
+
function getNestedValue(obj, path) {
|
|
2368
|
+
if (!obj || typeof obj !== "object") return void 0;
|
|
2369
|
+
const keys = path.split(".");
|
|
2370
|
+
let current = obj;
|
|
2371
|
+
for (const key of keys) {
|
|
2372
|
+
if (current === null || current === void 0) return void 0;
|
|
2373
|
+
if (typeof current !== "object") return void 0;
|
|
2374
|
+
current = current[key];
|
|
2375
|
+
}
|
|
2376
|
+
return current;
|
|
2377
|
+
}
|
|
2378
|
+
function tryFields(obj, ...paths) {
|
|
2379
|
+
for (const path of paths) {
|
|
2380
|
+
const value = getNestedValue(obj, path);
|
|
2381
|
+
if (value !== void 0 && value !== null) {
|
|
2382
|
+
return value;
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
return void 0;
|
|
2386
|
+
}
|
|
2387
|
+
function toSafeString(value, fallback = "") {
|
|
2388
|
+
if (value === null || value === void 0) return fallback ?? "";
|
|
2389
|
+
return String(value);
|
|
2390
|
+
}
|
|
2391
|
+
function toStringOrNull(value) {
|
|
2392
|
+
if (value === null || value === void 0 || value === "") return null;
|
|
2393
|
+
return String(value);
|
|
2394
|
+
}
|
|
2395
|
+
function toNumber(value, fallback = 0) {
|
|
2396
|
+
if (value === null || value === void 0) return fallback;
|
|
2397
|
+
const num = Number(value);
|
|
2398
|
+
return Number.isNaN(num) ? fallback : num;
|
|
2399
|
+
}
|
|
2400
|
+
function toBoolean(value, fallback = false) {
|
|
2401
|
+
if (value === null || value === void 0) return fallback;
|
|
2402
|
+
if (typeof value === "boolean") return value;
|
|
2403
|
+
if (typeof value === "string") {
|
|
2404
|
+
return value.toLowerCase() === "true" || value === "1";
|
|
2405
|
+
}
|
|
2406
|
+
return Boolean(value);
|
|
2407
|
+
}
|
|
2408
|
+
function defaultSourceTransform(data) {
|
|
2409
|
+
const obj = data;
|
|
2410
|
+
const url = toSafeString(
|
|
2411
|
+
tryFields(obj, "video_url", "url", "source_url", "playback_url", "stream_url"),
|
|
2412
|
+
""
|
|
2413
|
+
);
|
|
2414
|
+
const typeRaw = toSafeString(
|
|
2415
|
+
tryFields(obj, "video_type", "type", "source_type", "format"),
|
|
2416
|
+
"mp4"
|
|
2417
|
+
);
|
|
2418
|
+
const type = typeRaw.toLowerCase().includes("hls") ? "hls" : "mp4";
|
|
2419
|
+
return { url, type };
|
|
2420
|
+
}
|
|
2421
|
+
function defaultAuthorTransform(data) {
|
|
2422
|
+
const obj = data;
|
|
2423
|
+
const authorObj = tryFields(obj, "user", "author", "creator", "owner") ?? obj;
|
|
2424
|
+
const author = authorObj;
|
|
2425
|
+
return {
|
|
2426
|
+
id: toSafeString(tryFields(author, "id", "user_id", "author_id"), ""),
|
|
2427
|
+
name: toSafeString(
|
|
2428
|
+
tryFields(author, "display_name", "name", "username", "nickname"),
|
|
2429
|
+
"Unknown"
|
|
2430
|
+
),
|
|
2431
|
+
avatar: toSafeString(
|
|
2432
|
+
tryFields(author, "avatar", "avatar_url", "profile_picture", "photo"),
|
|
2433
|
+
void 0
|
|
2434
|
+
),
|
|
2435
|
+
isVerified: toBoolean(tryFields(author, "is_verified", "verified"), false)
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
function defaultStatsTransform(data) {
|
|
2439
|
+
const obj = data;
|
|
2440
|
+
const statsObj = tryFields(obj, "stats", "statistics", "metrics") ?? obj;
|
|
2441
|
+
const stats = statsObj;
|
|
2442
|
+
return {
|
|
2443
|
+
views: toNumber(tryFields(stats, "view_count", "views", "play_count"), 0),
|
|
2444
|
+
likes: toNumber(tryFields(stats, "like_count", "likes", "digg_count"), 0),
|
|
2445
|
+
comments: toNumber(tryFields(stats, "comment_count", "comments"), 0),
|
|
2446
|
+
shares: toNumber(tryFields(stats, "share_count", "shares"), 0)
|
|
2447
|
+
};
|
|
2448
|
+
}
|
|
2449
|
+
function defaultVideoItemTransform(apiResponse, fieldMap, logger) {
|
|
2450
|
+
const obj = apiResponse;
|
|
2451
|
+
const getMapped = (sdkField, ...fallbacks) => {
|
|
2452
|
+
if (fieldMap?.[sdkField]) {
|
|
2453
|
+
const mapped = getNestedValue(obj, fieldMap[sdkField]);
|
|
2454
|
+
if (mapped !== void 0) return mapped;
|
|
2455
|
+
}
|
|
2456
|
+
return tryFields(obj, ...fallbacks);
|
|
2457
|
+
};
|
|
2458
|
+
const id = toSafeString(getMapped("id", "id", "video_id", "_id"), "");
|
|
2459
|
+
const sourceUrl = toSafeString(
|
|
2460
|
+
getMapped("source.url", "video_url", "url", "source_url", "playback_url"),
|
|
2461
|
+
""
|
|
2462
|
+
);
|
|
2463
|
+
if (!id) {
|
|
2464
|
+
logger?.warn("[Transform] Missing required field: id", { data: obj });
|
|
2465
|
+
}
|
|
2466
|
+
if (!sourceUrl) {
|
|
2467
|
+
logger?.warn("[Transform] Missing required field: source.url", { data: obj });
|
|
2468
|
+
}
|
|
2469
|
+
const source = defaultSourceTransform(obj);
|
|
2470
|
+
const author = defaultAuthorTransform(obj);
|
|
2471
|
+
const stats = defaultStatsTransform(obj);
|
|
2472
|
+
const videoItem = {
|
|
2473
|
+
id,
|
|
2474
|
+
source,
|
|
2475
|
+
poster: toSafeString(
|
|
2476
|
+
getMapped("poster", "thumbnail_url", "poster", "thumbnail", "cover", "cover_url"),
|
|
2477
|
+
void 0
|
|
2478
|
+
),
|
|
2479
|
+
duration: toNumber(
|
|
2480
|
+
getMapped("duration", "duration", "duration_seconds", "length", "video_duration"),
|
|
2481
|
+
0
|
|
2482
|
+
),
|
|
2483
|
+
title: toSafeString(getMapped("title", "title", "description", "caption", "text"), void 0),
|
|
2484
|
+
author,
|
|
2485
|
+
stats,
|
|
2486
|
+
isLiked: toBoolean(getMapped("isLiked", "is_liked", "liked", "user_liked", "has_liked"), false),
|
|
2487
|
+
isFollowing: toBoolean(
|
|
2488
|
+
getMapped("isFollowing", "is_following", "following", "user_following"),
|
|
2489
|
+
false
|
|
2490
|
+
),
|
|
2491
|
+
createdAt: toSafeString(
|
|
2492
|
+
getMapped("createdAt", "created_at", "createdAt", "create_time", "timestamp"),
|
|
2493
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
2494
|
+
),
|
|
2495
|
+
hashtags: getMapped("hashtags", "hashtags", "tags", "hash_tags") ?? []
|
|
2496
|
+
};
|
|
2497
|
+
return videoItem;
|
|
2498
|
+
}
|
|
2499
|
+
function defaultFeedResponseTransform(apiResponse, fieldMap, logger) {
|
|
2500
|
+
const obj = apiResponse;
|
|
2501
|
+
const itemsPath = fieldMap?.items;
|
|
2502
|
+
let items;
|
|
2503
|
+
if (itemsPath) {
|
|
2504
|
+
items = getNestedValue(obj, itemsPath) ?? [];
|
|
2505
|
+
} else {
|
|
2506
|
+
items = tryFields(obj, "data", "items", "videos", "results", "list") ?? [];
|
|
2507
|
+
}
|
|
2508
|
+
if (!Array.isArray(items)) {
|
|
2509
|
+
logger?.warn("[Transform] Feed items is not an array", { data: obj });
|
|
2510
|
+
items = [];
|
|
2511
|
+
}
|
|
2512
|
+
const cursorPath = fieldMap?.nextCursor;
|
|
2513
|
+
let nextCursor;
|
|
2514
|
+
if (cursorPath) {
|
|
2515
|
+
nextCursor = toStringOrNull(getNestedValue(obj, cursorPath));
|
|
2516
|
+
} else {
|
|
2517
|
+
nextCursor = toStringOrNull(
|
|
2518
|
+
tryFields(
|
|
2519
|
+
obj,
|
|
2520
|
+
"next_cursor",
|
|
2521
|
+
"nextCursor",
|
|
2522
|
+
"cursor",
|
|
2523
|
+
"next_page",
|
|
2524
|
+
"pagination.next",
|
|
2525
|
+
"meta.next_cursor"
|
|
2526
|
+
)
|
|
2527
|
+
);
|
|
2528
|
+
}
|
|
2529
|
+
const hasMorePath = fieldMap?.hasMore;
|
|
2530
|
+
let hasMore;
|
|
2531
|
+
if (hasMorePath) {
|
|
2532
|
+
hasMore = toBoolean(getNestedValue(obj, hasMorePath), false);
|
|
2533
|
+
} else {
|
|
2534
|
+
const hasMoreValue = tryFields(
|
|
2535
|
+
obj,
|
|
2536
|
+
"has_more",
|
|
2537
|
+
"hasMore",
|
|
2538
|
+
"has_next",
|
|
2539
|
+
"hasNext",
|
|
2540
|
+
"pagination.has_more",
|
|
2541
|
+
"meta.has_more"
|
|
2542
|
+
);
|
|
2543
|
+
if (hasMoreValue !== void 0) {
|
|
2544
|
+
hasMore = toBoolean(hasMoreValue, false);
|
|
2545
|
+
} else {
|
|
2546
|
+
hasMore = nextCursor !== null;
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
return { items, nextCursor, hasMore };
|
|
2550
|
+
}
|
|
2551
|
+
function createTransforms(config, logger) {
|
|
2552
|
+
return {
|
|
2553
|
+
videoItem: config?.videoItem ? config.videoItem : (data) => defaultVideoItemTransform(data, config?.fieldMap?.video, logger),
|
|
2554
|
+
feedResponse: config?.feedResponse ? config.feedResponse : (data) => defaultFeedResponseTransform(data, config?.fieldMap?.feed, logger)
|
|
2555
|
+
};
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
// src/preset/createRESTAdapters.ts
|
|
2559
|
+
function validateConfig(config) {
|
|
2560
|
+
if (!config.baseUrl) {
|
|
2561
|
+
throw new Error("[createRESTAdapters] baseUrl is required");
|
|
2562
|
+
}
|
|
2563
|
+
if (!config.auth?.getAccessToken) {
|
|
2564
|
+
throw new Error("[createRESTAdapters] auth.getAccessToken is required");
|
|
2565
|
+
}
|
|
2566
|
+
if (!config.endpoints?.feed?.list) {
|
|
2567
|
+
throw new Error("[createRESTAdapters] endpoints.feed.list is required");
|
|
2568
|
+
}
|
|
2569
|
+
if (!config.endpoints?.feed?.detail) {
|
|
2570
|
+
throw new Error("[createRESTAdapters] endpoints.feed.detail is required");
|
|
2571
|
+
}
|
|
2572
|
+
const requiredInteractionEndpoints = [
|
|
2573
|
+
"like",
|
|
2574
|
+
"unlike",
|
|
2575
|
+
"follow",
|
|
2576
|
+
"unfollow",
|
|
2577
|
+
"comment",
|
|
2578
|
+
"deleteComment"
|
|
2579
|
+
];
|
|
2580
|
+
for (const endpoint of requiredInteractionEndpoints) {
|
|
2581
|
+
if (!config.endpoints.interaction?.[endpoint]) {
|
|
2582
|
+
throw new Error(`[createRESTAdapters] endpoints.interaction.${endpoint} is required`);
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
function createRESTAdapters(config) {
|
|
2587
|
+
validateConfig(config);
|
|
2588
|
+
const { baseUrl, auth, endpoints, transforms, request, logger } = config;
|
|
2589
|
+
const httpClient = new HttpClient({
|
|
2590
|
+
baseUrl,
|
|
2591
|
+
auth,
|
|
2592
|
+
request,
|
|
2593
|
+
logger
|
|
2594
|
+
});
|
|
2595
|
+
const resolvedTransforms = createTransforms(transforms, logger);
|
|
2596
|
+
const dataSource = new RESTDataAdapter({
|
|
2597
|
+
httpClient,
|
|
2598
|
+
endpoints: endpoints.feed,
|
|
2599
|
+
transforms: resolvedTransforms,
|
|
2600
|
+
pagination: request?.pagination,
|
|
2601
|
+
logger
|
|
2602
|
+
});
|
|
2603
|
+
const interaction = new RESTInteractionAdapter({
|
|
2604
|
+
httpClient,
|
|
2605
|
+
endpoints: endpoints.interaction,
|
|
2606
|
+
logger
|
|
2607
|
+
});
|
|
2608
|
+
const analytics = endpoints.analytics ? new RESTAnalyticsAdapter({
|
|
2609
|
+
httpClient,
|
|
2610
|
+
endpoints: endpoints.analytics,
|
|
2611
|
+
logger
|
|
2612
|
+
}) : createNoOpAnalyticsAdapter();
|
|
2613
|
+
return {
|
|
2614
|
+
dataSource,
|
|
2615
|
+
interaction,
|
|
2616
|
+
analytics
|
|
2617
|
+
};
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
// src/preset/adapters/BrowserVideoLoader.ts
|
|
2621
|
+
var DEFAULT_CONFIG = {
|
|
2622
|
+
defaultMaxBytes: 512 * 1024,
|
|
2623
|
+
// 512KB
|
|
2624
|
+
defaultTimeout: 1e4,
|
|
2625
|
+
useCache: true,
|
|
2626
|
+
cacheName: "sv-video-cache"
|
|
2627
|
+
};
|
|
2628
|
+
var BrowserVideoLoader = class {
|
|
2629
|
+
constructor(config = {}) {
|
|
2630
|
+
this.preloads = /* @__PURE__ */ new Map();
|
|
2631
|
+
this.cache = null;
|
|
2632
|
+
this.logger = config.logger;
|
|
2633
|
+
this.config = {
|
|
2634
|
+
defaultMaxBytes: config.defaultMaxBytes ?? DEFAULT_CONFIG.defaultMaxBytes,
|
|
2635
|
+
defaultTimeout: config.defaultTimeout ?? DEFAULT_CONFIG.defaultTimeout,
|
|
2636
|
+
useCache: config.useCache ?? DEFAULT_CONFIG.useCache,
|
|
2637
|
+
cacheName: config.cacheName ?? DEFAULT_CONFIG.cacheName
|
|
2638
|
+
};
|
|
2639
|
+
this.initCache();
|
|
2640
|
+
}
|
|
2641
|
+
/**
|
|
2642
|
+
* Initialize Cache API
|
|
2643
|
+
*/
|
|
2644
|
+
async initCache() {
|
|
2645
|
+
if (!this.config.useCache) return;
|
|
2646
|
+
try {
|
|
2647
|
+
if ("caches" in window) {
|
|
2648
|
+
this.cache = await caches.open(this.config.cacheName);
|
|
2649
|
+
this.logger?.debug("[BrowserVideoLoader] Cache initialized");
|
|
2650
|
+
}
|
|
2651
|
+
} catch (error) {
|
|
2652
|
+
this.logger?.warn("[BrowserVideoLoader] Cache API not available", { error });
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
/**
|
|
2656
|
+
* Preload video source
|
|
2657
|
+
*/
|
|
2658
|
+
async preload(videoId, source, config) {
|
|
2659
|
+
const existing = this.preloads.get(videoId);
|
|
2660
|
+
if (existing?.status === "ready") {
|
|
2661
|
+
return {
|
|
2662
|
+
videoId,
|
|
2663
|
+
status: "ready",
|
|
2664
|
+
loadedBytes: existing.loadedBytes
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
if (existing?.status === "loading") {
|
|
2668
|
+
this.logger?.debug("[BrowserVideoLoader] Already loading", { videoId });
|
|
2669
|
+
return { videoId, status: "loading" };
|
|
2670
|
+
}
|
|
2671
|
+
const abortController = new AbortController();
|
|
2672
|
+
const timeout = config?.timeout ?? this.config.defaultTimeout;
|
|
2673
|
+
const maxBytes = config?.maxBytes ?? this.config.defaultMaxBytes;
|
|
2674
|
+
this.preloads.set(videoId, {
|
|
2675
|
+
status: "loading",
|
|
2676
|
+
loadedBytes: 0,
|
|
2677
|
+
abortController
|
|
2678
|
+
});
|
|
2679
|
+
const timeoutId = setTimeout(() => {
|
|
2680
|
+
abortController.abort();
|
|
2681
|
+
}, timeout);
|
|
2682
|
+
try {
|
|
2683
|
+
let loadedBytes;
|
|
2684
|
+
if (source.type === "hls") {
|
|
2685
|
+
loadedBytes = await this.preloadHLS(source.url, abortController.signal);
|
|
2686
|
+
} else {
|
|
2687
|
+
loadedBytes = await this.preloadMP4(source.url, maxBytes, abortController.signal);
|
|
2688
|
+
}
|
|
2689
|
+
clearTimeout(timeoutId);
|
|
2690
|
+
this.preloads.set(videoId, {
|
|
2691
|
+
status: "ready",
|
|
2692
|
+
loadedBytes
|
|
2693
|
+
});
|
|
2694
|
+
this.logger?.debug("[BrowserVideoLoader] Preload complete", {
|
|
2695
|
+
videoId,
|
|
2696
|
+
loadedBytes,
|
|
2697
|
+
type: source.type
|
|
2698
|
+
});
|
|
2699
|
+
return {
|
|
2700
|
+
videoId,
|
|
2701
|
+
status: "ready",
|
|
2702
|
+
loadedBytes
|
|
2703
|
+
};
|
|
2704
|
+
} catch (error) {
|
|
2705
|
+
clearTimeout(timeoutId);
|
|
2706
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2707
|
+
if (err.name !== "AbortError") {
|
|
2708
|
+
this.logger?.warn("[BrowserVideoLoader] Preload failed", { videoId, error: err.message });
|
|
2709
|
+
}
|
|
2710
|
+
this.preloads.set(videoId, {
|
|
2711
|
+
status: err.name === "AbortError" ? "idle" : "error",
|
|
2712
|
+
loadedBytes: 0,
|
|
2713
|
+
error: err
|
|
2714
|
+
});
|
|
2715
|
+
return {
|
|
2716
|
+
videoId,
|
|
2717
|
+
status: err.name === "AbortError" ? "idle" : "error",
|
|
2718
|
+
error: err
|
|
2719
|
+
};
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
/**
|
|
2723
|
+
* Preload MP4 video using Range request
|
|
2724
|
+
*/
|
|
2725
|
+
async preloadMP4(url, maxBytes, signal) {
|
|
2726
|
+
const response = await fetch(url, {
|
|
2727
|
+
method: "GET",
|
|
2728
|
+
headers: {
|
|
2729
|
+
Range: `bytes=0-${maxBytes - 1}`
|
|
2730
|
+
},
|
|
2731
|
+
signal
|
|
2732
|
+
});
|
|
2733
|
+
if (!response.ok && response.status !== 206) {
|
|
2734
|
+
throw new Error(`HTTP error: ${response.status}`);
|
|
2735
|
+
}
|
|
2736
|
+
const buffer = await response.arrayBuffer();
|
|
2737
|
+
if (this.cache && buffer.byteLength > 0) {
|
|
2738
|
+
try {
|
|
2739
|
+
const cacheResponse = new Response(buffer, {
|
|
2740
|
+
headers: {
|
|
2741
|
+
"Content-Type": "video/mp4",
|
|
2742
|
+
"Content-Length": String(buffer.byteLength)
|
|
2743
|
+
}
|
|
2744
|
+
});
|
|
2745
|
+
await this.cache.put(url, cacheResponse);
|
|
2746
|
+
} catch {
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
return buffer.byteLength;
|
|
2750
|
+
}
|
|
2751
|
+
/**
|
|
2752
|
+
* Preload HLS - fetch manifest only
|
|
2753
|
+
* Full segment preload requires hls.js which is optional
|
|
2754
|
+
*/
|
|
2755
|
+
async preloadHLS(url, signal) {
|
|
2756
|
+
const response = await fetch(url, { signal });
|
|
2757
|
+
if (!response.ok) {
|
|
2758
|
+
throw new Error(`HTTP error: ${response.status}`);
|
|
2759
|
+
}
|
|
2760
|
+
const text = await response.text();
|
|
2761
|
+
if (this.cache) {
|
|
2762
|
+
try {
|
|
2763
|
+
const cacheResponse = new Response(text, {
|
|
2764
|
+
headers: {
|
|
2765
|
+
"Content-Type": "application/vnd.apple.mpegurl"
|
|
2766
|
+
}
|
|
2767
|
+
});
|
|
2768
|
+
await this.cache.put(url, cacheResponse);
|
|
2769
|
+
} catch {
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
return text.length;
|
|
2773
|
+
}
|
|
2774
|
+
/**
|
|
2775
|
+
* Cancel preload
|
|
2776
|
+
*/
|
|
2777
|
+
cancelPreload(videoId) {
|
|
2778
|
+
const state = this.preloads.get(videoId);
|
|
2779
|
+
if (state?.abortController) {
|
|
2780
|
+
state.abortController.abort();
|
|
2781
|
+
}
|
|
2782
|
+
this.preloads.set(videoId, {
|
|
2783
|
+
status: "idle",
|
|
2784
|
+
loadedBytes: 0
|
|
2785
|
+
});
|
|
2786
|
+
this.logger?.debug("[BrowserVideoLoader] Cancelled preload", { videoId });
|
|
2787
|
+
}
|
|
2788
|
+
/**
|
|
2789
|
+
* Check if video is preloaded
|
|
2790
|
+
*/
|
|
2791
|
+
isPreloaded(videoId) {
|
|
2792
|
+
return this.preloads.get(videoId)?.status === "ready";
|
|
2793
|
+
}
|
|
2794
|
+
/**
|
|
2795
|
+
* Get preload status
|
|
2796
|
+
*/
|
|
2797
|
+
getPreloadStatus(videoId) {
|
|
2798
|
+
return this.preloads.get(videoId)?.status ?? "idle";
|
|
2799
|
+
}
|
|
2800
|
+
/**
|
|
2801
|
+
* Clear preloaded data for a video
|
|
2802
|
+
*/
|
|
2803
|
+
clearPreload(videoId) {
|
|
2804
|
+
this.preloads.delete(videoId);
|
|
2805
|
+
}
|
|
2806
|
+
/**
|
|
2807
|
+
* Clear all preloaded data
|
|
2808
|
+
*/
|
|
2809
|
+
clearAll() {
|
|
2810
|
+
for (const [, state] of this.preloads) {
|
|
2811
|
+
if (state.abortController) {
|
|
2812
|
+
state.abortController.abort();
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
this.preloads.clear();
|
|
2816
|
+
if (this.cache) {
|
|
2817
|
+
caches.delete(this.config.cacheName).catch(() => {
|
|
2818
|
+
});
|
|
2819
|
+
}
|
|
2820
|
+
this.logger?.debug("[BrowserVideoLoader] Cleared all preloads");
|
|
2821
|
+
}
|
|
2822
|
+
/**
|
|
2823
|
+
* Get total preloaded bytes
|
|
2824
|
+
*/
|
|
2825
|
+
getTotalPreloadedBytes() {
|
|
2826
|
+
let total = 0;
|
|
2827
|
+
for (const state of this.preloads.values()) {
|
|
2828
|
+
if (state.status === "ready") {
|
|
2829
|
+
total += state.loadedBytes;
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
return total;
|
|
2833
|
+
}
|
|
2834
|
+
};
|
|
2835
|
+
var BrowserPosterLoader = class {
|
|
2836
|
+
constructor(config) {
|
|
2837
|
+
this.cache = /* @__PURE__ */ new Set();
|
|
2838
|
+
this.pending = /* @__PURE__ */ new Map();
|
|
2839
|
+
this.logger = config?.logger;
|
|
2840
|
+
this.timeout = config?.timeout ?? 1e4;
|
|
2841
|
+
}
|
|
2842
|
+
/**
|
|
2843
|
+
* Preload a poster image
|
|
2844
|
+
*/
|
|
2845
|
+
async preload(url) {
|
|
2846
|
+
if (this.cache.has(url)) {
|
|
2847
|
+
return;
|
|
2848
|
+
}
|
|
2849
|
+
const pending = this.pending.get(url);
|
|
2850
|
+
if (pending) {
|
|
2851
|
+
return pending;
|
|
2852
|
+
}
|
|
2853
|
+
const promise = new Promise((resolve, reject) => {
|
|
2854
|
+
const img = new Image();
|
|
2855
|
+
const timeoutId = setTimeout(() => {
|
|
2856
|
+
img.src = "";
|
|
2857
|
+
reject(new Error("Poster preload timeout"));
|
|
2858
|
+
}, this.timeout);
|
|
2859
|
+
img.onload = () => {
|
|
2860
|
+
clearTimeout(timeoutId);
|
|
2861
|
+
this.cache.add(url);
|
|
2862
|
+
this.pending.delete(url);
|
|
2863
|
+
this.logger?.debug("[BrowserPosterLoader] Preloaded", { url });
|
|
2864
|
+
resolve();
|
|
2865
|
+
};
|
|
2866
|
+
img.onerror = () => {
|
|
2867
|
+
clearTimeout(timeoutId);
|
|
2868
|
+
this.pending.delete(url);
|
|
2869
|
+
this.logger?.warn("[BrowserPosterLoader] Failed to preload", { url });
|
|
2870
|
+
reject(new Error(`Failed to load image: ${url}`));
|
|
2871
|
+
};
|
|
2872
|
+
img.src = url;
|
|
2873
|
+
});
|
|
2874
|
+
this.pending.set(url, promise);
|
|
2875
|
+
try {
|
|
2876
|
+
await promise;
|
|
2877
|
+
} catch {
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
/**
|
|
2881
|
+
* Check if poster is cached
|
|
2882
|
+
*/
|
|
2883
|
+
isCached(url) {
|
|
2884
|
+
return this.cache.has(url);
|
|
2885
|
+
}
|
|
2886
|
+
/**
|
|
2887
|
+
* Clear poster cache
|
|
2888
|
+
*/
|
|
2889
|
+
clearCache() {
|
|
2890
|
+
this.cache.clear();
|
|
2891
|
+
this.pending.clear();
|
|
2892
|
+
this.logger?.debug("[BrowserPosterLoader] Cache cleared");
|
|
2893
|
+
}
|
|
2894
|
+
};
|
|
2895
|
+
function createBrowserVideoLoader(config) {
|
|
2896
|
+
return new BrowserVideoLoader(config);
|
|
2897
|
+
}
|
|
2898
|
+
function createBrowserPosterLoader(config) {
|
|
2899
|
+
return new BrowserPosterLoader(config);
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
// src/preset/adapters/LocalStorageAdapter.ts
|
|
2903
|
+
var DEFAULT_CONFIG2 = {
|
|
2904
|
+
prefix: "sv-",
|
|
2905
|
+
maxSnapshotAge: 24 * 60 * 60 * 1e3
|
|
2906
|
+
// 24 hours
|
|
2907
|
+
};
|
|
2908
|
+
var SESSION_SNAPSHOT_KEY2 = "session-snapshot";
|
|
2909
|
+
var LocalStorageAdapter = class {
|
|
2910
|
+
constructor(config = {}) {
|
|
2911
|
+
this.prefix = config.prefix ?? DEFAULT_CONFIG2.prefix;
|
|
2912
|
+
this.logger = config.logger;
|
|
2913
|
+
}
|
|
2914
|
+
/**
|
|
2915
|
+
* Build namespaced key
|
|
2916
|
+
*/
|
|
2917
|
+
buildKey(key) {
|
|
2918
|
+
return `${this.prefix}${key}`;
|
|
2919
|
+
}
|
|
2920
|
+
/**
|
|
2921
|
+
* Get a value from localStorage
|
|
2922
|
+
*/
|
|
2923
|
+
async get(key) {
|
|
2924
|
+
try {
|
|
2925
|
+
const fullKey = this.buildKey(key);
|
|
2926
|
+
const value = localStorage.getItem(fullKey);
|
|
2927
|
+
if (value === null) {
|
|
2928
|
+
return null;
|
|
2929
|
+
}
|
|
2930
|
+
return JSON.parse(value);
|
|
2931
|
+
} catch (error) {
|
|
2932
|
+
this.logger?.warn("[LocalStorageAdapter] Failed to get/parse value", { key, error });
|
|
2933
|
+
return null;
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
/**
|
|
2937
|
+
* Set a value in localStorage
|
|
2938
|
+
*/
|
|
2939
|
+
async set(key, value) {
|
|
2940
|
+
try {
|
|
2941
|
+
const fullKey = this.buildKey(key);
|
|
2942
|
+
localStorage.setItem(fullKey, JSON.stringify(value));
|
|
2943
|
+
} catch (error) {
|
|
2944
|
+
if (error instanceof DOMException && error.name === "QuotaExceededError") {
|
|
2945
|
+
this.logger?.warn("[LocalStorageAdapter] Storage quota exceeded, clearing old data");
|
|
2946
|
+
await this.clearOldEntries();
|
|
2947
|
+
try {
|
|
2948
|
+
const fullKey = this.buildKey(key);
|
|
2949
|
+
localStorage.setItem(fullKey, JSON.stringify(value));
|
|
2950
|
+
} catch {
|
|
2951
|
+
this.logger?.error(
|
|
2952
|
+
"[LocalStorageAdapter] Still over quota after cleanup",
|
|
2953
|
+
error
|
|
2954
|
+
);
|
|
2955
|
+
throw error;
|
|
2956
|
+
}
|
|
2957
|
+
} else {
|
|
2958
|
+
throw error;
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
/**
|
|
2963
|
+
* Remove a value from localStorage
|
|
2964
|
+
*/
|
|
2965
|
+
async remove(key) {
|
|
2966
|
+
const fullKey = this.buildKey(key);
|
|
2967
|
+
localStorage.removeItem(fullKey);
|
|
2968
|
+
}
|
|
2969
|
+
/**
|
|
2970
|
+
* Clear all SDK-namespaced keys
|
|
2971
|
+
*/
|
|
2972
|
+
async clear() {
|
|
2973
|
+
const keysToRemove = [];
|
|
2974
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
2975
|
+
const key = localStorage.key(i);
|
|
2976
|
+
if (key?.startsWith(this.prefix)) {
|
|
2977
|
+
keysToRemove.push(key);
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
for (const key of keysToRemove) {
|
|
2981
|
+
localStorage.removeItem(key);
|
|
2982
|
+
}
|
|
2983
|
+
this.logger?.debug("[LocalStorageAdapter] Cleared all SDK keys", {
|
|
2984
|
+
count: keysToRemove.length
|
|
2985
|
+
});
|
|
2986
|
+
}
|
|
2987
|
+
/**
|
|
2988
|
+
* Get all SDK-namespaced keys
|
|
2989
|
+
*/
|
|
2990
|
+
async keys() {
|
|
2991
|
+
const sdkKeys = [];
|
|
2992
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
2993
|
+
const key = localStorage.key(i);
|
|
2994
|
+
if (key?.startsWith(this.prefix)) {
|
|
2995
|
+
sdkKeys.push(key.slice(this.prefix.length));
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
return sdkKeys;
|
|
2999
|
+
}
|
|
3000
|
+
/**
|
|
3001
|
+
* Clear old entries when quota exceeded (simple LRU-like behavior)
|
|
3002
|
+
*/
|
|
3003
|
+
async clearOldEntries() {
|
|
3004
|
+
const allKeys = await this.keys();
|
|
3005
|
+
const keysToRemove = allKeys.slice(0, Math.ceil(allKeys.length / 2));
|
|
3006
|
+
for (const key of keysToRemove) {
|
|
3007
|
+
await this.remove(key);
|
|
3008
|
+
}
|
|
3009
|
+
this.logger?.debug("[LocalStorageAdapter] Cleared old entries", { count: keysToRemove.length });
|
|
3010
|
+
}
|
|
3011
|
+
};
|
|
3012
|
+
var LocalSessionStorageAdapter = class extends LocalStorageAdapter {
|
|
3013
|
+
constructor(config = {}) {
|
|
3014
|
+
super(config);
|
|
3015
|
+
this.maxSnapshotAge = config.maxSnapshotAge ?? DEFAULT_CONFIG2.maxSnapshotAge;
|
|
3016
|
+
}
|
|
3017
|
+
/**
|
|
3018
|
+
* Save session snapshot
|
|
3019
|
+
*/
|
|
3020
|
+
async saveSnapshot(snapshot) {
|
|
3021
|
+
await this.set(SESSION_SNAPSHOT_KEY2, snapshot);
|
|
3022
|
+
this.logger?.debug("[LocalSessionStorageAdapter] Saved snapshot", {
|
|
3023
|
+
index: snapshot.focusedIndex,
|
|
3024
|
+
cursor: snapshot.cursor
|
|
3025
|
+
});
|
|
3026
|
+
}
|
|
3027
|
+
/**
|
|
3028
|
+
* Load session snapshot (returns null if stale)
|
|
3029
|
+
*/
|
|
3030
|
+
async loadSnapshot() {
|
|
3031
|
+
const snapshot = await this.get(SESSION_SNAPSHOT_KEY2);
|
|
3032
|
+
if (!snapshot) {
|
|
3033
|
+
return null;
|
|
3034
|
+
}
|
|
3035
|
+
const age = Date.now() - snapshot.savedAt;
|
|
3036
|
+
if (age > this.maxSnapshotAge) {
|
|
3037
|
+
this.logger?.debug("[LocalSessionStorageAdapter] Snapshot is stale, clearing", {
|
|
3038
|
+
age,
|
|
3039
|
+
maxAge: this.maxSnapshotAge
|
|
3040
|
+
});
|
|
3041
|
+
await this.clearSnapshot();
|
|
3042
|
+
return null;
|
|
3043
|
+
}
|
|
3044
|
+
this.logger?.debug("[LocalSessionStorageAdapter] Loaded snapshot", {
|
|
3045
|
+
index: snapshot.focusedIndex,
|
|
3046
|
+
age
|
|
3047
|
+
});
|
|
3048
|
+
return snapshot;
|
|
3049
|
+
}
|
|
3050
|
+
/**
|
|
3051
|
+
* Clear session snapshot
|
|
3052
|
+
*/
|
|
3053
|
+
async clearSnapshot() {
|
|
3054
|
+
await this.remove(SESSION_SNAPSHOT_KEY2);
|
|
3055
|
+
this.logger?.debug("[LocalSessionStorageAdapter] Cleared snapshot");
|
|
3056
|
+
}
|
|
3057
|
+
};
|
|
3058
|
+
function createLocalStorageAdapter(config) {
|
|
3059
|
+
return new LocalStorageAdapter(config);
|
|
3060
|
+
}
|
|
3061
|
+
function createSessionStorageAdapter(config) {
|
|
3062
|
+
return new LocalSessionStorageAdapter(config);
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
// src/preset/adapters/WebNetworkAdapter.ts
|
|
3066
|
+
function mapEffectiveType(effectiveType) {
|
|
3067
|
+
switch (effectiveType) {
|
|
3068
|
+
case "4g":
|
|
3069
|
+
return "4g";
|
|
3070
|
+
case "3g":
|
|
3071
|
+
return "3g";
|
|
3072
|
+
case "2g":
|
|
3073
|
+
case "slow-2g":
|
|
3074
|
+
return "slow";
|
|
3075
|
+
default:
|
|
3076
|
+
return "wifi";
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
function mapConnectionType(type, effectiveType) {
|
|
3080
|
+
if (type === "none") return "offline";
|
|
3081
|
+
if (type === "wifi" || type === "ethernet") return "wifi";
|
|
3082
|
+
if (type === "cellular") {
|
|
3083
|
+
return mapEffectiveType(effectiveType);
|
|
3084
|
+
}
|
|
3085
|
+
return mapEffectiveType(effectiveType);
|
|
3086
|
+
}
|
|
3087
|
+
var WebNetworkAdapter = class {
|
|
3088
|
+
constructor(config = {}) {
|
|
3089
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
3090
|
+
this.connection = null;
|
|
3091
|
+
this.logger = config.logger;
|
|
3092
|
+
this.fallbackType = config.fallbackType ?? "wifi";
|
|
3093
|
+
this.fallbackDownlink = config.fallbackDownlink ?? 10;
|
|
3094
|
+
const nav = navigator;
|
|
3095
|
+
this.connection = nav.connection ?? nav.mozConnection ?? nav.webkitConnection ?? null;
|
|
3096
|
+
this.boundOnlineHandler = this.handleOnline.bind(this);
|
|
3097
|
+
this.boundOfflineHandler = this.handleOffline.bind(this);
|
|
3098
|
+
this.boundChangeHandler = this.handleConnectionChange.bind(this);
|
|
3099
|
+
this.setupListeners();
|
|
3100
|
+
this.logger?.debug("[WebNetworkAdapter] Initialized", {
|
|
3101
|
+
hasConnectionAPI: !!this.connection
|
|
3102
|
+
});
|
|
3103
|
+
}
|
|
3104
|
+
/**
|
|
3105
|
+
* Get current network type
|
|
3106
|
+
*/
|
|
3107
|
+
async getNetworkType() {
|
|
3108
|
+
if (!navigator.onLine) {
|
|
3109
|
+
return "offline";
|
|
3110
|
+
}
|
|
3111
|
+
if (!this.connection) {
|
|
3112
|
+
return this.fallbackType;
|
|
3113
|
+
}
|
|
3114
|
+
return mapConnectionType(this.connection.type, this.connection.effectiveType);
|
|
3115
|
+
}
|
|
3116
|
+
/**
|
|
3117
|
+
* Get detailed network quality
|
|
3118
|
+
*/
|
|
3119
|
+
async getNetworkQuality() {
|
|
3120
|
+
const type = await this.getNetworkType();
|
|
3121
|
+
if (!this.connection) {
|
|
3122
|
+
return {
|
|
3123
|
+
type,
|
|
3124
|
+
downlink: this.fallbackDownlink,
|
|
3125
|
+
rtt: void 0,
|
|
3126
|
+
isMetered: false
|
|
3127
|
+
};
|
|
3128
|
+
}
|
|
3129
|
+
return {
|
|
3130
|
+
type,
|
|
3131
|
+
downlink: this.connection.downlink,
|
|
3132
|
+
rtt: this.connection.rtt,
|
|
3133
|
+
isMetered: this.connection.saveData ?? this.connection.type === "cellular"
|
|
3134
|
+
};
|
|
3135
|
+
}
|
|
3136
|
+
/**
|
|
3137
|
+
* Subscribe to network changes
|
|
3138
|
+
*/
|
|
3139
|
+
onNetworkChange(callback) {
|
|
3140
|
+
this.listeners.add(callback);
|
|
3141
|
+
return () => {
|
|
3142
|
+
this.listeners.delete(callback);
|
|
3143
|
+
};
|
|
3144
|
+
}
|
|
3145
|
+
/**
|
|
3146
|
+
* Check if currently online
|
|
3147
|
+
*/
|
|
3148
|
+
async isOnline() {
|
|
3149
|
+
return navigator.onLine;
|
|
3150
|
+
}
|
|
3151
|
+
/**
|
|
3152
|
+
* Cleanup listeners
|
|
3153
|
+
*/
|
|
3154
|
+
destroy() {
|
|
3155
|
+
window.removeEventListener("online", this.boundOnlineHandler);
|
|
3156
|
+
window.removeEventListener("offline", this.boundOfflineHandler);
|
|
3157
|
+
if (this.connection) {
|
|
3158
|
+
this.connection.removeEventListener("change", this.boundChangeHandler);
|
|
3159
|
+
}
|
|
3160
|
+
this.listeners.clear();
|
|
3161
|
+
this.logger?.debug("[WebNetworkAdapter] Destroyed");
|
|
3162
|
+
}
|
|
3163
|
+
// ═══════════════════════════════════════════════════════════════
|
|
3164
|
+
// PRIVATE METHODS
|
|
3165
|
+
// ═══════════════════════════════════════════════════════════════
|
|
3166
|
+
setupListeners() {
|
|
3167
|
+
window.addEventListener("online", this.boundOnlineHandler);
|
|
3168
|
+
window.addEventListener("offline", this.boundOfflineHandler);
|
|
3169
|
+
if (this.connection) {
|
|
3170
|
+
this.connection.addEventListener("change", this.boundChangeHandler);
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
handleOnline() {
|
|
3174
|
+
this.logger?.debug("[WebNetworkAdapter] Online");
|
|
3175
|
+
this.notifyListeners();
|
|
3176
|
+
}
|
|
3177
|
+
handleOffline() {
|
|
3178
|
+
this.logger?.debug("[WebNetworkAdapter] Offline");
|
|
3179
|
+
this.notifyListeners();
|
|
3180
|
+
}
|
|
3181
|
+
handleConnectionChange() {
|
|
3182
|
+
this.logger?.debug("[WebNetworkAdapter] Connection changed", {
|
|
3183
|
+
type: this.connection?.type,
|
|
3184
|
+
effectiveType: this.connection?.effectiveType
|
|
3185
|
+
});
|
|
3186
|
+
this.notifyListeners();
|
|
3187
|
+
}
|
|
3188
|
+
async notifyListeners() {
|
|
3189
|
+
const type = await this.getNetworkType();
|
|
3190
|
+
for (const listener of this.listeners) {
|
|
3191
|
+
try {
|
|
3192
|
+
listener(type);
|
|
3193
|
+
} catch (error) {
|
|
3194
|
+
this.logger?.error("[WebNetworkAdapter] Listener error", error);
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
};
|
|
3199
|
+
function createWebNetworkAdapter(config) {
|
|
3200
|
+
return new WebNetworkAdapter(config);
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
// src/preset/createBrowserAdapters.ts
|
|
3204
|
+
function createBrowserAdapters(config) {
|
|
3205
|
+
const restAdapters = createRESTAdapters(config);
|
|
3206
|
+
const storage = new LocalSessionStorageAdapter({
|
|
3207
|
+
...config.storage,
|
|
3208
|
+
logger: config.logger
|
|
3209
|
+
});
|
|
3210
|
+
const network = new WebNetworkAdapter({
|
|
3211
|
+
...config.network,
|
|
3212
|
+
logger: config.logger
|
|
3213
|
+
});
|
|
3214
|
+
const videoLoader = new BrowserVideoLoader({
|
|
3215
|
+
...config.videoLoader,
|
|
3216
|
+
logger: config.logger
|
|
3217
|
+
});
|
|
3218
|
+
const posterLoader = new BrowserPosterLoader({
|
|
3219
|
+
logger: config.logger,
|
|
3220
|
+
timeout: config.posterTimeout
|
|
3221
|
+
});
|
|
3222
|
+
config.logger?.info("[createBrowserAdapters] Created all adapters", {
|
|
3223
|
+
baseUrl: config.baseUrl,
|
|
3224
|
+
hasAnalytics: !!config.endpoints.analytics
|
|
3225
|
+
});
|
|
3226
|
+
return {
|
|
3227
|
+
dataSource: restAdapters.dataSource,
|
|
3228
|
+
interaction: restAdapters.interaction,
|
|
3229
|
+
analytics: restAdapters.analytics,
|
|
3230
|
+
storage,
|
|
3231
|
+
network,
|
|
3232
|
+
videoLoader,
|
|
3233
|
+
posterLoader,
|
|
3234
|
+
logger: config.logger
|
|
3235
|
+
};
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
export { BrowserPosterLoader, BrowserVideoLoader, DEFAULT_REQUEST_CONFIG, DEFAULT_RETRY_CONFIG, HttpClient, HttpError, LocalSessionStorageAdapter, LocalStorageAdapter, MockAnalyticsAdapter, MockDataAdapter, MockInteractionAdapter, MockLoggerAdapter, MockNetworkAdapter, MockPosterLoader, MockSessionStorageAdapter, MockStorageAdapter, MockVideoLoader, RESTAnalyticsAdapter, RESTDataAdapter, RESTInteractionAdapter, WebNetworkAdapter, createBrowserAdapters, createBrowserPosterLoader, createBrowserVideoLoader, createLocalStorageAdapter, createNoOpAnalyticsAdapter, createRESTAdapters, createSessionStorageAdapter, createTransforms, createWebNetworkAdapter, defaultFeedResponseTransform, defaultVideoItemTransform };
|