peertube-plugin-sponsorblock 0.2.1 → 0.3.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.
@@ -210,7 +210,11 @@ function register({ registerHook, peertubeHelpers }) {
210
210
  const currentDiv = document.createElement('div')
211
211
  currentDiv.className = 'sponsorblock-widget-current'
212
212
  if (currentMapping) {
213
- currentDiv.innerHTML = `${currentLabel} <code>${currentMapping.youtube_id}</code>`
213
+ currentDiv.textContent = ''
214
+ currentDiv.appendChild(document.createTextNode(currentLabel + ' '))
215
+ const code = document.createElement('code')
216
+ code.textContent = currentMapping.youtube_id
217
+ currentDiv.appendChild(code)
214
218
  }
215
219
  content.appendChild(currentDiv)
216
220
 
@@ -299,7 +303,11 @@ function register({ registerHook, peertubeHelpers }) {
299
303
  }
300
304
 
301
305
  // Update current mapping display
302
- currentDiv.innerHTML = `${currentLabel} <code>${data.youtubeId}</code>`
306
+ currentDiv.textContent = ''
307
+ currentDiv.appendChild(document.createTextNode(currentLabel + ' '))
308
+ const codeEl = document.createElement('code')
309
+ codeEl.textContent = data.youtubeId
310
+ currentDiv.appendChild(codeEl)
303
311
  input.value = ''
304
312
 
305
313
  } catch (e) {
package/main.js CHANGED
@@ -5,6 +5,73 @@
5
5
 
6
6
  const { registerRoutes } = require('./server/routes')
7
7
  const { getVideoDuration, processVideoFile, findVideoFiles } = require('./server/ffmpeg')
8
+ const { URL } = require('url')
9
+
10
+ /**
11
+ * Validate that an API URL is safe (not targeting internal/private networks)
12
+ * Rejects private IPs, loopback, link-local, and non-HTTPS schemes
13
+ */
14
+ function validateApiUrl(urlString) {
15
+ let parsed
16
+ try {
17
+ parsed = new URL(urlString)
18
+ } catch {
19
+ throw new Error(`Invalid API URL: ${urlString}`)
20
+ }
21
+
22
+ if (parsed.protocol !== 'https:') {
23
+ throw new Error(`API URL must use HTTPS, got: ${parsed.protocol}`)
24
+ }
25
+
26
+ const hostname = parsed.hostname
27
+
28
+ // Reject IPv6 private/loopback
29
+ if (hostname.startsWith('[')) {
30
+ const ipv6 = hostname.slice(1, -1).toLowerCase()
31
+ if (ipv6 === '::1' || ipv6.startsWith('fc') || ipv6.startsWith('fd') || ipv6.startsWith('fe80')) {
32
+ throw new Error(`API URL must not target private/internal addresses: ${hostname}`)
33
+ }
34
+ }
35
+
36
+ // Reject IPv4 private/loopback/link-local ranges
37
+ const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
38
+ if (ipv4Match) {
39
+ const [, a, b] = ipv4Match.map(Number)
40
+ if (
41
+ a === 127 || // 127.0.0.0/8 loopback
42
+ a === 10 || // 10.0.0.0/8 private
43
+ (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private
44
+ (a === 192 && b === 168) || // 192.168.0.0/16 private
45
+ (a === 169 && b === 254) || // 169.254.0.0/16 link-local
46
+ a === 0 // 0.0.0.0/8
47
+ ) {
48
+ throw new Error(`API URL must not target private/internal addresses: ${hostname}`)
49
+ }
50
+ }
51
+
52
+ // Reject localhost by name
53
+ if (hostname === 'localhost' || hostname.endsWith('.local')) {
54
+ throw new Error(`API URL must not target local addresses: ${hostname}`)
55
+ }
56
+
57
+ return parsed.toString()
58
+ }
59
+
60
+ /**
61
+ * Validate a segment from the SponsorBlock API response
62
+ */
63
+ function validateSegment(segment) {
64
+ if (!segment || typeof segment !== 'object') return false
65
+ if (typeof segment.UUID !== 'string' || segment.UUID.length === 0 || segment.UUID.length > 128) return false
66
+ if (!Array.isArray(segment.segment) || segment.segment.length !== 2) return false
67
+ const [start, end] = segment.segment
68
+ if (typeof start !== 'number' || typeof end !== 'number') return false
69
+ if (start < 0 || end < 0 || start >= end) return false
70
+ if (!isFinite(start) || !isFinite(end)) return false
71
+ if (typeof segment.category !== 'string' || segment.category.length === 0 || segment.category.length > 50) return false
72
+ if (segment.votes !== undefined && typeof segment.votes !== 'number') return false
73
+ return true
74
+ }
8
75
 
9
76
  let workerIntervalId = null
10
77
  let syncIntervalId = null
@@ -102,7 +169,7 @@ function registerSettings(registerSetting) {
102
169
  label: 'SponsorBlock API URL',
103
170
  type: 'input',
104
171
  default: 'https://sponsor.ajay.app',
105
- private: false
172
+ private: true
106
173
  })
107
174
 
108
175
  registerSetting({
@@ -230,9 +297,9 @@ function registerImportHooks(registerHook, peertubeHelpers, settingsManager) {
230
297
  target: 'filter:api.video.post-import-url.accept.result',
231
298
  handler: async (result, params) => {
232
299
  try {
233
- const { videoImport } = params
300
+ const { videoImport, video } = params
234
301
 
235
- if (!videoImport || !videoImport.video) {
302
+ if (!videoImport || !video) {
236
303
  return result
237
304
  }
238
305
 
@@ -244,12 +311,12 @@ function registerImportHooks(registerHook, peertubeHelpers, settingsManager) {
244
311
  return result
245
312
  }
246
313
 
247
- logger.info(`Video imported from YouTube: ${youtubeId} -> ${videoImport.video.uuid}`)
314
+ logger.info(`Video imported from YouTube: ${youtubeId} -> ${video.uuid}`)
248
315
 
249
316
  // Save mapping
250
317
  await saveYouTubeMapping(
251
318
  peertubeHelpers,
252
- videoImport.video.uuid,
319
+ video.uuid,
253
320
  youtubeId
254
321
  )
255
322
 
@@ -265,7 +332,7 @@ function registerImportHooks(registerHook, peertubeHelpers, settingsManager) {
265
332
  if (mode === 'remove') {
266
333
  await queueVideoProcessing(
267
334
  peertubeHelpers,
268
- videoImport.video.uuid,
335
+ video.uuid,
269
336
  youtubeId
270
337
  )
271
338
  }
@@ -322,6 +389,7 @@ async function fetchAndCacheSegments(peertubeHelpers, settingsManager, youtubeId
322
389
 
323
390
  try {
324
391
  const apiUrl = await settingsManager.getSetting('api_url') || 'https://sponsor.ajay.app'
392
+ validateApiUrl(apiUrl)
325
393
  const url = `${apiUrl}/api/skipSegments?videoID=${youtubeId}`
326
394
 
327
395
  logger.debug(`Fetching SponsorBlock segments for ${youtubeId}`)
@@ -338,30 +406,42 @@ async function fetchAndCacheSegments(peertubeHelpers, settingsManager, youtubeId
338
406
 
339
407
  const segments = await response.json()
340
408
 
341
- // Delete old segments
342
- await database.query(`
343
- DELETE FROM plugin_sponsorblock_segments WHERE youtube_id = $1
344
- `, { bind: [youtubeId] })
345
-
346
- // Insert new segments
347
- for (const segment of segments) {
409
+ // Delete old and insert new segments in a transaction
410
+ await database.query('BEGIN')
411
+ try {
348
412
  await database.query(`
349
- INSERT INTO plugin_sponsorblock_segments
350
- (youtube_id, segment_uuid, start_time, end_time, category, action_type, votes)
351
- VALUES ($1, $2, $3, $4, $5, $6, $7)
352
- ON CONFLICT (segment_uuid) DO NOTHING
353
- `, { bind: [
354
- youtubeId,
355
- segment.UUID,
356
- segment.segment[0],
357
- segment.segment[1],
358
- segment.category,
359
- segment.actionType || 'skip',
360
- segment.votes || 0
361
- ] })
362
- }
413
+ DELETE FROM plugin_sponsorblock_segments WHERE youtube_id = $1
414
+ `, { bind: [youtubeId] })
415
+
416
+ let cached = 0
417
+ for (const segment of segments) {
418
+ if (!validateSegment(segment)) {
419
+ logger.warn(`Skipping invalid segment from API: ${JSON.stringify(segment).slice(0, 200)}`)
420
+ continue
421
+ }
422
+ await database.query(`
423
+ INSERT INTO plugin_sponsorblock_segments
424
+ (youtube_id, segment_uuid, start_time, end_time, category, action_type, votes)
425
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
426
+ ON CONFLICT (segment_uuid) DO NOTHING
427
+ `, { bind: [
428
+ youtubeId,
429
+ segment.UUID,
430
+ segment.segment[0],
431
+ segment.segment[1],
432
+ segment.category,
433
+ segment.actionType || 'skip',
434
+ segment.votes || 0
435
+ ] })
436
+ cached++
437
+ }
363
438
 
364
- logger.info(`Cached ${segments.length} segments for ${youtubeId}`)
439
+ await database.query('COMMIT')
440
+ logger.info(`Cached ${cached} segments for ${youtubeId}`)
441
+ } catch (txError) {
442
+ await database.query('ROLLBACK')
443
+ throw txError
444
+ }
365
445
 
366
446
  } catch (error) {
367
447
  logger.error(`Failed to fetch segments for ${youtubeId}`, error)
@@ -524,6 +604,7 @@ function startSyncTimer(peertubeHelpers, settingsManager) {
524
604
  )
525
605
 
526
606
  const apiUrl = await settingsManager.getSetting('api_url') || 'https://sponsor.ajay.app'
607
+ validateApiUrl(apiUrl)
527
608
 
528
609
  for (const mapping of (mappings || [])) {
529
610
  try {
@@ -532,26 +613,38 @@ function startSyncTimer(peertubeHelpers, settingsManager) {
532
613
  if (response.ok) {
533
614
  const apiSegments = await response.json()
534
615
 
535
- await database.query(
536
- 'DELETE FROM plugin_sponsorblock_segments WHERE youtube_id = $1',
537
- { bind: [mapping.youtube_id] }
538
- )
539
-
540
- for (const segment of apiSegments) {
541
- await database.query(`
542
- INSERT INTO plugin_sponsorblock_segments
543
- (youtube_id, segment_uuid, start_time, end_time, category, action_type, votes)
544
- VALUES ($1, $2, $3, $4, $5, $6, $7)
545
- ON CONFLICT (segment_uuid) DO NOTHING
546
- `, { bind: [
547
- mapping.youtube_id,
548
- segment.UUID,
549
- segment.segment[0],
550
- segment.segment[1],
551
- segment.category,
552
- segment.actionType || 'skip',
553
- segment.votes || 0
554
- ] })
616
+ await database.query('BEGIN')
617
+ try {
618
+ await database.query(
619
+ 'DELETE FROM plugin_sponsorblock_segments WHERE youtube_id = $1',
620
+ { bind: [mapping.youtube_id] }
621
+ )
622
+
623
+ for (const segment of apiSegments) {
624
+ if (!validateSegment(segment)) {
625
+ logger.warn(`Periodic sync: skipping invalid segment: ${JSON.stringify(segment).slice(0, 200)}`)
626
+ continue
627
+ }
628
+ await database.query(`
629
+ INSERT INTO plugin_sponsorblock_segments
630
+ (youtube_id, segment_uuid, start_time, end_time, category, action_type, votes)
631
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
632
+ ON CONFLICT (segment_uuid) DO NOTHING
633
+ `, { bind: [
634
+ mapping.youtube_id,
635
+ segment.UUID,
636
+ segment.segment[0],
637
+ segment.segment[1],
638
+ segment.category,
639
+ segment.actionType || 'skip',
640
+ segment.votes || 0
641
+ ] })
642
+ }
643
+
644
+ await database.query('COMMIT')
645
+ } catch (txError) {
646
+ await database.query('ROLLBACK')
647
+ throw txError
555
648
  }
556
649
  }
557
650
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peertube-plugin-sponsorblock",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Automatically skip or remove sponsor segments in videos imported from YouTube using the SponsorBlock API",
5
5
  "license": "AGPL-3.0",
6
6
  "engine": {
package/server/ffmpeg.js CHANGED
@@ -172,7 +172,12 @@ async function findVideoFiles(database, videoUuid, storagePath, logger) {
172
172
  )
173
173
 
174
174
  for (const row of (webVideoFiles || [])) {
175
- const filePath = path.join(storagePath, 'web-videos', row.filename)
175
+ const baseDir = path.resolve(storagePath, 'web-videos')
176
+ const filePath = path.resolve(baseDir, row.filename)
177
+ if (!filePath.startsWith(baseDir + path.sep)) {
178
+ logger.warn(`Path traversal blocked for web-video: ${row.filename}`)
179
+ continue
180
+ }
176
181
  if (await fileExists(filePath)) {
177
182
  files.push({ type: 'web-video', path: filePath })
178
183
  }
@@ -191,7 +196,12 @@ async function findVideoFiles(database, videoUuid, storagePath, logger) {
191
196
  )
192
197
 
193
198
  for (const row of (hlsFiles || [])) {
194
- const filePath = path.join(storagePath, 'streaming-playlists', 'hls', videoUuid, row.filename)
199
+ const baseDir = path.resolve(storagePath, 'streaming-playlists', 'hls', videoUuid)
200
+ const filePath = path.resolve(baseDir, row.filename)
201
+ if (!filePath.startsWith(baseDir + path.sep)) {
202
+ logger.warn(`Path traversal blocked for HLS file: ${row.filename}`)
203
+ continue
204
+ }
195
205
  if (await fileExists(filePath)) {
196
206
  files.push({ type: 'hls', path: filePath })
197
207
  }
package/server/routes.js CHANGED
@@ -2,16 +2,74 @@
2
2
  * Server-side API routes
3
3
  */
4
4
 
5
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
6
+
7
+ function isValidUuid(value) {
8
+ return typeof value === 'string' && UUID_REGEX.test(value)
9
+ }
10
+
11
+ /**
12
+ * Simple in-memory token-bucket rate limiter
13
+ * @param {number} maxTokens - Maximum requests allowed in the window
14
+ * @param {number} windowMs - Time window in milliseconds
15
+ */
16
+ function createRateLimiter(maxTokens, windowMs) {
17
+ const buckets = new Map()
18
+
19
+ // Periodically clean up expired entries to avoid memory leaks
20
+ setInterval(() => {
21
+ const now = Date.now()
22
+ for (const [key, bucket] of buckets) {
23
+ if (now - bucket.lastRefill > windowMs * 2) {
24
+ buckets.delete(key)
25
+ }
26
+ }
27
+ }, windowMs).unref()
28
+
29
+ return (req, res, next) => {
30
+ const ip = req.ip || req.connection.remoteAddress || 'unknown'
31
+ const now = Date.now()
32
+
33
+ let bucket = buckets.get(ip)
34
+ if (!bucket) {
35
+ bucket = { tokens: maxTokens, lastRefill: now }
36
+ buckets.set(ip, bucket)
37
+ }
38
+
39
+ // Refill tokens based on elapsed time
40
+ const elapsed = now - bucket.lastRefill
41
+ const refill = Math.floor((elapsed / windowMs) * maxTokens)
42
+ if (refill > 0) {
43
+ bucket.tokens = Math.min(maxTokens, bucket.tokens + refill)
44
+ bucket.lastRefill = now
45
+ }
46
+
47
+ if (bucket.tokens <= 0) {
48
+ return res.status(429).json({ error: 'Too many requests, please try again later' })
49
+ }
50
+
51
+ bucket.tokens--
52
+ next()
53
+ }
54
+ }
55
+
5
56
  async function registerRoutes({ router, peertubeHelpers }) {
6
57
  const logger = peertubeHelpers.logger
7
58
 
59
+ // Rate limiter: 60 requests per minute per IP for public endpoints
60
+ const segmentsRateLimiter = createRateLimiter(60, 60 * 1000)
61
+
8
62
  /**
9
63
  * GET /segments/:videoUuid
10
64
  * Returns SponsorBlock segments for a given video
11
65
  */
12
- router.get('/segments/:videoUuid', async (req, res) => {
66
+ router.get('/segments/:videoUuid', segmentsRateLimiter, async (req, res) => {
13
67
  const videoUuid = req.params.videoUuid
14
68
 
69
+ if (!isValidUuid(videoUuid)) {
70
+ return res.status(400).json({ error: 'Invalid video UUID format' })
71
+ }
72
+
15
73
  try {
16
74
  const database = peertubeHelpers.database
17
75
 
@@ -66,7 +124,17 @@ async function registerRoutes({ router, peertubeHelpers }) {
66
124
  router.get('/mapping/:videoUuid', async (req, res) => {
67
125
  const videoUuid = req.params.videoUuid
68
126
 
127
+ if (!isValidUuid(videoUuid)) {
128
+ return res.status(400).json({ error: 'Invalid video UUID format' })
129
+ }
130
+
69
131
  try {
132
+ // Auth check: admin/moderator only
133
+ const user = await peertubeHelpers.user.getAuthUser(res)
134
+ if (!user || (user.role !== 0 && user.role !== 1)) {
135
+ return res.status(403).json({ error: 'Admin or moderator access required' })
136
+ }
137
+
70
138
  const database = peertubeHelpers.database
71
139
 
72
140
  const [mappings] = await database.query(`
@@ -98,6 +166,10 @@ async function registerRoutes({ router, peertubeHelpers }) {
98
166
  router.post('/mapping/:videoUuid', async (req, res) => {
99
167
  const videoUuid = req.params.videoUuid
100
168
 
169
+ if (!isValidUuid(videoUuid)) {
170
+ return res.status(400).json({ error: 'Invalid video UUID format' })
171
+ }
172
+
101
173
  try {
102
174
  // Auth check: admin/moderator only
103
175
  const user = await peertubeHelpers.user.getAuthUser(res)
@@ -221,6 +293,10 @@ async function registerRoutes({ router, peertubeHelpers }) {
221
293
  router.post('/process/:videoUuid', async (req, res) => {
222
294
  const videoUuid = req.params.videoUuid
223
295
 
296
+ if (!isValidUuid(videoUuid)) {
297
+ return res.status(400).json({ error: 'Invalid video UUID format' })
298
+ }
299
+
224
300
  try {
225
301
  // Auth check: admin/moderator only
226
302
  const user = await peertubeHelpers.user.getAuthUser(res)
@@ -438,6 +514,10 @@ async function registerRoutes({ router, peertubeHelpers }) {
438
514
  router.delete('/mapping/:videoUuid', async (req, res) => {
439
515
  const videoUuid = req.params.videoUuid
440
516
 
517
+ if (!isValidUuid(videoUuid)) {
518
+ return res.status(400).json({ error: 'Invalid video UUID format' })
519
+ }
520
+
441
521
  try {
442
522
  const user = await peertubeHelpers.user.getAuthUser(res)
443
523
  if (!user || user.role !== 0) {
@@ -544,7 +624,17 @@ async function registerRoutes({ router, peertubeHelpers }) {
544
624
  router.post('/sync/:videoUuid', async (req, res) => {
545
625
  const videoUuid = req.params.videoUuid
546
626
 
627
+ if (!isValidUuid(videoUuid)) {
628
+ return res.status(400).json({ error: 'Invalid video UUID format' })
629
+ }
630
+
547
631
  try {
632
+ // Auth check: admin/moderator only
633
+ const user = await peertubeHelpers.user.getAuthUser(res)
634
+ if (!user || (user.role !== 0 && user.role !== 1)) {
635
+ return res.status(403).json({ error: 'Admin or moderator access required' })
636
+ }
637
+
548
638
  const database = peertubeHelpers.database
549
639
 
550
640
  // Get YouTube ID
@@ -573,29 +663,40 @@ async function registerRoutes({ router, peertubeHelpers }) {
573
663
 
574
664
  const segments = await response.json()
575
665
 
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
666
+ // Delete old and insert new segments in a transaction
667
+ await database.query('BEGIN')
582
668
  let insertedCount = 0
583
- for (const segment of segments) {
669
+ try {
584
670
  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++
671
+ DELETE FROM plugin_sponsorblock_segments WHERE youtube_id = $1
672
+ `, { bind: [youtubeId] })
673
+
674
+ for (const segment of segments) {
675
+ if (!validateSegment(segment)) {
676
+ logger.warn(`Skipping invalid segment from API: ${JSON.stringify(segment).slice(0, 200)}`)
677
+ continue
678
+ }
679
+ await database.query(`
680
+ INSERT INTO plugin_sponsorblock_segments
681
+ (youtube_id, segment_uuid, start_time, end_time, category, action_type, votes)
682
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
683
+ ON CONFLICT (segment_uuid) DO NOTHING
684
+ `, { bind: [
685
+ youtubeId,
686
+ segment.UUID,
687
+ segment.segment[0],
688
+ segment.segment[1],
689
+ segment.category,
690
+ segment.actionType || 'skip',
691
+ segment.votes || 0
692
+ ] })
693
+ insertedCount++
694
+ }
695
+
696
+ await database.query('COMMIT')
697
+ } catch (txError) {
698
+ await database.query('ROLLBACK')
699
+ throw txError
599
700
  }
600
701
 
601
702
  // Update last_sync timestamp
@@ -653,6 +754,23 @@ function extractYoutubeId(input) {
653
754
  return null
654
755
  }
655
756
 
757
+ /**
758
+ * Validate a segment from the SponsorBlock API response
759
+ * Returns true if the segment has valid shape, false otherwise
760
+ */
761
+ function validateSegment(segment) {
762
+ if (!segment || typeof segment !== 'object') return false
763
+ if (typeof segment.UUID !== 'string' || segment.UUID.length === 0 || segment.UUID.length > 128) return false
764
+ if (!Array.isArray(segment.segment) || segment.segment.length !== 2) return false
765
+ const [start, end] = segment.segment
766
+ if (typeof start !== 'number' || typeof end !== 'number') return false
767
+ if (start < 0 || end < 0 || start >= end) return false
768
+ if (!isFinite(start) || !isFinite(end)) return false
769
+ if (typeof segment.category !== 'string' || segment.category.length === 0 || segment.category.length > 50) return false
770
+ if (segment.votes !== undefined && typeof segment.votes !== 'number') return false
771
+ return true
772
+ }
773
+
656
774
  /**
657
775
  * Fetch segments from SponsorBlock API and cache them in database
658
776
  */
@@ -671,39 +789,49 @@ async function fetchAndCacheSegments(database, youtubeId, logger) {
671
789
 
672
790
  const apiSegments = await response.json()
673
791
 
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) {
792
+ // Delete old and insert new segments in a transaction
793
+ await database.query('BEGIN')
794
+ try {
682
795
  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
- }
796
+ DELETE FROM plugin_sponsorblock_segments WHERE youtube_id = $1
797
+ `, { bind: [youtubeId] })
798
+
799
+ const segments = []
800
+ for (const segment of apiSegments) {
801
+ if (!validateSegment(segment)) {
802
+ logger.warn(`Skipping invalid segment from API: ${JSON.stringify(segment).slice(0, 200)}`)
803
+ continue
804
+ }
805
+ await database.query(`
806
+ INSERT INTO plugin_sponsorblock_segments
807
+ (youtube_id, segment_uuid, start_time, end_time, category, action_type, votes)
808
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
809
+ ON CONFLICT (segment_uuid) DO NOTHING
810
+ `, { bind: [
811
+ youtubeId,
812
+ segment.UUID,
813
+ segment.segment[0],
814
+ segment.segment[1],
815
+ segment.category,
816
+ segment.actionType || 'skip',
817
+ segment.votes || 0
818
+ ] })
819
+ segments.push({
820
+ segment_uuid: segment.UUID,
821
+ start_time: segment.segment[0],
822
+ end_time: segment.segment[1],
823
+ category: segment.category,
824
+ action_type: segment.actionType || 'skip',
825
+ votes: segment.votes || 0
826
+ })
827
+ }
705
828
 
706
- return segments
829
+ await database.query('COMMIT')
830
+ return segments
831
+ } catch (txError) {
832
+ await database.query('ROLLBACK')
833
+ throw txError
834
+ }
707
835
  }
708
836
 
709
837
  module.exports = { registerRoutes }