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/LICENSE +21 -0
- package/README.md +161 -0
- package/assets/style.css +409 -0
- package/client/admin.js +403 -0
- package/client/common.js +12 -0
- package/client/video-watch.js +383 -0
- package/languages/en.json +77 -0
- package/languages/fr.json +77 -0
- package/main.js +582 -0
- package/package.json +61 -0
- package/server/ffmpeg.js +234 -0
- package/server/routes.js +709 -0
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
|
+
}
|