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/client/admin.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin dashboard client script
|
|
3
|
+
* Scope: admin-plugin
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function register({ registerHook, peertubeHelpers }) {
|
|
7
|
+
registerHook({
|
|
8
|
+
target: 'action:admin-plugin-settings.init',
|
|
9
|
+
handler: ({ npmName }) => {
|
|
10
|
+
if (npmName !== 'peertube-plugin-sponsorblock') return
|
|
11
|
+
initDashboard(peertubeHelpers)
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function initDashboard(peertubeHelpers) {
|
|
17
|
+
const container = document.getElementById('sponsorblock-admin-dashboard')
|
|
18
|
+
if (!container) return
|
|
19
|
+
|
|
20
|
+
const baseUrl = peertubeHelpers.getBaseRouterRoute()
|
|
21
|
+
const t = (key) => peertubeHelpers.translate(key)
|
|
22
|
+
|
|
23
|
+
const root = document.createElement('div')
|
|
24
|
+
root.className = 'sponsorblock-admin-root'
|
|
25
|
+
container.appendChild(root)
|
|
26
|
+
|
|
27
|
+
// Stats row
|
|
28
|
+
const statsRow = document.createElement('div')
|
|
29
|
+
statsRow.className = 'sponsorblock-admin-stats'
|
|
30
|
+
root.appendChild(statsRow)
|
|
31
|
+
|
|
32
|
+
const statCards = [
|
|
33
|
+
{ id: 'mapped', key: 'admin-stats-mapped', value: '—' },
|
|
34
|
+
{ id: 'segments', key: 'admin-stats-segments', value: '—' },
|
|
35
|
+
{ id: 'time', key: 'admin-stats-time-saved', value: '—' },
|
|
36
|
+
{ id: 'queue', key: 'admin-stats-queue', value: '—' }
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
for (const card of statCards) {
|
|
40
|
+
const el = document.createElement('div')
|
|
41
|
+
el.className = 'sponsorblock-admin-stat'
|
|
42
|
+
const valEl = document.createElement('div')
|
|
43
|
+
valEl.className = 'sponsorblock-admin-stat-value'
|
|
44
|
+
valEl.id = `sponsorblock-stat-${card.id}`
|
|
45
|
+
valEl.textContent = card.value
|
|
46
|
+
const labelEl = document.createElement('div')
|
|
47
|
+
labelEl.className = 'sponsorblock-admin-stat-label'
|
|
48
|
+
labelEl.textContent = await t(card.key) || card.key
|
|
49
|
+
el.appendChild(valEl)
|
|
50
|
+
el.appendChild(labelEl)
|
|
51
|
+
statsRow.appendChild(el)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Action buttons
|
|
55
|
+
const actionsRow = document.createElement('div')
|
|
56
|
+
actionsRow.className = 'sponsorblock-admin-actions'
|
|
57
|
+
root.appendChild(actionsRow)
|
|
58
|
+
|
|
59
|
+
const scanBtn = await createButton(t, 'admin-btn-scan', 'sponsorblock-admin-btn')
|
|
60
|
+
const syncAllBtn = await createButton(t, 'admin-btn-sync-all', 'sponsorblock-admin-btn sponsorblock-admin-btn--secondary')
|
|
61
|
+
const processAllBtn = await createButton(t, 'admin-btn-process-all', 'sponsorblock-admin-btn sponsorblock-admin-btn--secondary')
|
|
62
|
+
|
|
63
|
+
actionsRow.appendChild(scanBtn)
|
|
64
|
+
actionsRow.appendChild(syncAllBtn)
|
|
65
|
+
actionsRow.appendChild(processAllBtn)
|
|
66
|
+
|
|
67
|
+
// Status message area
|
|
68
|
+
const messageEl = document.createElement('div')
|
|
69
|
+
messageEl.className = 'sponsorblock-admin-message'
|
|
70
|
+
messageEl.style.display = 'none'
|
|
71
|
+
root.appendChild(messageEl)
|
|
72
|
+
|
|
73
|
+
// Mappings table
|
|
74
|
+
const tableWrap = document.createElement('div')
|
|
75
|
+
tableWrap.className = 'sponsorblock-admin-table-wrap'
|
|
76
|
+
root.appendChild(tableWrap)
|
|
77
|
+
|
|
78
|
+
// Load data
|
|
79
|
+
await refreshStats(baseUrl, peertubeHelpers)
|
|
80
|
+
await refreshTable(baseUrl, peertubeHelpers, t, tableWrap, messageEl)
|
|
81
|
+
|
|
82
|
+
// Button handlers
|
|
83
|
+
scanBtn.addEventListener('click', async () => {
|
|
84
|
+
scanBtn.disabled = true
|
|
85
|
+
showMessage(messageEl, await t('admin-scan-started') || 'Scanning…', 'success')
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const resp = await apiFetch(baseUrl, '/scan', peertubeHelpers, { method: 'POST' })
|
|
89
|
+
const data = await resp.json()
|
|
90
|
+
const msg = (await t('admin-scan-result') || 'Scanned {scanned}, mapped {mapped} new video(s).')
|
|
91
|
+
.replace('{scanned}', data.scanned)
|
|
92
|
+
.replace('{mapped}', data.mapped)
|
|
93
|
+
showMessage(messageEl, msg, 'success')
|
|
94
|
+
await refreshStats(baseUrl, peertubeHelpers)
|
|
95
|
+
await refreshTable(baseUrl, peertubeHelpers, t, tableWrap, messageEl)
|
|
96
|
+
} catch (err) {
|
|
97
|
+
showMessage(messageEl, err.message, 'error')
|
|
98
|
+
} finally {
|
|
99
|
+
scanBtn.disabled = false
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
syncAllBtn.addEventListener('click', async () => {
|
|
104
|
+
syncAllBtn.disabled = true
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const resp = await apiFetch(baseUrl, '/sync-all', peertubeHelpers, { method: 'POST' })
|
|
108
|
+
const data = await resp.json()
|
|
109
|
+
const msg = (await t('admin-sync-started') || 'Syncing all mappings ({total})…')
|
|
110
|
+
.replace('{total}', data.total)
|
|
111
|
+
showMessage(messageEl, msg, 'success')
|
|
112
|
+
// Refresh after a short delay to let background sync start
|
|
113
|
+
setTimeout(async () => {
|
|
114
|
+
await refreshStats(baseUrl, peertubeHelpers)
|
|
115
|
+
await refreshTable(baseUrl, peertubeHelpers, t, tableWrap, messageEl)
|
|
116
|
+
}, 2000)
|
|
117
|
+
} catch (err) {
|
|
118
|
+
showMessage(messageEl, err.message, 'error')
|
|
119
|
+
} finally {
|
|
120
|
+
syncAllBtn.disabled = false
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
processAllBtn.addEventListener('click', async () => {
|
|
125
|
+
processAllBtn.disabled = true
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const resp = await apiFetch(baseUrl, '/process-all', peertubeHelpers, { method: 'POST' })
|
|
129
|
+
const data = await resp.json()
|
|
130
|
+
const msg = (await t('admin-process-result') || 'Queued {queued} video(s) for processing.')
|
|
131
|
+
.replace('{queued}', data.queued)
|
|
132
|
+
showMessage(messageEl, msg, 'success')
|
|
133
|
+
await refreshStats(baseUrl, peertubeHelpers)
|
|
134
|
+
await refreshTable(baseUrl, peertubeHelpers, t, tableWrap, messageEl)
|
|
135
|
+
} catch (err) {
|
|
136
|
+
showMessage(messageEl, err.message, 'error')
|
|
137
|
+
} finally {
|
|
138
|
+
processAllBtn.disabled = false
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Fetch and update stats cards
|
|
145
|
+
*/
|
|
146
|
+
async function refreshStats(baseUrl, peertubeHelpers) {
|
|
147
|
+
try {
|
|
148
|
+
const resp = await apiFetch(baseUrl, '/admin/stats', peertubeHelpers)
|
|
149
|
+
const data = await resp.json()
|
|
150
|
+
|
|
151
|
+
setText('sponsorblock-stat-mapped', data.mapped_videos)
|
|
152
|
+
setText('sponsorblock-stat-segments', data.total_segments)
|
|
153
|
+
setText('sponsorblock-stat-time', formatDuration(data.total_time_saved))
|
|
154
|
+
setText('sponsorblock-stat-queue', data.queue.pending)
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.error('[SponsorBlock] Failed to load stats:', err)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Fetch and render mappings table
|
|
162
|
+
*/
|
|
163
|
+
async function refreshTable(baseUrl, peertubeHelpers, t, tableWrap, messageEl) {
|
|
164
|
+
try {
|
|
165
|
+
const resp = await apiFetch(baseUrl, '/admin/mappings', peertubeHelpers)
|
|
166
|
+
const data = await resp.json()
|
|
167
|
+
const mappings = data.mappings || []
|
|
168
|
+
|
|
169
|
+
tableWrap.innerHTML = ''
|
|
170
|
+
|
|
171
|
+
if (mappings.length === 0) {
|
|
172
|
+
const empty = document.createElement('div')
|
|
173
|
+
empty.className = 'sponsorblock-admin-empty'
|
|
174
|
+
empty.textContent = await t('admin-no-mappings') || 'No mappings yet.'
|
|
175
|
+
tableWrap.appendChild(empty)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const table = document.createElement('table')
|
|
180
|
+
table.className = 'sponsorblock-admin-table'
|
|
181
|
+
|
|
182
|
+
// Header
|
|
183
|
+
const thead = document.createElement('thead')
|
|
184
|
+
const headerRow = document.createElement('tr')
|
|
185
|
+
const headers = [
|
|
186
|
+
'admin-table-uuid', 'admin-table-youtube', 'admin-table-segments',
|
|
187
|
+
'admin-table-saved', 'admin-table-synced', 'admin-table-queue', 'admin-table-actions'
|
|
188
|
+
]
|
|
189
|
+
for (const key of headers) {
|
|
190
|
+
const th = document.createElement('th')
|
|
191
|
+
th.textContent = await t(key) || key
|
|
192
|
+
headerRow.appendChild(th)
|
|
193
|
+
}
|
|
194
|
+
thead.appendChild(headerRow)
|
|
195
|
+
table.appendChild(thead)
|
|
196
|
+
|
|
197
|
+
// Body
|
|
198
|
+
const tbody = document.createElement('tbody')
|
|
199
|
+
for (const mapping of mappings) {
|
|
200
|
+
const tr = document.createElement('tr')
|
|
201
|
+
|
|
202
|
+
// UUID (truncated)
|
|
203
|
+
const tdUuid = document.createElement('td')
|
|
204
|
+
const uuidCode = document.createElement('code')
|
|
205
|
+
uuidCode.textContent = mapping.peertube_uuid.substring(0, 8) + '…'
|
|
206
|
+
uuidCode.title = mapping.peertube_uuid
|
|
207
|
+
tdUuid.appendChild(uuidCode)
|
|
208
|
+
tr.appendChild(tdUuid)
|
|
209
|
+
|
|
210
|
+
// YouTube ID
|
|
211
|
+
const tdYt = document.createElement('td')
|
|
212
|
+
const ytCode = document.createElement('code')
|
|
213
|
+
ytCode.textContent = mapping.youtube_id
|
|
214
|
+
tdYt.appendChild(ytCode)
|
|
215
|
+
tr.appendChild(tdYt)
|
|
216
|
+
|
|
217
|
+
// Segments count
|
|
218
|
+
const tdSeg = document.createElement('td')
|
|
219
|
+
tdSeg.textContent = mapping.segment_count
|
|
220
|
+
tr.appendChild(tdSeg)
|
|
221
|
+
|
|
222
|
+
// Time saved
|
|
223
|
+
const tdTime = document.createElement('td')
|
|
224
|
+
tdTime.textContent = formatDuration(mapping.time_saved)
|
|
225
|
+
tr.appendChild(tdTime)
|
|
226
|
+
|
|
227
|
+
// Last sync
|
|
228
|
+
const tdSync = document.createElement('td')
|
|
229
|
+
if (mapping.last_sync) {
|
|
230
|
+
tdSync.textContent = formatRelativeTime(mapping.last_sync)
|
|
231
|
+
tdSync.title = new Date(mapping.last_sync).toLocaleString()
|
|
232
|
+
} else {
|
|
233
|
+
tdSync.textContent = await t('admin-last-sync-never') || 'Never'
|
|
234
|
+
}
|
|
235
|
+
tr.appendChild(tdSync)
|
|
236
|
+
|
|
237
|
+
// Queue status
|
|
238
|
+
const tdQueue = document.createElement('td')
|
|
239
|
+
if (mapping.queue_status) {
|
|
240
|
+
const badge = document.createElement('span')
|
|
241
|
+
badge.className = `sponsorblock-admin-badge sponsorblock-admin-badge--${mapping.queue_status}`
|
|
242
|
+
const queueKey = `admin-queue-${mapping.queue_status}`
|
|
243
|
+
badge.textContent = await t(queueKey) || mapping.queue_status
|
|
244
|
+
if (mapping.queue_error) badge.title = mapping.queue_error
|
|
245
|
+
tdQueue.appendChild(badge)
|
|
246
|
+
} else {
|
|
247
|
+
tdQueue.textContent = '—'
|
|
248
|
+
}
|
|
249
|
+
tr.appendChild(tdQueue)
|
|
250
|
+
|
|
251
|
+
// Actions
|
|
252
|
+
const tdActions = document.createElement('td')
|
|
253
|
+
const actionsDiv = document.createElement('div')
|
|
254
|
+
actionsDiv.className = 'sponsorblock-admin-row-actions'
|
|
255
|
+
|
|
256
|
+
const syncBtn = document.createElement('button')
|
|
257
|
+
syncBtn.className = 'sponsorblock-admin-row-btn sponsorblock-admin-row-btn--sync'
|
|
258
|
+
syncBtn.textContent = await t('admin-action-sync') || 'Sync'
|
|
259
|
+
syncBtn.addEventListener('click', async () => {
|
|
260
|
+
syncBtn.disabled = true
|
|
261
|
+
try {
|
|
262
|
+
await apiFetch(baseUrl, `/sync/${mapping.peertube_uuid}`, peertubeHelpers, { method: 'POST' })
|
|
263
|
+
await refreshStats(baseUrl, peertubeHelpers)
|
|
264
|
+
await refreshTable(baseUrl, peertubeHelpers, t, tableWrap, messageEl)
|
|
265
|
+
} catch (err) {
|
|
266
|
+
showMessage(messageEl, err.message, 'error')
|
|
267
|
+
} finally {
|
|
268
|
+
syncBtn.disabled = false
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
const processBtn = document.createElement('button')
|
|
273
|
+
processBtn.className = 'sponsorblock-admin-row-btn sponsorblock-admin-row-btn--process'
|
|
274
|
+
processBtn.textContent = await t('admin-action-process') || 'Process'
|
|
275
|
+
processBtn.addEventListener('click', async () => {
|
|
276
|
+
processBtn.disabled = true
|
|
277
|
+
try {
|
|
278
|
+
await apiFetch(baseUrl, `/process/${mapping.peertube_uuid}`, peertubeHelpers, { method: 'POST' })
|
|
279
|
+
await refreshStats(baseUrl, peertubeHelpers)
|
|
280
|
+
await refreshTable(baseUrl, peertubeHelpers, t, tableWrap, messageEl)
|
|
281
|
+
} catch (err) {
|
|
282
|
+
showMessage(messageEl, err.message, 'error')
|
|
283
|
+
} finally {
|
|
284
|
+
processBtn.disabled = false
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
const deleteBtn = document.createElement('button')
|
|
289
|
+
deleteBtn.className = 'sponsorblock-admin-row-btn sponsorblock-admin-row-btn--delete'
|
|
290
|
+
deleteBtn.textContent = await t('admin-action-delete') || 'Delete'
|
|
291
|
+
deleteBtn.addEventListener('click', async () => {
|
|
292
|
+
const confirmMsg = await t('admin-delete-confirm') || 'Delete mapping for this video?'
|
|
293
|
+
if (!confirm(confirmMsg)) return
|
|
294
|
+
|
|
295
|
+
deleteBtn.disabled = true
|
|
296
|
+
try {
|
|
297
|
+
await apiFetch(baseUrl, `/mapping/${mapping.peertube_uuid}`, peertubeHelpers, { method: 'DELETE' })
|
|
298
|
+
showMessage(messageEl, await t('admin-delete-success') || 'Mapping deleted.', 'success')
|
|
299
|
+
await refreshStats(baseUrl, peertubeHelpers)
|
|
300
|
+
await refreshTable(baseUrl, peertubeHelpers, t, tableWrap, messageEl)
|
|
301
|
+
} catch (err) {
|
|
302
|
+
showMessage(messageEl, err.message, 'error')
|
|
303
|
+
} finally {
|
|
304
|
+
deleteBtn.disabled = false
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
actionsDiv.appendChild(syncBtn)
|
|
309
|
+
actionsDiv.appendChild(processBtn)
|
|
310
|
+
actionsDiv.appendChild(deleteBtn)
|
|
311
|
+
tdActions.appendChild(actionsDiv)
|
|
312
|
+
tr.appendChild(tdActions)
|
|
313
|
+
|
|
314
|
+
tbody.appendChild(tr)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
table.appendChild(tbody)
|
|
318
|
+
tableWrap.appendChild(table)
|
|
319
|
+
} catch (err) {
|
|
320
|
+
console.error('[SponsorBlock] Failed to load mappings:', err)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Helper: API fetch with auth
|
|
326
|
+
*/
|
|
327
|
+
async function apiFetch(baseUrl, path, peertubeHelpers, options = {}) {
|
|
328
|
+
const resp = await fetch(baseUrl + path, {
|
|
329
|
+
...options,
|
|
330
|
+
headers: {
|
|
331
|
+
'Content-Type': 'application/json',
|
|
332
|
+
...peertubeHelpers.getAuthHeader(),
|
|
333
|
+
...(options.headers || {})
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
if (!resp.ok) {
|
|
338
|
+
const data = await resp.json().catch(() => ({}))
|
|
339
|
+
throw new Error(data.error || `HTTP ${resp.status}`)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return resp
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Helper: Create a translated button
|
|
347
|
+
*/
|
|
348
|
+
async function createButton(t, key, className) {
|
|
349
|
+
const btn = document.createElement('button')
|
|
350
|
+
btn.className = className
|
|
351
|
+
btn.textContent = await t(key) || key
|
|
352
|
+
return btn
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Helper: Set text content by ID
|
|
357
|
+
*/
|
|
358
|
+
function setText(id, value) {
|
|
359
|
+
const el = document.getElementById(id)
|
|
360
|
+
if (el) el.textContent = value
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Helper: Format seconds as human-readable duration
|
|
365
|
+
*/
|
|
366
|
+
function formatDuration(seconds) {
|
|
367
|
+
if (!seconds || seconds <= 0) return '0s'
|
|
368
|
+
const s = Math.round(seconds)
|
|
369
|
+
if (s < 60) return `${s}s`
|
|
370
|
+
const m = Math.floor(s / 60)
|
|
371
|
+
const rem = s % 60
|
|
372
|
+
if (m < 60) return rem > 0 ? `${m}m ${rem}s` : `${m}m`
|
|
373
|
+
const h = Math.floor(m / 60)
|
|
374
|
+
const remM = m % 60
|
|
375
|
+
return remM > 0 ? `${h}h ${remM}m` : `${h}h`
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Helper: Format ISO date as relative time
|
|
380
|
+
*/
|
|
381
|
+
function formatRelativeTime(isoDate) {
|
|
382
|
+
const diff = Date.now() - new Date(isoDate).getTime()
|
|
383
|
+
const seconds = Math.floor(diff / 1000)
|
|
384
|
+
if (seconds < 60) return '<1m ago'
|
|
385
|
+
const minutes = Math.floor(seconds / 60)
|
|
386
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
387
|
+
const hours = Math.floor(minutes / 60)
|
|
388
|
+
if (hours < 24) return `${hours}h ago`
|
|
389
|
+
const days = Math.floor(hours / 24)
|
|
390
|
+
return `${days}d ago`
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Helper: Show status message
|
|
395
|
+
*/
|
|
396
|
+
function showMessage(el, text, type) {
|
|
397
|
+
el.textContent = text
|
|
398
|
+
el.className = `sponsorblock-admin-message sponsorblock-admin-message--${type}`
|
|
399
|
+
el.style.display = 'block'
|
|
400
|
+
setTimeout(() => { el.style.display = 'none' }, 8000)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export { register }
|
package/client/common.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common client-side code
|
|
3
|
+
* Loaded on all pages
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function register({ registerHook, peertubeHelpers }) {
|
|
7
|
+
console.log('[SponsorBlock] Common client script loaded')
|
|
8
|
+
|
|
9
|
+
// Add custom styles or global functionality here if needed
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export { register }
|