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
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side code for video watch page
|
|
3
|
+
* Implements automatic segment skipping
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function register({ registerHook, peertubeHelpers }) {
|
|
7
|
+
console.log('[SponsorBlock] Video watch script loaded')
|
|
8
|
+
|
|
9
|
+
let segments = []
|
|
10
|
+
let skippedSegments = new Set()
|
|
11
|
+
let currentVideo = null
|
|
12
|
+
let skippingActive = false
|
|
13
|
+
let playerRef = null
|
|
14
|
+
|
|
15
|
+
// Hook: When video is loaded
|
|
16
|
+
registerHook({
|
|
17
|
+
target: 'action:video-watch.video.loaded',
|
|
18
|
+
handler: async ({ video, videojs }) => {
|
|
19
|
+
console.log('[SponsorBlock] Video loaded:', video.uuid)
|
|
20
|
+
|
|
21
|
+
currentVideo = video
|
|
22
|
+
segments = []
|
|
23
|
+
skippedSegments.clear()
|
|
24
|
+
skippingActive = false
|
|
25
|
+
playerRef = videojs
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
// Fetch segments for this video
|
|
29
|
+
segments = await fetchSegments(video.uuid)
|
|
30
|
+
|
|
31
|
+
if (segments.length > 0) {
|
|
32
|
+
console.log(`[SponsorBlock] Found ${segments.length} segments to skip`)
|
|
33
|
+
setupSegmentSkipping(videojs)
|
|
34
|
+
} else {
|
|
35
|
+
console.log('[SponsorBlock] No segments found for this video')
|
|
36
|
+
}
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error('[SponsorBlock] Failed to fetch segments:', error)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Show mapping widget for admins/moderators
|
|
42
|
+
try {
|
|
43
|
+
const user = await peertubeHelpers.getUser()
|
|
44
|
+
if (user && (user.role === 0 || user.role === 1)) {
|
|
45
|
+
renderMappingWidget(video.uuid)
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
// Not logged in or can't get user — skip widget
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fetch segments from server
|
|
55
|
+
*/
|
|
56
|
+
async function fetchSegments(videoUuid) {
|
|
57
|
+
try {
|
|
58
|
+
const baseUrl = window.location.origin
|
|
59
|
+
const response = await fetch(
|
|
60
|
+
`${baseUrl}/plugins/sponsorblock/router/segments/${videoUuid}`,
|
|
61
|
+
{
|
|
62
|
+
method: 'GET',
|
|
63
|
+
headers: peertubeHelpers.getAuthHeader()
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
if (response.status === 404) {
|
|
69
|
+
return [] // No segments found
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`Failed to fetch segments: ${response.status}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const data = await response.json()
|
|
75
|
+
return data.segments || []
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error('[SponsorBlock] Error fetching segments:', error)
|
|
78
|
+
return []
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Setup segment skipping on the video player
|
|
84
|
+
*/
|
|
85
|
+
function setupSegmentSkipping(player) {
|
|
86
|
+
if (!player) return
|
|
87
|
+
|
|
88
|
+
// Avoid doubling listeners if skipping was already set up
|
|
89
|
+
if (skippingActive) return
|
|
90
|
+
skippingActive = true
|
|
91
|
+
|
|
92
|
+
// Access the native HTML5 <video> element (PeerTube v8 wraps Video.js)
|
|
93
|
+
const videoEl = player.el
|
|
94
|
+
? player.el().querySelector('video')
|
|
95
|
+
: document.querySelector('.vjs-tech')
|
|
96
|
+
|
|
97
|
+
if (!videoEl) {
|
|
98
|
+
console.error('[SponsorBlock] Could not find video element')
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let lastCheckTime = 0
|
|
103
|
+
|
|
104
|
+
videoEl.addEventListener('timeupdate', () => {
|
|
105
|
+
const currentTime = videoEl.currentTime
|
|
106
|
+
|
|
107
|
+
// Throttle checks to avoid performance issues
|
|
108
|
+
if (Math.abs(currentTime - lastCheckTime) < 0.5) {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
lastCheckTime = currentTime
|
|
112
|
+
|
|
113
|
+
// Check if we're in a segment to skip
|
|
114
|
+
for (const segment of segments) {
|
|
115
|
+
const segmentKey = `${segment.start_time}-${segment.end_time}`
|
|
116
|
+
|
|
117
|
+
if (currentTime >= segment.start_time && currentTime < segment.end_time) {
|
|
118
|
+
// Skip this segment if not already skipped
|
|
119
|
+
if (!skippedSegments.has(segmentKey)) {
|
|
120
|
+
console.log(`[SponsorBlock] Skipping ${segment.category} segment: ${segment.start_time}s - ${segment.end_time}s`)
|
|
121
|
+
|
|
122
|
+
videoEl.currentTime = segment.end_time
|
|
123
|
+
skippedSegments.add(segmentKey)
|
|
124
|
+
|
|
125
|
+
// Show notification
|
|
126
|
+
showSkipNotification(segment)
|
|
127
|
+
}
|
|
128
|
+
break
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// Add visual indicators to the progress bar
|
|
134
|
+
addProgressBarMarkers(player)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Show notification when a segment is skipped
|
|
139
|
+
*/
|
|
140
|
+
function showSkipNotification(segment) {
|
|
141
|
+
const categoryLabels = {
|
|
142
|
+
sponsor: 'Sponsor',
|
|
143
|
+
selfpromo: 'Self-promotion',
|
|
144
|
+
interaction: 'Interaction reminder',
|
|
145
|
+
intro: 'Intro',
|
|
146
|
+
outro: 'Outro',
|
|
147
|
+
preview: 'Preview',
|
|
148
|
+
music_offtopic: 'Off-topic music',
|
|
149
|
+
filler: 'Filler'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const label = categoryLabels[segment.category] || segment.category
|
|
153
|
+
const duration = (segment.end_time - segment.start_time).toFixed(1)
|
|
154
|
+
|
|
155
|
+
peertubeHelpers.notifier.info(
|
|
156
|
+
`Skipped ${label} (${duration}s)`,
|
|
157
|
+
'SponsorBlock',
|
|
158
|
+
3000
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Render the mapping widget for admins/moderators
|
|
164
|
+
*/
|
|
165
|
+
async function renderMappingWidget(videoUuid) {
|
|
166
|
+
// Remove any existing widget
|
|
167
|
+
const existing = document.querySelector('.sponsorblock-widget')
|
|
168
|
+
if (existing) existing.remove()
|
|
169
|
+
|
|
170
|
+
// Find the container below the player
|
|
171
|
+
const container = document.querySelector('.video-info')
|
|
172
|
+
if (!container) return
|
|
173
|
+
|
|
174
|
+
const widget = document.createElement('div')
|
|
175
|
+
widget.className = 'sponsorblock-widget'
|
|
176
|
+
|
|
177
|
+
// Check for existing mapping
|
|
178
|
+
let currentMapping = null
|
|
179
|
+
try {
|
|
180
|
+
const baseUrl = window.location.origin
|
|
181
|
+
const resp = await fetch(
|
|
182
|
+
`${baseUrl}/plugins/sponsorblock/router/mapping/${videoUuid}`,
|
|
183
|
+
{ headers: peertubeHelpers.getAuthHeader() }
|
|
184
|
+
)
|
|
185
|
+
if (resp.ok) {
|
|
186
|
+
currentMapping = await resp.json()
|
|
187
|
+
}
|
|
188
|
+
} catch (e) {
|
|
189
|
+
// No mapping yet
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const translate = (key) => peertubeHelpers.translate(key)
|
|
193
|
+
|
|
194
|
+
const label = await translate('mapping-label') || 'SponsorBlock'
|
|
195
|
+
const placeholder = await translate('mapping-placeholder') || 'YouTube ID or URL'
|
|
196
|
+
const linkBtn = await translate('mapping-link-btn') || 'Link'
|
|
197
|
+
const currentLabel = await translate('mapping-current') || 'Linked to:'
|
|
198
|
+
|
|
199
|
+
// Build toggle label
|
|
200
|
+
const toggle = document.createElement('span')
|
|
201
|
+
toggle.className = 'sponsorblock-widget-toggle'
|
|
202
|
+
toggle.textContent = `▶ ${label}`
|
|
203
|
+
widget.appendChild(toggle)
|
|
204
|
+
|
|
205
|
+
// Collapsible content
|
|
206
|
+
const content = document.createElement('div')
|
|
207
|
+
content.style.display = 'none'
|
|
208
|
+
|
|
209
|
+
// Show current mapping if any
|
|
210
|
+
const currentDiv = document.createElement('div')
|
|
211
|
+
currentDiv.className = 'sponsorblock-widget-current'
|
|
212
|
+
if (currentMapping) {
|
|
213
|
+
currentDiv.innerHTML = `${currentLabel} <code>${currentMapping.youtube_id}</code>`
|
|
214
|
+
}
|
|
215
|
+
content.appendChild(currentDiv)
|
|
216
|
+
|
|
217
|
+
// Form row
|
|
218
|
+
const form = document.createElement('div')
|
|
219
|
+
form.className = 'sponsorblock-widget-form'
|
|
220
|
+
|
|
221
|
+
const input = document.createElement('input')
|
|
222
|
+
input.type = 'text'
|
|
223
|
+
input.className = 'sponsorblock-widget-input'
|
|
224
|
+
input.placeholder = placeholder
|
|
225
|
+
form.appendChild(input)
|
|
226
|
+
|
|
227
|
+
const btn = document.createElement('button')
|
|
228
|
+
btn.className = 'sponsorblock-widget-btn'
|
|
229
|
+
btn.textContent = linkBtn
|
|
230
|
+
form.appendChild(btn)
|
|
231
|
+
|
|
232
|
+
content.appendChild(form)
|
|
233
|
+
|
|
234
|
+
// Status message
|
|
235
|
+
const status = document.createElement('div')
|
|
236
|
+
status.className = 'sponsorblock-widget-status'
|
|
237
|
+
content.appendChild(status)
|
|
238
|
+
|
|
239
|
+
widget.appendChild(content)
|
|
240
|
+
|
|
241
|
+
// Toggle open/close
|
|
242
|
+
toggle.addEventListener('click', () => {
|
|
243
|
+
const open = content.style.display !== 'none'
|
|
244
|
+
content.style.display = open ? 'none' : 'block'
|
|
245
|
+
toggle.textContent = `${open ? '▶' : '▼'} ${label}`
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// Submit mapping
|
|
249
|
+
btn.addEventListener('click', async () => {
|
|
250
|
+
const value = input.value.trim()
|
|
251
|
+
if (!value) return
|
|
252
|
+
|
|
253
|
+
btn.disabled = true
|
|
254
|
+
status.textContent = ''
|
|
255
|
+
status.className = 'sponsorblock-widget-status'
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const baseUrl = window.location.origin
|
|
259
|
+
const resp = await fetch(
|
|
260
|
+
`${baseUrl}/plugins/sponsorblock/router/mapping/${videoUuid}`,
|
|
261
|
+
{
|
|
262
|
+
method: 'POST',
|
|
263
|
+
headers: {
|
|
264
|
+
'Content-Type': 'application/json',
|
|
265
|
+
...peertubeHelpers.getAuthHeader()
|
|
266
|
+
},
|
|
267
|
+
body: JSON.stringify({ youtubeId: value })
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
const data = await resp.json()
|
|
272
|
+
|
|
273
|
+
if (!resp.ok) {
|
|
274
|
+
const errorMsg = data.error === 'Invalid YouTube ID format'
|
|
275
|
+
? (await translate('mapping-invalid-id') || 'Invalid YouTube ID.')
|
|
276
|
+
: (await translate('mapping-error') || 'Error linking video.')
|
|
277
|
+
status.textContent = errorMsg
|
|
278
|
+
status.classList.add('error')
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Update segments and activate skipping
|
|
283
|
+
segments = data.segments || []
|
|
284
|
+
skippedSegments.clear()
|
|
285
|
+
|
|
286
|
+
if (segments.length > 0) {
|
|
287
|
+
const successMsg = (await translate('mapping-success') || 'Linked! {count} segment(s) found.')
|
|
288
|
+
.replace('{count}', segments.length)
|
|
289
|
+
status.textContent = successMsg
|
|
290
|
+
status.classList.add('success')
|
|
291
|
+
|
|
292
|
+
if (playerRef) {
|
|
293
|
+
setupSegmentSkipping(playerRef)
|
|
294
|
+
addProgressBarMarkers(playerRef)
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
status.textContent = await translate('mapping-no-segments') || 'Linked, but no segments found on SponsorBlock.'
|
|
298
|
+
status.classList.add('success')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Update current mapping display
|
|
302
|
+
currentDiv.innerHTML = `${currentLabel} <code>${data.youtubeId}</code>`
|
|
303
|
+
input.value = ''
|
|
304
|
+
|
|
305
|
+
} catch (e) {
|
|
306
|
+
status.textContent = await translate('mapping-error') || 'Error linking video.'
|
|
307
|
+
status.classList.add('error')
|
|
308
|
+
} finally {
|
|
309
|
+
btn.disabled = false
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
// Allow Enter key to submit
|
|
314
|
+
input.addEventListener('keydown', (e) => {
|
|
315
|
+
if (e.key === 'Enter') btn.click()
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
container.prepend(widget)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Add visual markers to the progress bar
|
|
323
|
+
*/
|
|
324
|
+
function addProgressBarMarkers(player) {
|
|
325
|
+
if (!player || segments.length === 0) return
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
// Access seek bar element with fallback for PeerTube v8 wrapper
|
|
329
|
+
let seekBarEl
|
|
330
|
+
try {
|
|
331
|
+
seekBarEl = player.controlBar.progressControl.seekBar.el()
|
|
332
|
+
} catch {
|
|
333
|
+
seekBarEl = document.querySelector('.vjs-progress-holder')
|
|
334
|
+
}
|
|
335
|
+
if (!seekBarEl) return
|
|
336
|
+
|
|
337
|
+
// Access the native HTML5 <video> element for duration
|
|
338
|
+
const videoEl = player.el
|
|
339
|
+
? player.el().querySelector('video')
|
|
340
|
+
: document.querySelector('.vjs-tech')
|
|
341
|
+
if (!videoEl) return
|
|
342
|
+
|
|
343
|
+
const duration = videoEl.duration
|
|
344
|
+
if (!duration || duration === Infinity || isNaN(duration)) {
|
|
345
|
+
// Wait for duration to be available
|
|
346
|
+
videoEl.addEventListener('durationchange', () => addProgressBarMarkers(player), { once: true })
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Remove existing markers
|
|
351
|
+
const existingMarkers = seekBarEl.querySelectorAll('.sponsorblock-marker')
|
|
352
|
+
existingMarkers.forEach(marker => marker.remove())
|
|
353
|
+
|
|
354
|
+
// Add markers for each segment
|
|
355
|
+
segments.forEach(segment => {
|
|
356
|
+
const startPercent = (segment.start_time / duration) * 100
|
|
357
|
+
const widthPercent = ((segment.end_time - segment.start_time) / duration) * 100
|
|
358
|
+
|
|
359
|
+
const marker = document.createElement('div')
|
|
360
|
+
marker.className = 'sponsorblock-marker'
|
|
361
|
+
marker.dataset.category = segment.category
|
|
362
|
+
marker.style.cssText = `
|
|
363
|
+
position: absolute;
|
|
364
|
+
top: 0;
|
|
365
|
+
bottom: 0;
|
|
366
|
+
left: ${startPercent}%;
|
|
367
|
+
width: ${widthPercent}%;
|
|
368
|
+
pointer-events: none;
|
|
369
|
+
z-index: 30;
|
|
370
|
+
`
|
|
371
|
+
marker.title = `${segment.category}: ${segment.start_time}s - ${segment.end_time}s`
|
|
372
|
+
|
|
373
|
+
seekBarEl.appendChild(marker)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
console.log(`[SponsorBlock] Added ${segments.length} progress bar markers`)
|
|
377
|
+
} catch (error) {
|
|
378
|
+
console.error('[SponsorBlock] Failed to add progress bar markers:', error)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export { register }
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"operation-mode": "Operation mode",
|
|
3
|
+
"skip-segments": "Skip segments (client-side)",
|
|
4
|
+
"remove-segments": "Remove segments permanently (experimental)",
|
|
5
|
+
"mode-description": "Skip mode: Segments are skipped during playback. Remove mode: Segments are permanently deleted from video files (requires FFmpeg).",
|
|
6
|
+
"category-sponsor": "Sponsors",
|
|
7
|
+
"category-selfpromo": "Self-promotion",
|
|
8
|
+
"category-interaction": "Interaction reminders",
|
|
9
|
+
"category-intro": "Intros",
|
|
10
|
+
"category-outro": "Outros",
|
|
11
|
+
"category-preview": "Previews/Recaps",
|
|
12
|
+
"category-music": "Off-topic music",
|
|
13
|
+
"category-filler": "Filler content",
|
|
14
|
+
"api-url": "SponsorBlock API URL",
|
|
15
|
+
"cache-duration": "Cache duration (hours)",
|
|
16
|
+
"cache-description": "How long to cache SponsorBlock segments before refreshing",
|
|
17
|
+
"show-notifications": "Show skip notifications",
|
|
18
|
+
"notifications-description": "Display a notification when a segment is skipped",
|
|
19
|
+
"skipped-segment": "Skipped {category} ({duration}s)",
|
|
20
|
+
"no-segments": "No sponsor segments found for this video",
|
|
21
|
+
"mapping-label": "SponsorBlock",
|
|
22
|
+
"mapping-placeholder": "YouTube ID or URL",
|
|
23
|
+
"mapping-link-btn": "Link",
|
|
24
|
+
"mapping-success": "Linked! {count} segment(s) found.",
|
|
25
|
+
"mapping-error": "Error linking video.",
|
|
26
|
+
"mapping-invalid-id": "Invalid YouTube ID.",
|
|
27
|
+
"mapping-current": "Linked to:",
|
|
28
|
+
"mapping-no-segments": "Linked, but no segments found on SponsorBlock.",
|
|
29
|
+
"storage-path": "PeerTube storage path",
|
|
30
|
+
"storage-path-description": "Absolute path to the PeerTube storage directory. Required for remove mode.",
|
|
31
|
+
"process-queued": "Video queued for processing ({count} segments).",
|
|
32
|
+
"process-already-queued": "This video is already queued or being processed.",
|
|
33
|
+
"process-no-segments": "No segments found to process for this video.",
|
|
34
|
+
"process-no-mapping": "No YouTube mapping found for this video.",
|
|
35
|
+
"process-all-queued": "{count} video(s) queued for processing.",
|
|
36
|
+
"process-error": "An error occurred while queuing video for processing.",
|
|
37
|
+
|
|
38
|
+
"sync-interval": "Periodic sync interval (hours)",
|
|
39
|
+
"sync-interval-description": "Automatically re-fetch segments for all mapped videos at this interval. Set to 0 to disable.",
|
|
40
|
+
|
|
41
|
+
"admin-stats-mapped": "Mapped Videos",
|
|
42
|
+
"admin-stats-segments": "Total Segments",
|
|
43
|
+
"admin-stats-time-saved": "Time Saved",
|
|
44
|
+
"admin-stats-queue": "Queue Pending",
|
|
45
|
+
|
|
46
|
+
"admin-btn-scan": "Scan Imports",
|
|
47
|
+
"admin-btn-sync-all": "Sync All",
|
|
48
|
+
"admin-btn-process-all": "Process All",
|
|
49
|
+
|
|
50
|
+
"admin-table-uuid": "Video UUID",
|
|
51
|
+
"admin-table-youtube": "YouTube ID",
|
|
52
|
+
"admin-table-segments": "Segments",
|
|
53
|
+
"admin-table-saved": "Time Saved",
|
|
54
|
+
"admin-table-synced": "Last Sync",
|
|
55
|
+
"admin-table-queue": "Queue",
|
|
56
|
+
"admin-table-actions": "Actions",
|
|
57
|
+
|
|
58
|
+
"admin-action-sync": "Sync",
|
|
59
|
+
"admin-action-process": "Process",
|
|
60
|
+
"admin-action-delete": "Delete",
|
|
61
|
+
|
|
62
|
+
"admin-scan-started": "Scanning imports…",
|
|
63
|
+
"admin-scan-result": "Scanned {scanned}, mapped {mapped} new video(s).",
|
|
64
|
+
"admin-sync-started": "Syncing all mappings ({total})…",
|
|
65
|
+
"admin-process-result": "Queued {queued} video(s) for processing.",
|
|
66
|
+
"admin-delete-confirm": "Delete mapping for this video?",
|
|
67
|
+
"admin-delete-success": "Mapping deleted.",
|
|
68
|
+
"admin-no-mappings": "No mappings yet. Use Scan Imports or link videos manually.",
|
|
69
|
+
|
|
70
|
+
"admin-queue-pending": "Pending",
|
|
71
|
+
"admin-queue-processing": "Processing",
|
|
72
|
+
"admin-queue-done": "Done",
|
|
73
|
+
"admin-queue-error": "Error",
|
|
74
|
+
|
|
75
|
+
"admin-last-sync-never": "Never",
|
|
76
|
+
"admin-last-sync-ago": "{time} ago"
|
|
77
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"operation-mode": "Mode d'opération",
|
|
3
|
+
"skip-segments": "Sauter les segments (côté client)",
|
|
4
|
+
"remove-segments": "Supprimer les segments définitivement (expérimental)",
|
|
5
|
+
"mode-description": "Mode saut : Les segments sont sautés pendant la lecture. Mode suppression : Les segments sont définitivement supprimés des fichiers vidéo (nécessite FFmpeg).",
|
|
6
|
+
"category-sponsor": "Sponsors",
|
|
7
|
+
"category-selfpromo": "Auto-promotion",
|
|
8
|
+
"category-interaction": "Rappels d'interaction",
|
|
9
|
+
"category-intro": "Intros",
|
|
10
|
+
"category-outro": "Outros",
|
|
11
|
+
"category-preview": "Aperçus/Récapitulatifs",
|
|
12
|
+
"category-music": "Musique hors-sujet",
|
|
13
|
+
"category-filler": "Contenu de remplissage",
|
|
14
|
+
"api-url": "URL de l'API SponsorBlock",
|
|
15
|
+
"cache-duration": "Durée du cache (heures)",
|
|
16
|
+
"cache-description": "Durée de conservation des segments SponsorBlock en cache avant actualisation",
|
|
17
|
+
"show-notifications": "Afficher les notifications de saut",
|
|
18
|
+
"notifications-description": "Afficher une notification lorsqu'un segment est sauté",
|
|
19
|
+
"skipped-segment": "{category} sauté ({duration}s)",
|
|
20
|
+
"no-segments": "Aucun segment sponsor trouvé pour cette vidéo",
|
|
21
|
+
"mapping-label": "SponsorBlock",
|
|
22
|
+
"mapping-placeholder": "ID YouTube ou URL",
|
|
23
|
+
"mapping-link-btn": "Lier",
|
|
24
|
+
"mapping-success": "Lié ! {count} segment(s) trouvé(s).",
|
|
25
|
+
"mapping-error": "Erreur lors de la liaison.",
|
|
26
|
+
"mapping-invalid-id": "ID YouTube invalide.",
|
|
27
|
+
"mapping-current": "Lié à :",
|
|
28
|
+
"mapping-no-segments": "Lié, mais aucun segment trouvé sur SponsorBlock.",
|
|
29
|
+
"storage-path": "Chemin du stockage PeerTube",
|
|
30
|
+
"storage-path-description": "Chemin absolu vers le répertoire de stockage PeerTube. Requis pour le mode suppression.",
|
|
31
|
+
"process-queued": "Vidéo mise en file d'attente ({count} segments).",
|
|
32
|
+
"process-already-queued": "Cette vidéo est déjà en file d'attente ou en cours de traitement.",
|
|
33
|
+
"process-no-segments": "Aucun segment à traiter pour cette vidéo.",
|
|
34
|
+
"process-no-mapping": "Aucune correspondance YouTube trouvée pour cette vidéo.",
|
|
35
|
+
"process-all-queued": "{count} vidéo(s) mise(s) en file d'attente.",
|
|
36
|
+
"process-error": "Une erreur est survenue lors de la mise en file d'attente.",
|
|
37
|
+
|
|
38
|
+
"sync-interval": "Intervalle de synchronisation périodique (heures)",
|
|
39
|
+
"sync-interval-description": "Re-télécharger automatiquement les segments pour toutes les vidéos liées à cet intervalle. 0 pour désactiver.",
|
|
40
|
+
|
|
41
|
+
"admin-stats-mapped": "Vidéos liées",
|
|
42
|
+
"admin-stats-segments": "Segments totaux",
|
|
43
|
+
"admin-stats-time-saved": "Temps économisé",
|
|
44
|
+
"admin-stats-queue": "File d'attente",
|
|
45
|
+
|
|
46
|
+
"admin-btn-scan": "Scanner les imports",
|
|
47
|
+
"admin-btn-sync-all": "Tout synchroniser",
|
|
48
|
+
"admin-btn-process-all": "Tout traiter",
|
|
49
|
+
|
|
50
|
+
"admin-table-uuid": "UUID vidéo",
|
|
51
|
+
"admin-table-youtube": "ID YouTube",
|
|
52
|
+
"admin-table-segments": "Segments",
|
|
53
|
+
"admin-table-saved": "Temps économisé",
|
|
54
|
+
"admin-table-synced": "Dernière sync",
|
|
55
|
+
"admin-table-queue": "File",
|
|
56
|
+
"admin-table-actions": "Actions",
|
|
57
|
+
|
|
58
|
+
"admin-action-sync": "Sync",
|
|
59
|
+
"admin-action-process": "Traiter",
|
|
60
|
+
"admin-action-delete": "Supprimer",
|
|
61
|
+
|
|
62
|
+
"admin-scan-started": "Scan des imports en cours…",
|
|
63
|
+
"admin-scan-result": "{scanned} scannés, {mapped} nouvelle(s) vidéo(s) liée(s).",
|
|
64
|
+
"admin-sync-started": "Synchronisation de toutes les liaisons ({total})…",
|
|
65
|
+
"admin-process-result": "{queued} vidéo(s) mise(s) en file de traitement.",
|
|
66
|
+
"admin-delete-confirm": "Supprimer la liaison pour cette vidéo ?",
|
|
67
|
+
"admin-delete-success": "Liaison supprimée.",
|
|
68
|
+
"admin-no-mappings": "Aucune liaison. Utilisez Scanner les imports ou liez des vidéos manuellement.",
|
|
69
|
+
|
|
70
|
+
"admin-queue-pending": "En attente",
|
|
71
|
+
"admin-queue-processing": "En cours",
|
|
72
|
+
"admin-queue-done": "Terminé",
|
|
73
|
+
"admin-queue-error": "Erreur",
|
|
74
|
+
|
|
75
|
+
"admin-last-sync-never": "Jamais",
|
|
76
|
+
"admin-last-sync-ago": "Il y a {time}"
|
|
77
|
+
}
|