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/server/routes.js
ADDED
|
@@ -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 }
|