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.
package/main.js ADDED
@@ -0,0 +1,582 @@
1
+ /**
2
+ * PeerTube Plugin SponsorBlock
3
+ * Main entry point for server-side plugin
4
+ */
5
+
6
+ const { registerRoutes } = require('./server/routes')
7
+ const { getVideoDuration, processVideoFile, findVideoFiles } = require('./server/ffmpeg')
8
+
9
+ let workerIntervalId = null
10
+ let syncIntervalId = null
11
+ let lastSyncCheck = 0
12
+
13
+ async function register({
14
+ registerHook,
15
+ registerSetting,
16
+ settingsManager,
17
+ storageManager,
18
+ videoCategoryManager,
19
+ videoLicenceManager,
20
+ videoLanguageManager,
21
+ peertubeHelpers,
22
+ getRouter
23
+ }) {
24
+ const logger = peertubeHelpers.logger
25
+
26
+ logger.info('Registering PeerTube SponsorBlock plugin')
27
+
28
+ // Register settings
29
+ registerSettings(registerSetting)
30
+
31
+ // Initialize database tables
32
+ await initDatabase(peertubeHelpers)
33
+
34
+ // Register API routes
35
+ const router = getRouter()
36
+ await registerRoutes({ router, peertubeHelpers })
37
+
38
+ // Register hooks for video import
39
+ registerImportHooks(registerHook, peertubeHelpers, settingsManager)
40
+
41
+ // Start background worker for processing
42
+ await startWorker(peertubeHelpers, settingsManager)
43
+
44
+ // Start periodic sync timer
45
+ startSyncTimer(peertubeHelpers, settingsManager)
46
+
47
+ logger.info('PeerTube SponsorBlock plugin registered successfully')
48
+ }
49
+
50
+ async function unregister() {
51
+ if (workerIntervalId) {
52
+ clearInterval(workerIntervalId)
53
+ workerIntervalId = null
54
+ }
55
+ if (syncIntervalId) {
56
+ clearInterval(syncIntervalId)
57
+ syncIntervalId = null
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Register plugin settings
63
+ */
64
+ function registerSettings(registerSetting) {
65
+ // Mode: skip (client-side) or remove (permanent deletion)
66
+ registerSetting({
67
+ name: 'mode',
68
+ label: 'Operation mode',
69
+ type: 'select',
70
+ options: [
71
+ { label: 'Skip segments (client-side)', value: 'skip' },
72
+ { label: 'Remove segments permanently (experimental)', value: 'remove' }
73
+ ],
74
+ default: 'skip',
75
+ descriptionHTML: 'Skip mode: Segments are skipped during playback. Remove mode: Segments are permanently deleted from video files (requires FFmpeg).'
76
+ })
77
+
78
+ // Enable/disable categories
79
+ const categories = [
80
+ { name: 'sponsor', label: 'Sponsors', default: true },
81
+ { name: 'selfpromo', label: 'Self-promotion', default: true },
82
+ { name: 'interaction', label: 'Interaction reminders', default: true },
83
+ { name: 'intro', label: 'Intros', default: false },
84
+ { name: 'outro', label: 'Outros', default: false },
85
+ { name: 'preview', label: 'Previews/Recaps', default: false },
86
+ { name: 'music_offtopic', label: 'Off-topic music', default: false },
87
+ { name: 'filler', label: 'Filler content', default: false }
88
+ ]
89
+
90
+ categories.forEach(cat => {
91
+ registerSetting({
92
+ name: `category_${cat.name}`,
93
+ label: `Skip/Remove ${cat.label}`,
94
+ type: 'input-checkbox',
95
+ default: cat.default
96
+ })
97
+ })
98
+
99
+ // Advanced settings
100
+ registerSetting({
101
+ name: 'api_url',
102
+ label: 'SponsorBlock API URL',
103
+ type: 'input',
104
+ default: 'https://sponsor.ajay.app',
105
+ private: false
106
+ })
107
+
108
+ registerSetting({
109
+ name: 'cache_duration',
110
+ label: 'Cache duration (hours)',
111
+ type: 'input',
112
+ default: 24,
113
+ descriptionHTML: 'How long to cache SponsorBlock segments before refreshing'
114
+ })
115
+
116
+ registerSetting({
117
+ name: 'show_notifications',
118
+ label: 'Show skip notifications',
119
+ type: 'input-checkbox',
120
+ default: true,
121
+ descriptionHTML: 'Display a notification when a segment is skipped'
122
+ })
123
+
124
+ registerSetting({
125
+ name: 'storage_path',
126
+ label: 'PeerTube storage path',
127
+ type: 'input',
128
+ default: '/var/www/peertube/storage',
129
+ private: true,
130
+ descriptionHTML: 'Absolute path to the PeerTube storage directory. Required for remove mode.'
131
+ })
132
+
133
+ registerSetting({
134
+ name: 'sync_interval',
135
+ label: 'Periodic sync interval (hours)',
136
+ type: 'input',
137
+ default: '0',
138
+ descriptionHTML: 'Automatically re-fetch segments for all mapped videos at this interval. Set to 0 to disable.'
139
+ })
140
+
141
+ registerSetting({
142
+ name: 'admin-dashboard-container',
143
+ type: 'html',
144
+ html: '<div id="sponsorblock-admin-dashboard"></div>'
145
+ })
146
+ }
147
+
148
+ /**
149
+ * Initialize database tables
150
+ */
151
+ async function initDatabase(peertubeHelpers) {
152
+ const logger = peertubeHelpers.logger
153
+ const database = peertubeHelpers.database
154
+
155
+ try {
156
+ // Table: YouTube ID to PeerTube UUID mapping
157
+ await database.query(`
158
+ CREATE TABLE IF NOT EXISTS plugin_sponsorblock_mapping (
159
+ peertube_uuid UUID PRIMARY KEY,
160
+ youtube_id VARCHAR(11) NOT NULL,
161
+ created_at TIMESTAMP DEFAULT NOW(),
162
+ last_sync TIMESTAMP
163
+ );
164
+ `)
165
+
166
+ await database.query(`
167
+ CREATE INDEX IF NOT EXISTS idx_sponsorblock_youtube_id
168
+ ON plugin_sponsorblock_mapping(youtube_id);
169
+ `)
170
+
171
+ // Table: SponsorBlock segments cache
172
+ await database.query(`
173
+ CREATE TABLE IF NOT EXISTS plugin_sponsorblock_segments (
174
+ id SERIAL PRIMARY KEY,
175
+ youtube_id VARCHAR(11) NOT NULL,
176
+ segment_uuid VARCHAR(128) NOT NULL,
177
+ start_time FLOAT NOT NULL,
178
+ end_time FLOAT NOT NULL,
179
+ category VARCHAR(50) NOT NULL,
180
+ action_type VARCHAR(20) NOT NULL,
181
+ votes INTEGER DEFAULT 0,
182
+ created_at TIMESTAMP DEFAULT NOW(),
183
+ UNIQUE(segment_uuid)
184
+ );
185
+ `)
186
+
187
+ await database.query(`
188
+ CREATE INDEX IF NOT EXISTS idx_segments_youtube_id
189
+ ON plugin_sponsorblock_segments(youtube_id);
190
+ `)
191
+
192
+ // Table: Processing queue for video modification
193
+ await database.query(`
194
+ CREATE TABLE IF NOT EXISTS plugin_sponsorblock_processing_queue (
195
+ id SERIAL PRIMARY KEY,
196
+ video_uuid UUID NOT NULL,
197
+ youtube_id VARCHAR(11) NOT NULL,
198
+ status VARCHAR(20) DEFAULT 'pending',
199
+ priority INTEGER DEFAULT 0,
200
+ segments JSONB NOT NULL,
201
+ error TEXT,
202
+ retry_count INTEGER DEFAULT 0,
203
+ max_retries INTEGER DEFAULT 3,
204
+ created_at TIMESTAMP DEFAULT NOW(),
205
+ started_at TIMESTAMP,
206
+ completed_at TIMESTAMP
207
+ );
208
+ `)
209
+
210
+ await database.query(`
211
+ CREATE INDEX IF NOT EXISTS idx_queue_status
212
+ ON plugin_sponsorblock_processing_queue(status, priority, created_at);
213
+ `)
214
+
215
+ logger.info('Database tables initialized successfully')
216
+ } catch (error) {
217
+ logger.error('Failed to initialize database tables', error)
218
+ throw error
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Register hooks for video import
224
+ */
225
+ function registerImportHooks(registerHook, peertubeHelpers, settingsManager) {
226
+ const logger = peertubeHelpers.logger
227
+
228
+ // Hook: After video import from URL
229
+ registerHook({
230
+ target: 'filter:api.video.post-import-url.accept.result',
231
+ handler: async (result, params) => {
232
+ try {
233
+ const { videoImport } = params
234
+
235
+ if (!videoImport || !videoImport.video) {
236
+ return result
237
+ }
238
+
239
+ const targetUrl = videoImport.targetUrl
240
+ const youtubeId = extractYouTubeId(targetUrl)
241
+
242
+ if (!youtubeId) {
243
+ logger.debug(`No YouTube ID found in URL: ${targetUrl}`)
244
+ return result
245
+ }
246
+
247
+ logger.info(`Video imported from YouTube: ${youtubeId} -> ${videoImport.video.uuid}`)
248
+
249
+ // Save mapping
250
+ await saveYouTubeMapping(
251
+ peertubeHelpers,
252
+ videoImport.video.uuid,
253
+ youtubeId
254
+ )
255
+
256
+ // Fetch and cache SponsorBlock segments
257
+ await fetchAndCacheSegments(
258
+ peertubeHelpers,
259
+ settingsManager,
260
+ youtubeId
261
+ )
262
+
263
+ // Queue for processing if remove mode is enabled
264
+ const mode = await settingsManager.getSetting('mode')
265
+ if (mode === 'remove') {
266
+ await queueVideoProcessing(
267
+ peertubeHelpers,
268
+ videoImport.video.uuid,
269
+ youtubeId
270
+ )
271
+ }
272
+
273
+ } catch (error) {
274
+ logger.error('Error in post-import hook', error)
275
+ }
276
+
277
+ return result
278
+ }
279
+ })
280
+ }
281
+
282
+ /**
283
+ * Extract YouTube video ID from URL
284
+ */
285
+ function extractYouTubeId(url) {
286
+ if (!url) return null
287
+
288
+ const patterns = [
289
+ /(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/,
290
+ /youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/,
291
+ /youtube\.com\/v\/([a-zA-Z0-9_-]{11})/
292
+ ]
293
+
294
+ for (const pattern of patterns) {
295
+ const match = url.match(pattern)
296
+ if (match) return match[1]
297
+ }
298
+
299
+ return null
300
+ }
301
+
302
+ /**
303
+ * Save YouTube ID to PeerTube UUID mapping
304
+ */
305
+ async function saveYouTubeMapping(peertubeHelpers, peertubeUuid, youtubeId) {
306
+ const database = peertubeHelpers.database
307
+
308
+ await database.query(`
309
+ INSERT INTO plugin_sponsorblock_mapping (peertube_uuid, youtube_id)
310
+ VALUES ($1, $2)
311
+ ON CONFLICT (peertube_uuid) DO UPDATE
312
+ SET youtube_id = $2, last_sync = NOW()
313
+ `, { bind: [peertubeUuid, youtubeId] })
314
+ }
315
+
316
+ /**
317
+ * Fetch segments from SponsorBlock API and cache them
318
+ */
319
+ async function fetchAndCacheSegments(peertubeHelpers, settingsManager, youtubeId) {
320
+ const logger = peertubeHelpers.logger
321
+ const database = peertubeHelpers.database
322
+
323
+ try {
324
+ const apiUrl = await settingsManager.getSetting('api_url') || 'https://sponsor.ajay.app'
325
+ const url = `${apiUrl}/api/skipSegments?videoID=${youtubeId}`
326
+
327
+ logger.debug(`Fetching SponsorBlock segments for ${youtubeId}`)
328
+
329
+ const response = await fetch(url)
330
+
331
+ if (!response.ok) {
332
+ if (response.status === 404) {
333
+ logger.debug(`No segments found for ${youtubeId}`)
334
+ return
335
+ }
336
+ throw new Error(`SponsorBlock API error: ${response.status}`)
337
+ }
338
+
339
+ const segments = await response.json()
340
+
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) {
348
+ 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
+ }
363
+
364
+ logger.info(`Cached ${segments.length} segments for ${youtubeId}`)
365
+
366
+ } catch (error) {
367
+ logger.error(`Failed to fetch segments for ${youtubeId}`, error)
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Queue video for processing (removal mode)
373
+ */
374
+ async function queueVideoProcessing(peertubeHelpers, videoUuid, youtubeId) {
375
+ const logger = peertubeHelpers.logger
376
+ const database = peertubeHelpers.database
377
+
378
+ try {
379
+ // Get segments
380
+ const [segments] = await database.query(`
381
+ SELECT start_time, end_time, category
382
+ FROM plugin_sponsorblock_segments
383
+ WHERE youtube_id = $1
384
+ ORDER BY start_time ASC
385
+ `, { bind: [youtubeId] })
386
+
387
+ if (!segments || segments.length === 0) {
388
+ logger.debug(`No segments to process for ${videoUuid}`)
389
+ return
390
+ }
391
+
392
+ // Add to processing queue
393
+ await database.query(`
394
+ INSERT INTO plugin_sponsorblock_processing_queue
395
+ (video_uuid, youtube_id, segments, priority)
396
+ VALUES ($1, $2, $3, 10)
397
+ `, { bind: [videoUuid, youtubeId, JSON.stringify(segments)] })
398
+
399
+ logger.info(`Queued video ${videoUuid} for processing (${segments.length} segments)`)
400
+
401
+ } catch (error) {
402
+ logger.error(`Failed to queue video ${videoUuid}`, error)
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Start background worker for processing queue
408
+ */
409
+ async function startWorker(peertubeHelpers, settingsManager) {
410
+ const logger = peertubeHelpers.logger
411
+ const database = peertubeHelpers.database
412
+
413
+ let processing = false
414
+
415
+ workerIntervalId = setInterval(async () => {
416
+ if (processing) return
417
+
418
+ try {
419
+ const mode = await settingsManager.getSetting('mode')
420
+ if (mode !== 'remove') return
421
+
422
+ processing = true
423
+
424
+ // Claim next pending job using advisory lock pattern
425
+ const [claimed] = await database.query(`
426
+ UPDATE plugin_sponsorblock_processing_queue
427
+ SET status = 'processing', started_at = NOW()
428
+ WHERE id = (
429
+ SELECT id FROM plugin_sponsorblock_processing_queue
430
+ WHERE status = 'pending'
431
+ ORDER BY priority DESC, created_at ASC
432
+ LIMIT 1
433
+ FOR UPDATE SKIP LOCKED
434
+ )
435
+ RETURNING *
436
+ `)
437
+
438
+ if (!claimed || claimed.length === 0) {
439
+ return
440
+ }
441
+
442
+ const job = claimed[0]
443
+ logger.info(`Worker claimed job ${job.id} for video ${job.video_uuid}`)
444
+
445
+ try {
446
+ const storagePath = await settingsManager.getSetting('storage_path') || '/var/www/peertube/storage'
447
+ const videoFiles = await findVideoFiles(database, job.video_uuid, storagePath, logger)
448
+
449
+ if (videoFiles.length === 0) {
450
+ throw new Error('No local video files found')
451
+ }
452
+
453
+ const segments = typeof job.segments === 'string' ? JSON.parse(job.segments) : job.segments
454
+
455
+ for (const file of videoFiles) {
456
+ logger.info(`Processing file: ${file.type} - ${file.path}`)
457
+ const duration = await getVideoDuration(file.path)
458
+ await processVideoFile(file.path, segments, duration, logger)
459
+ }
460
+
461
+ // Mark as done
462
+ await database.query(`
463
+ UPDATE plugin_sponsorblock_processing_queue
464
+ SET status = 'done', completed_at = NOW()
465
+ WHERE id = $1
466
+ `, { bind: [job.id] })
467
+
468
+ logger.info(`Job ${job.id} completed successfully`)
469
+
470
+ } catch (error) {
471
+ logger.error(`Job ${job.id} failed`, error)
472
+
473
+ const newRetryCount = (job.retry_count || 0) + 1
474
+ const maxRetries = job.max_retries || 3
475
+
476
+ if (newRetryCount >= maxRetries) {
477
+ await database.query(`
478
+ UPDATE plugin_sponsorblock_processing_queue
479
+ SET status = 'error', error = $2, retry_count = $3, completed_at = NOW()
480
+ WHERE id = $1
481
+ `, { bind: [job.id, String(error.message), newRetryCount] })
482
+ } else {
483
+ await database.query(`
484
+ UPDATE plugin_sponsorblock_processing_queue
485
+ SET status = 'pending', error = $2, retry_count = $3, started_at = NULL
486
+ WHERE id = $1
487
+ `, { bind: [job.id, String(error.message), newRetryCount] })
488
+ }
489
+ }
490
+ } catch (error) {
491
+ logger.error('Worker error', error)
492
+ } finally {
493
+ processing = false
494
+ }
495
+ }, 30000)
496
+
497
+ logger.info('Background worker started (30s polling)')
498
+ }
499
+
500
+ /**
501
+ * Start periodic sync timer
502
+ * Checks every 5 minutes if sync_interval has elapsed, then re-fetches all segments
503
+ */
504
+ function startSyncTimer(peertubeHelpers, settingsManager) {
505
+ const logger = peertubeHelpers.logger
506
+ const database = peertubeHelpers.database
507
+ const CHECK_INTERVAL = 5 * 60 * 1000 // 5 minutes
508
+
509
+ syncIntervalId = setInterval(async () => {
510
+ try {
511
+ const intervalHours = parseFloat(await settingsManager.getSetting('sync_interval')) || 0
512
+ if (intervalHours <= 0) return
513
+
514
+ const intervalMs = intervalHours * 3600 * 1000
515
+ const now = Date.now()
516
+
517
+ if (lastSyncCheck > 0 && (now - lastSyncCheck) < intervalMs) return
518
+
519
+ lastSyncCheck = now
520
+ logger.info('Periodic sync: starting')
521
+
522
+ const [mappings] = await database.query(
523
+ 'SELECT peertube_uuid, youtube_id FROM plugin_sponsorblock_mapping'
524
+ )
525
+
526
+ const apiUrl = await settingsManager.getSetting('api_url') || 'https://sponsor.ajay.app'
527
+
528
+ for (const mapping of (mappings || [])) {
529
+ try {
530
+ const response = await fetch(`${apiUrl}/api/skipSegments?videoID=${mapping.youtube_id}`)
531
+
532
+ if (response.ok) {
533
+ const apiSegments = await response.json()
534
+
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
+ ] })
555
+ }
556
+ }
557
+
558
+ await database.query(
559
+ 'UPDATE plugin_sponsorblock_mapping SET last_sync = NOW() WHERE peertube_uuid = $1',
560
+ { bind: [mapping.peertube_uuid] }
561
+ )
562
+ } catch (err) {
563
+ logger.error(`Periodic sync: failed for ${mapping.youtube_id}`, err)
564
+ }
565
+
566
+ // Rate limit: 200ms between requests
567
+ await new Promise(resolve => setTimeout(resolve, 200))
568
+ }
569
+
570
+ logger.info(`Periodic sync: complete (${(mappings || []).length} mappings)`)
571
+ } catch (error) {
572
+ logger.error('Periodic sync error', error)
573
+ }
574
+ }, CHECK_INTERVAL)
575
+
576
+ logger.info('Periodic sync timer started (5-min check interval)')
577
+ }
578
+
579
+ module.exports = {
580
+ register,
581
+ unregister
582
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "peertube-plugin-sponsorblock",
3
+ "version": "0.1.0",
4
+ "description": "Automatically skip or remove sponsor segments in videos imported from YouTube using the SponsorBlock API",
5
+ "license": "AGPL-3.0",
6
+ "engine": {
7
+ "peertube": ">=6.0.0"
8
+ },
9
+ "keywords": [
10
+ "peertube",
11
+ "plugin",
12
+ "sponsorblock",
13
+ "youtube",
14
+ "sponsor",
15
+ "skip",
16
+ "segments"
17
+ ],
18
+ "homepage": "https://git.ut0pia.org/jbl/peertube-plugin-sponsorblock",
19
+ "bugs": "https://git.ut0pia.org/jbl/peertube-plugin-sponsorblock/issues",
20
+ "author": {
21
+ "name": "Jean-Baptiste L.",
22
+ "email": "git@lemee.co"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://git.ut0pia.org/jbl/peertube-plugin-sponsorblock.git"
27
+ },
28
+ "library": "./main.js",
29
+ "staticDirs": {
30
+ "images": "assets/images"
31
+ },
32
+ "css": [
33
+ "assets/style.css"
34
+ ],
35
+ "clientScripts": [
36
+ {
37
+ "script": "client/common.js",
38
+ "scopes": ["common"]
39
+ },
40
+ {
41
+ "script": "client/video-watch.js",
42
+ "scopes": ["video-watch"]
43
+ },
44
+ {
45
+ "script": "client/admin.js",
46
+ "scopes": ["admin-plugin"]
47
+ }
48
+ ],
49
+ "translations": {
50
+ "en": "./languages/en.json",
51
+ "fr": "./languages/fr.json"
52
+ },
53
+ "scripts": {
54
+ "build": "node scripts/build.js",
55
+ "lint": "eslint .",
56
+ "test": "echo 'No tests yet' && exit 0"
57
+ },
58
+ "devDependencies": {
59
+ "eslint": "^8.0.0"
60
+ }
61
+ }