peertube-plugin-sponsorblock 0.1.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.
@@ -0,0 +1,709 @@
1
+ /**
2
+ * Server-side API routes
3
+ */
4
+
5
+ async function registerRoutes({ router, peertubeHelpers }) {
6
+ const logger = peertubeHelpers.logger
7
+
8
+ /**
9
+ * GET /segments/:videoUuid
10
+ * Returns SponsorBlock segments for a given video
11
+ */
12
+ router.get('/segments/:videoUuid', async (req, res) => {
13
+ const videoUuid = req.params.videoUuid
14
+
15
+ try {
16
+ const database = peertubeHelpers.database
17
+
18
+ // Get YouTube ID for this video
19
+ const [mappings] = await database.query(`
20
+ SELECT youtube_id FROM plugin_sponsorblock_mapping
21
+ WHERE peertube_uuid = $1
22
+ `, { bind: [videoUuid] })
23
+
24
+ if (!mappings || mappings.length === 0) {
25
+ return res.status(404).json({
26
+ error: 'No YouTube mapping found for this video',
27
+ segments: []
28
+ })
29
+ }
30
+
31
+ const youtubeId = mappings[0].youtube_id
32
+
33
+ // Get segments
34
+ const [segments] = await database.query(`
35
+ SELECT
36
+ segment_uuid,
37
+ start_time,
38
+ end_time,
39
+ category,
40
+ action_type,
41
+ votes
42
+ FROM plugin_sponsorblock_segments
43
+ WHERE youtube_id = $1
44
+ ORDER BY start_time ASC
45
+ `, { bind: [youtubeId] })
46
+
47
+ res.json({
48
+ videoUuid,
49
+ youtubeId,
50
+ segments: segments || []
51
+ })
52
+
53
+ } catch (error) {
54
+ logger.error('Error fetching segments', error)
55
+ res.status(500).json({
56
+ error: 'Internal server error',
57
+ segments: []
58
+ })
59
+ }
60
+ })
61
+
62
+ /**
63
+ * GET /mapping/:videoUuid
64
+ * Returns YouTube ID mapping for a video
65
+ */
66
+ router.get('/mapping/:videoUuid', async (req, res) => {
67
+ const videoUuid = req.params.videoUuid
68
+
69
+ try {
70
+ const database = peertubeHelpers.database
71
+
72
+ const [mappings] = await database.query(`
73
+ SELECT youtube_id, created_at, last_sync
74
+ FROM plugin_sponsorblock_mapping
75
+ WHERE peertube_uuid = $1
76
+ `, { bind: [videoUuid] })
77
+
78
+ if (!mappings || mappings.length === 0) {
79
+ return res.status(404).json({
80
+ error: 'No YouTube mapping found'
81
+ })
82
+ }
83
+
84
+ res.json(mappings[0])
85
+
86
+ } catch (error) {
87
+ logger.error('Error fetching mapping', error)
88
+ res.status(500).json({
89
+ error: 'Internal server error'
90
+ })
91
+ }
92
+ })
93
+
94
+ /**
95
+ * POST /mapping/:videoUuid
96
+ * Manually associate a YouTube ID with a PeerTube video
97
+ */
98
+ router.post('/mapping/:videoUuid', async (req, res) => {
99
+ const videoUuid = req.params.videoUuid
100
+
101
+ try {
102
+ // Auth check: admin/moderator only
103
+ const user = await peertubeHelpers.user.getAuthUser(res)
104
+ if (!user || (user.role !== 0 && user.role !== 1)) {
105
+ return res.status(403).json({ error: 'Admin or moderator access required' })
106
+ }
107
+
108
+ let { youtubeId } = req.body
109
+
110
+ // Accept full YouTube URLs and extract the ID
111
+ if (youtubeId && youtubeId.includes('/')) {
112
+ const extracted = extractYoutubeId(youtubeId)
113
+ if (extracted) {
114
+ youtubeId = extracted
115
+ }
116
+ }
117
+
118
+ // Validate YouTube ID format (11 chars, alphanumeric + _ -)
119
+ if (!youtubeId || !/^[a-zA-Z0-9_-]{11}$/.test(youtubeId)) {
120
+ return res.status(400).json({ error: 'Invalid YouTube ID format' })
121
+ }
122
+
123
+ const database = peertubeHelpers.database
124
+
125
+ // Upsert mapping
126
+ await database.query(`
127
+ INSERT INTO plugin_sponsorblock_mapping (peertube_uuid, youtube_id, created_at, last_sync)
128
+ VALUES ($1, $2, NOW(), NOW())
129
+ ON CONFLICT (peertube_uuid) DO UPDATE SET youtube_id = $2, last_sync = NOW()
130
+ `, { bind: [videoUuid, youtubeId] })
131
+
132
+ // Fetch segments from SponsorBlock
133
+ const segments = await fetchAndCacheSegments(database, youtubeId, logger)
134
+
135
+ logger.info(`Manual mapping created: ${videoUuid} -> ${youtubeId} (${segments.length} segments)`)
136
+
137
+ res.json({
138
+ success: true,
139
+ videoUuid,
140
+ youtubeId,
141
+ segments
142
+ })
143
+
144
+ } catch (error) {
145
+ logger.error('Error creating mapping', error)
146
+ res.status(500).json({ error: 'Internal server error' })
147
+ }
148
+ })
149
+
150
+ /**
151
+ * POST /scan
152
+ * Scan videoImport table for YouTube imports and create mappings
153
+ */
154
+ router.post('/scan', async (req, res) => {
155
+ try {
156
+ // Auth check: admin/moderator only
157
+ const user = await peertubeHelpers.user.getAuthUser(res)
158
+ if (!user || (user.role !== 0 && user.role !== 1)) {
159
+ return res.status(403).json({ error: 'Admin or moderator access required' })
160
+ }
161
+
162
+ const database = peertubeHelpers.database
163
+
164
+ // Find YouTube imports
165
+ const [imports] = await database.query(`
166
+ SELECT vi."targetUrl", v."uuid"
167
+ FROM "videoImport" vi
168
+ JOIN "video" v ON vi."videoId" = v."id"
169
+ WHERE vi."targetUrl" LIKE '%youtube%' OR vi."targetUrl" LIKE '%youtu.be%'
170
+ `)
171
+
172
+ let scanned = 0
173
+ let mapped = 0
174
+ const errors = []
175
+
176
+ for (const row of (imports || [])) {
177
+ scanned++
178
+ const youtubeId = extractYoutubeId(row.targetUrl)
179
+ if (!youtubeId) {
180
+ errors.push(`Could not extract YouTube ID from: ${row.targetUrl}`)
181
+ continue
182
+ }
183
+
184
+ // Check if mapping already exists
185
+ const [existing] = await database.query(`
186
+ SELECT 1 FROM plugin_sponsorblock_mapping WHERE peertube_uuid = $1
187
+ `, { bind: [row.uuid] })
188
+
189
+ if (existing && existing.length > 0) continue
190
+
191
+ // Create mapping
192
+ await database.query(`
193
+ INSERT INTO plugin_sponsorblock_mapping (peertube_uuid, youtube_id, created_at, last_sync)
194
+ VALUES ($1, $2, NOW(), NOW())
195
+ ON CONFLICT (peertube_uuid) DO NOTHING
196
+ `, { bind: [row.uuid, youtubeId] })
197
+
198
+ // Fetch segments
199
+ try {
200
+ await fetchAndCacheSegments(database, youtubeId, logger)
201
+ mapped++
202
+ } catch (err) {
203
+ errors.push(`Failed to fetch segments for ${youtubeId}: ${err.message}`)
204
+ }
205
+ }
206
+
207
+ logger.info(`Scan complete: ${scanned} scanned, ${mapped} mapped, ${errors.length} errors`)
208
+
209
+ res.json({ success: true, scanned, mapped, errors })
210
+
211
+ } catch (error) {
212
+ logger.error('Error during scan', error)
213
+ res.status(500).json({ error: 'Internal server error' })
214
+ }
215
+ })
216
+
217
+ /**
218
+ * POST /process/:videoUuid
219
+ * Queue a single video for FFmpeg segment removal
220
+ */
221
+ router.post('/process/:videoUuid', async (req, res) => {
222
+ const videoUuid = req.params.videoUuid
223
+
224
+ try {
225
+ // Auth check: admin/moderator only
226
+ const user = await peertubeHelpers.user.getAuthUser(res)
227
+ if (!user || (user.role !== 0 && user.role !== 1)) {
228
+ return res.status(403).json({ error: 'Admin or moderator access required' })
229
+ }
230
+
231
+ const database = peertubeHelpers.database
232
+
233
+ // Get YouTube ID mapping
234
+ const [mappings] = await database.query(
235
+ 'SELECT youtube_id FROM plugin_sponsorblock_mapping WHERE peertube_uuid = $1',
236
+ { bind: [videoUuid] }
237
+ )
238
+
239
+ if (!mappings || mappings.length === 0) {
240
+ return res.status(404).json({ error: 'process-no-mapping' })
241
+ }
242
+
243
+ const youtubeId = mappings[0].youtube_id
244
+
245
+ // Get segments
246
+ const [segments] = await database.query(
247
+ 'SELECT start_time, end_time, category FROM plugin_sponsorblock_segments WHERE youtube_id = $1 ORDER BY start_time ASC',
248
+ { bind: [youtubeId] }
249
+ )
250
+
251
+ if (!segments || segments.length === 0) {
252
+ return res.status(404).json({ error: 'process-no-segments' })
253
+ }
254
+
255
+ // Check for existing pending/processing entry
256
+ const [existing] = await database.query(
257
+ "SELECT id FROM plugin_sponsorblock_processing_queue WHERE video_uuid = $1 AND status IN ('pending', 'processing')",
258
+ { bind: [videoUuid] }
259
+ )
260
+
261
+ if (existing && existing.length > 0) {
262
+ return res.status(409).json({ error: 'process-already-queued' })
263
+ }
264
+
265
+ // Insert into queue with priority 5
266
+ const [inserted] = await database.query(`
267
+ INSERT INTO plugin_sponsorblock_processing_queue (video_uuid, youtube_id, segments, priority)
268
+ VALUES ($1, $2, $3, 5)
269
+ RETURNING id
270
+ `, { bind: [videoUuid, youtubeId, JSON.stringify(segments)] })
271
+
272
+ logger.info(`Queued video ${videoUuid} for processing (${segments.length} segments, priority 5)`)
273
+
274
+ res.json({
275
+ success: true,
276
+ queueId: inserted[0].id,
277
+ segmentsCount: segments.length
278
+ })
279
+
280
+ } catch (error) {
281
+ logger.error('Error queuing video for processing', error)
282
+ res.status(500).json({ error: 'process-error' })
283
+ }
284
+ })
285
+
286
+ /**
287
+ * POST /process-all
288
+ * Queue all mapped videos that have segments but no pending/done queue entry
289
+ */
290
+ router.post('/process-all', async (req, res) => {
291
+ try {
292
+ // Auth check: admin/moderator only
293
+ const user = await peertubeHelpers.user.getAuthUser(res)
294
+ if (!user || (user.role !== 0 && user.role !== 1)) {
295
+ return res.status(403).json({ error: 'Admin or moderator access required' })
296
+ }
297
+
298
+ const database = peertubeHelpers.database
299
+
300
+ // Find all mappings with segments that have no done/pending/processing queue entry
301
+ const [candidates] = await database.query(`
302
+ SELECT DISTINCT m.peertube_uuid, m.youtube_id
303
+ FROM plugin_sponsorblock_mapping m
304
+ INNER JOIN plugin_sponsorblock_segments s ON s.youtube_id = m.youtube_id
305
+ WHERE NOT EXISTS (
306
+ SELECT 1 FROM plugin_sponsorblock_processing_queue q
307
+ WHERE q.video_uuid = m.peertube_uuid
308
+ AND q.status IN ('done', 'pending', 'processing')
309
+ )
310
+ `)
311
+
312
+ let queued = 0
313
+ const errors = []
314
+
315
+ for (const candidate of (candidates || [])) {
316
+ try {
317
+ const [segments] = await database.query(
318
+ 'SELECT start_time, end_time, category FROM plugin_sponsorblock_segments WHERE youtube_id = $1 ORDER BY start_time ASC',
319
+ { bind: [candidate.youtube_id] }
320
+ )
321
+
322
+ if (!segments || segments.length === 0) continue
323
+
324
+ await database.query(`
325
+ INSERT INTO plugin_sponsorblock_processing_queue (video_uuid, youtube_id, segments, priority)
326
+ VALUES ($1, $2, $3, 1)
327
+ `, { bind: [candidate.peertube_uuid, candidate.youtube_id, JSON.stringify(segments)] })
328
+
329
+ queued++
330
+ } catch (err) {
331
+ errors.push(`${candidate.peertube_uuid}: ${err.message}`)
332
+ }
333
+ }
334
+
335
+ logger.info(`Process-all: queued ${queued} videos, ${errors.length} errors`)
336
+
337
+ res.json({ success: true, queued, errors })
338
+
339
+ } catch (error) {
340
+ logger.error('Error in process-all', error)
341
+ res.status(500).json({ error: 'process-error' })
342
+ }
343
+ })
344
+
345
+ /**
346
+ * GET /admin/stats
347
+ * Returns dashboard statistics
348
+ */
349
+ router.get('/admin/stats', async (req, res) => {
350
+ try {
351
+ const user = await peertubeHelpers.user.getAuthUser(res)
352
+ if (!user || user.role !== 0) {
353
+ return res.status(403).json({ error: 'Admin access required' })
354
+ }
355
+
356
+ const database = peertubeHelpers.database
357
+
358
+ const [[mappingCount], [segmentStats], [queueStats], [lastSync]] = await Promise.all([
359
+ database.query('SELECT COUNT(*) AS count FROM plugin_sponsorblock_mapping'),
360
+ database.query('SELECT COUNT(*) AS count, COALESCE(SUM(end_time - start_time), 0) AS total_time FROM plugin_sponsorblock_segments'),
361
+ database.query(`
362
+ SELECT
363
+ COUNT(*) FILTER (WHERE status = 'pending') AS pending,
364
+ COUNT(*) FILTER (WHERE status = 'processing') AS processing,
365
+ COUNT(*) FILTER (WHERE status = 'done') AS done,
366
+ COUNT(*) FILTER (WHERE status = 'error') AS errored
367
+ FROM plugin_sponsorblock_processing_queue
368
+ `),
369
+ database.query('SELECT MAX(last_sync) AS last_global_sync FROM plugin_sponsorblock_mapping')
370
+ ])
371
+
372
+ res.json({
373
+ mapped_videos: parseInt(mappingCount[0].count, 10),
374
+ total_segments: parseInt(segmentStats[0].count, 10),
375
+ total_time_saved: parseFloat(segmentStats[0].total_time) || 0,
376
+ queue: {
377
+ pending: parseInt(queueStats[0].pending, 10),
378
+ processing: parseInt(queueStats[0].processing, 10),
379
+ done: parseInt(queueStats[0].done, 10),
380
+ errored: parseInt(queueStats[0].errored, 10)
381
+ },
382
+ last_global_sync: lastSync[0].last_global_sync
383
+ })
384
+ } catch (error) {
385
+ logger.error('Error fetching admin stats', error)
386
+ res.status(500).json({ error: 'Internal server error' })
387
+ }
388
+ })
389
+
390
+ /**
391
+ * GET /admin/mappings
392
+ * Returns all mappings with segment counts and queue status
393
+ */
394
+ router.get('/admin/mappings', async (req, res) => {
395
+ try {
396
+ const user = await peertubeHelpers.user.getAuthUser(res)
397
+ if (!user || user.role !== 0) {
398
+ return res.status(403).json({ error: 'Admin access required' })
399
+ }
400
+
401
+ const database = peertubeHelpers.database
402
+
403
+ const [mappings] = await database.query(`
404
+ SELECT
405
+ m.peertube_uuid,
406
+ m.youtube_id,
407
+ m.created_at,
408
+ m.last_sync,
409
+ COALESCE(seg.segment_count, 0) AS segment_count,
410
+ COALESCE(seg.time_saved, 0) AS time_saved,
411
+ q.status AS queue_status,
412
+ q.error AS queue_error
413
+ FROM plugin_sponsorblock_mapping m
414
+ LEFT JOIN (
415
+ SELECT youtube_id, COUNT(*) AS segment_count, SUM(end_time - start_time) AS time_saved
416
+ FROM plugin_sponsorblock_segments
417
+ GROUP BY youtube_id
418
+ ) seg ON seg.youtube_id = m.youtube_id
419
+ LEFT JOIN LATERAL (
420
+ SELECT status, error FROM plugin_sponsorblock_processing_queue
421
+ WHERE video_uuid = m.peertube_uuid
422
+ ORDER BY created_at DESC LIMIT 1
423
+ ) q ON true
424
+ ORDER BY m.created_at DESC
425
+ `)
426
+
427
+ res.json({ mappings: mappings || [] })
428
+ } catch (error) {
429
+ logger.error('Error fetching admin mappings', error)
430
+ res.status(500).json({ error: 'Internal server error' })
431
+ }
432
+ })
433
+
434
+ /**
435
+ * DELETE /mapping/:videoUuid
436
+ * Delete a mapping and its orphaned segments
437
+ */
438
+ router.delete('/mapping/:videoUuid', async (req, res) => {
439
+ const videoUuid = req.params.videoUuid
440
+
441
+ try {
442
+ const user = await peertubeHelpers.user.getAuthUser(res)
443
+ if (!user || user.role !== 0) {
444
+ return res.status(403).json({ error: 'Admin access required' })
445
+ }
446
+
447
+ const database = peertubeHelpers.database
448
+
449
+ // Get the youtube_id before deleting the mapping
450
+ const [mappings] = await database.query(
451
+ 'SELECT youtube_id FROM plugin_sponsorblock_mapping WHERE peertube_uuid = $1',
452
+ { bind: [videoUuid] }
453
+ )
454
+
455
+ if (!mappings || mappings.length === 0) {
456
+ return res.status(404).json({ error: 'Mapping not found' })
457
+ }
458
+
459
+ const youtubeId = mappings[0].youtube_id
460
+
461
+ // Delete the mapping
462
+ await database.query(
463
+ 'DELETE FROM plugin_sponsorblock_mapping WHERE peertube_uuid = $1',
464
+ { bind: [videoUuid] }
465
+ )
466
+
467
+ // Delete queue entries for this video
468
+ await database.query(
469
+ 'DELETE FROM plugin_sponsorblock_processing_queue WHERE video_uuid = $1',
470
+ { bind: [videoUuid] }
471
+ )
472
+
473
+ // Delete segments only if no other mapping references the same youtube_id
474
+ const [otherMappings] = await database.query(
475
+ 'SELECT 1 FROM plugin_sponsorblock_mapping WHERE youtube_id = $1 LIMIT 1',
476
+ { bind: [youtubeId] }
477
+ )
478
+
479
+ if (!otherMappings || otherMappings.length === 0) {
480
+ await database.query(
481
+ 'DELETE FROM plugin_sponsorblock_segments WHERE youtube_id = $1',
482
+ { bind: [youtubeId] }
483
+ )
484
+ }
485
+
486
+ logger.info(`Deleted mapping ${videoUuid} -> ${youtubeId}`)
487
+
488
+ res.json({ success: true })
489
+ } catch (error) {
490
+ logger.error('Error deleting mapping', error)
491
+ res.status(500).json({ error: 'Internal server error' })
492
+ }
493
+ })
494
+
495
+ /**
496
+ * POST /sync-all
497
+ * Re-fetch segments for all mappings in background
498
+ */
499
+ router.post('/sync-all', async (req, res) => {
500
+ try {
501
+ const user = await peertubeHelpers.user.getAuthUser(res)
502
+ if (!user || user.role !== 0) {
503
+ return res.status(403).json({ error: 'Admin access required' })
504
+ }
505
+
506
+ const database = peertubeHelpers.database
507
+
508
+ const [mappings] = await database.query(
509
+ 'SELECT peertube_uuid, youtube_id FROM plugin_sponsorblock_mapping'
510
+ )
511
+
512
+ const total = (mappings || []).length
513
+
514
+ // Respond immediately
515
+ res.json({ success: true, total })
516
+
517
+ // Process in background with rate limiting
518
+ ;(async () => {
519
+ for (const mapping of (mappings || [])) {
520
+ try {
521
+ await fetchAndCacheSegments(database, mapping.youtube_id, logger)
522
+ await database.query(
523
+ 'UPDATE plugin_sponsorblock_mapping SET last_sync = NOW() WHERE peertube_uuid = $1',
524
+ { bind: [mapping.peertube_uuid] }
525
+ )
526
+ } catch (err) {
527
+ logger.error(`Sync-all: failed for ${mapping.youtube_id}`, err)
528
+ }
529
+ // Rate limit: 200ms between requests
530
+ await new Promise(resolve => setTimeout(resolve, 200))
531
+ }
532
+ logger.info(`Sync-all complete: ${total} mappings processed`)
533
+ })()
534
+ } catch (error) {
535
+ logger.error('Error in sync-all', error)
536
+ res.status(500).json({ error: 'Internal server error' })
537
+ }
538
+ })
539
+
540
+ /**
541
+ * POST /sync/:videoUuid
542
+ * Manually trigger sync for a video
543
+ */
544
+ router.post('/sync/:videoUuid', async (req, res) => {
545
+ const videoUuid = req.params.videoUuid
546
+
547
+ try {
548
+ const database = peertubeHelpers.database
549
+
550
+ // Get YouTube ID
551
+ const [mappings] = await database.query(`
552
+ SELECT youtube_id FROM plugin_sponsorblock_mapping
553
+ WHERE peertube_uuid = $1
554
+ `, { bind: [videoUuid] })
555
+
556
+ if (!mappings || mappings.length === 0) {
557
+ return res.status(404).json({
558
+ error: 'No YouTube mapping found'
559
+ })
560
+ }
561
+
562
+ const youtubeId = mappings[0].youtube_id
563
+
564
+ // Fetch fresh segments
565
+ const apiUrl = 'https://sponsor.ajay.app'
566
+ const response = await fetch(`${apiUrl}/api/skipSegments?videoID=${youtubeId}`)
567
+
568
+ if (!response.ok) {
569
+ return res.status(response.status).json({
570
+ error: 'Failed to fetch from SponsorBlock API'
571
+ })
572
+ }
573
+
574
+ const segments = await response.json()
575
+
576
+ // Delete old segments
577
+ await database.query(`
578
+ DELETE FROM plugin_sponsorblock_segments WHERE youtube_id = $1
579
+ `, { bind: [youtubeId] })
580
+
581
+ // Insert new segments
582
+ let insertedCount = 0
583
+ for (const segment of segments) {
584
+ await database.query(`
585
+ INSERT INTO plugin_sponsorblock_segments
586
+ (youtube_id, segment_uuid, start_time, end_time, category, action_type, votes)
587
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
588
+ ON CONFLICT (segment_uuid) DO NOTHING
589
+ `, { bind: [
590
+ youtubeId,
591
+ segment.UUID,
592
+ segment.segment[0],
593
+ segment.segment[1],
594
+ segment.category,
595
+ segment.actionType || 'skip',
596
+ segment.votes || 0
597
+ ] })
598
+ insertedCount++
599
+ }
600
+
601
+ // Update last_sync timestamp
602
+ await database.query(`
603
+ UPDATE plugin_sponsorblock_mapping
604
+ SET last_sync = NOW()
605
+ WHERE peertube_uuid = $1
606
+ `, { bind: [videoUuid] })
607
+
608
+ logger.info(`Synced ${insertedCount} segments for ${videoUuid}`)
609
+
610
+ res.json({
611
+ success: true,
612
+ segmentsCount: insertedCount,
613
+ youtubeId
614
+ })
615
+
616
+ } catch (error) {
617
+ logger.error('Error syncing segments', error)
618
+ res.status(500).json({
619
+ error: 'Internal server error'
620
+ })
621
+ }
622
+ })
623
+ }
624
+
625
+ /**
626
+ * Extract YouTube ID from a URL or raw ID string
627
+ */
628
+ function extractYoutubeId(input) {
629
+ if (!input) return null
630
+
631
+ // Already a raw ID
632
+ if (/^[a-zA-Z0-9_-]{11}$/.test(input.trim())) {
633
+ return input.trim()
634
+ }
635
+
636
+ // Try URL patterns
637
+ try {
638
+ const url = new URL(input)
639
+
640
+ // youtube.com/watch?v=ID
641
+ if (url.searchParams.has('v')) {
642
+ const v = url.searchParams.get('v')
643
+ if (/^[a-zA-Z0-9_-]{11}$/.test(v)) return v
644
+ }
645
+
646
+ // youtu.be/ID or youtube.com/embed/ID or youtube.com/shorts/ID
647
+ const pathMatch = url.pathname.match(/^\/(?:embed\/|shorts\/|v\/)?([a-zA-Z0-9_-]{11})/)
648
+ if (pathMatch) return pathMatch[1]
649
+ } catch {
650
+ // Not a valid URL
651
+ }
652
+
653
+ return null
654
+ }
655
+
656
+ /**
657
+ * Fetch segments from SponsorBlock API and cache them in database
658
+ */
659
+ async function fetchAndCacheSegments(database, youtubeId, logger) {
660
+ const apiUrl = 'https://sponsor.ajay.app'
661
+ const response = await fetch(`${apiUrl}/api/skipSegments?videoID=${youtubeId}`)
662
+
663
+ if (response.status === 404) {
664
+ // No segments found on SponsorBlock — not an error
665
+ return []
666
+ }
667
+
668
+ if (!response.ok) {
669
+ throw new Error(`SponsorBlock API error: ${response.status}`)
670
+ }
671
+
672
+ const apiSegments = await response.json()
673
+
674
+ // Delete old segments
675
+ await database.query(`
676
+ DELETE FROM plugin_sponsorblock_segments WHERE youtube_id = $1
677
+ `, { bind: [youtubeId] })
678
+
679
+ // Insert new segments
680
+ const segments = []
681
+ for (const segment of apiSegments) {
682
+ await database.query(`
683
+ INSERT INTO plugin_sponsorblock_segments
684
+ (youtube_id, segment_uuid, start_time, end_time, category, action_type, votes)
685
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
686
+ ON CONFLICT (segment_uuid) DO NOTHING
687
+ `, { bind: [
688
+ youtubeId,
689
+ segment.UUID,
690
+ segment.segment[0],
691
+ segment.segment[1],
692
+ segment.category,
693
+ segment.actionType || 'skip',
694
+ segment.votes || 0
695
+ ] })
696
+ segments.push({
697
+ segment_uuid: segment.UUID,
698
+ start_time: segment.segment[0],
699
+ end_time: segment.segment[1],
700
+ category: segment.category,
701
+ action_type: segment.actionType || 'skip',
702
+ votes: segment.votes || 0
703
+ })
704
+ }
705
+
706
+ return segments
707
+ }
708
+
709
+ module.exports = { registerRoutes }