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.
@@ -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 }
@@ -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 }