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/ffmpeg.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FFmpeg/ffprobe wrapper for segment removal
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { execFile } = require('child_process')
|
|
6
|
+
const { promisify } = require('util')
|
|
7
|
+
const fs = require('fs')
|
|
8
|
+
const path = require('path')
|
|
9
|
+
const os = require('os')
|
|
10
|
+
const crypto = require('crypto')
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile)
|
|
13
|
+
const fsPromises = fs.promises
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get video duration in seconds using ffprobe
|
|
17
|
+
*/
|
|
18
|
+
async function getVideoDuration(filePath) {
|
|
19
|
+
const { stdout } = await execFileAsync('ffprobe', [
|
|
20
|
+
'-v', 'error',
|
|
21
|
+
'-show_entries', 'format=duration',
|
|
22
|
+
'-of', 'default=noprint_wrappers=1:nokey=1',
|
|
23
|
+
filePath
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
const duration = parseFloat(stdout.trim())
|
|
27
|
+
if (isNaN(duration) || duration <= 0) {
|
|
28
|
+
throw new Error(`Invalid duration for ${filePath}: ${stdout.trim()}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return duration
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compute the segments to keep (inverse of sponsor segments)
|
|
36
|
+
* Returns array of { start, end } representing parts to preserve
|
|
37
|
+
*/
|
|
38
|
+
function computeKeepSegments(segments, duration) {
|
|
39
|
+
if (!segments || segments.length === 0) {
|
|
40
|
+
return [{ start: 0, end: duration }]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Sort by start_time and merge overlapping segments
|
|
44
|
+
const sorted = segments
|
|
45
|
+
.map(s => ({ start: parseFloat(s.start_time), end: parseFloat(s.end_time) }))
|
|
46
|
+
.sort((a, b) => a.start - b.start)
|
|
47
|
+
|
|
48
|
+
const merged = [sorted[0]]
|
|
49
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
50
|
+
const last = merged[merged.length - 1]
|
|
51
|
+
if (sorted[i].start <= last.end) {
|
|
52
|
+
last.end = Math.max(last.end, sorted[i].end)
|
|
53
|
+
} else {
|
|
54
|
+
merged.push(sorted[i])
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Invert to get keep segments
|
|
59
|
+
const keep = []
|
|
60
|
+
|
|
61
|
+
if (merged[0].start > 0) {
|
|
62
|
+
keep.push({ start: 0, end: merged[0].start })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < merged.length - 1; i++) {
|
|
66
|
+
keep.push({ start: merged[i].end, end: merged[i + 1].start })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (merged[merged.length - 1].end < duration) {
|
|
70
|
+
keep.push({ start: merged[merged.length - 1].end, end: duration })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Filter out segments shorter than 0.1s
|
|
74
|
+
const filtered = keep.filter(s => (s.end - s.start) >= 0.1)
|
|
75
|
+
|
|
76
|
+
if (filtered.length === 0) {
|
|
77
|
+
throw new Error('No content remaining after removing sponsor segments')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return filtered
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Process a video file by removing sponsor segments using FFmpeg
|
|
85
|
+
* Cuts the keep segments and concatenates them back together
|
|
86
|
+
*/
|
|
87
|
+
async function processVideoFile(filePath, segments, duration, logger) {
|
|
88
|
+
const ext = path.extname(filePath)
|
|
89
|
+
const tmpDir = path.join(os.tmpdir(), `sponsorblock-${crypto.randomBytes(8).toString('hex')}`)
|
|
90
|
+
|
|
91
|
+
await fsPromises.mkdir(tmpDir, { recursive: true })
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const keepSegments = computeKeepSegments(segments, duration)
|
|
95
|
+
logger.info(`Processing ${filePath}: ${keepSegments.length} segments to keep`)
|
|
96
|
+
|
|
97
|
+
// Extract each keep segment
|
|
98
|
+
const partFiles = []
|
|
99
|
+
for (let i = 0; i < keepSegments.length; i++) {
|
|
100
|
+
const seg = keepSegments[i]
|
|
101
|
+
const partFile = path.join(tmpDir, `part${i}${ext}`)
|
|
102
|
+
partFiles.push(partFile)
|
|
103
|
+
|
|
104
|
+
await execFileAsync('ffmpeg', [
|
|
105
|
+
'-y',
|
|
106
|
+
'-i', filePath,
|
|
107
|
+
'-ss', String(seg.start),
|
|
108
|
+
'-to', String(seg.end),
|
|
109
|
+
'-c', 'copy',
|
|
110
|
+
'-avoid_negative_ts', 'make_zero',
|
|
111
|
+
partFile
|
|
112
|
+
], { timeout: 300000 })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Write concat file
|
|
116
|
+
const concatFile = path.join(tmpDir, 'concat.txt')
|
|
117
|
+
const concatContent = partFiles.map(f => `file '${f}'`).join('\n')
|
|
118
|
+
await fsPromises.writeFile(concatFile, concatContent)
|
|
119
|
+
|
|
120
|
+
// Concatenate all parts
|
|
121
|
+
const outputFile = path.join(tmpDir, `output${ext}`)
|
|
122
|
+
await execFileAsync('ffmpeg', [
|
|
123
|
+
'-y',
|
|
124
|
+
'-f', 'concat',
|
|
125
|
+
'-safe', '0',
|
|
126
|
+
'-i', concatFile,
|
|
127
|
+
'-c', 'copy',
|
|
128
|
+
outputFile
|
|
129
|
+
], { timeout: 600000 })
|
|
130
|
+
|
|
131
|
+
// Validate output
|
|
132
|
+
const stat = await fsPromises.stat(outputFile)
|
|
133
|
+
if (stat.size === 0) {
|
|
134
|
+
throw new Error('Output file is empty')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Replace original with output
|
|
138
|
+
await fsPromises.copyFile(outputFile, filePath)
|
|
139
|
+
|
|
140
|
+
logger.info(`Successfully processed ${filePath} (${stat.size} bytes)`)
|
|
141
|
+
} finally {
|
|
142
|
+
// Cleanup temp directory
|
|
143
|
+
await fsPromises.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Find all local video files for a given video UUID
|
|
149
|
+
* Searches web-videos, HLS streaming playlists, and original files
|
|
150
|
+
*/
|
|
151
|
+
async function findVideoFiles(database, videoUuid, storagePath, logger) {
|
|
152
|
+
const files = []
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
// Get video ID from UUID
|
|
156
|
+
const [videos] = await database.query(
|
|
157
|
+
'SELECT "id" FROM "video" WHERE "uuid" = $1',
|
|
158
|
+
{ bind: [videoUuid] }
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if (!videos || videos.length === 0) {
|
|
162
|
+
logger.warn(`Video not found: ${videoUuid}`)
|
|
163
|
+
return files
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const videoId = videos[0].id
|
|
167
|
+
|
|
168
|
+
// Find web-video files (storage = 0 means local)
|
|
169
|
+
const [webVideoFiles] = await database.query(
|
|
170
|
+
'SELECT "filename" FROM "videoFile" WHERE "videoId" = $1 AND "storage" = 0',
|
|
171
|
+
{ bind: [videoId] }
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
for (const row of (webVideoFiles || [])) {
|
|
175
|
+
const filePath = path.join(storagePath, 'web-videos', row.filename)
|
|
176
|
+
if (await fileExists(filePath)) {
|
|
177
|
+
files.push({ type: 'web-video', path: filePath })
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Find HLS files via videoStreamingPlaylist
|
|
182
|
+
const [playlists] = await database.query(
|
|
183
|
+
'SELECT "id" FROM "videoStreamingPlaylist" WHERE "videoId" = $1',
|
|
184
|
+
{ bind: [videoId] }
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
for (const playlist of (playlists || [])) {
|
|
188
|
+
const [hlsFiles] = await database.query(
|
|
189
|
+
'SELECT "filename" FROM "videoFile" WHERE "videoStreamingPlaylistId" = $1 AND "storage" = 0',
|
|
190
|
+
{ bind: [playlist.id] }
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
for (const row of (hlsFiles || [])) {
|
|
194
|
+
const filePath = path.join(storagePath, 'streaming-playlists', 'hls', videoUuid, row.filename)
|
|
195
|
+
if (await fileExists(filePath)) {
|
|
196
|
+
files.push({ type: 'hls', path: filePath })
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Find original video files (glob for uuid in filename)
|
|
202
|
+
const originalDir = path.join(storagePath, 'original-video-files')
|
|
203
|
+
if (await fileExists(originalDir)) {
|
|
204
|
+
const entries = await fsPromises.readdir(originalDir)
|
|
205
|
+
for (const entry of entries) {
|
|
206
|
+
if (entry.includes(videoUuid)) {
|
|
207
|
+
const filePath = path.join(originalDir, entry)
|
|
208
|
+
files.push({ type: 'original', path: filePath })
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
logger.error(`Error finding video files for ${videoUuid}`, error)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
logger.info(`Found ${files.length} local file(s) for video ${videoUuid}`)
|
|
217
|
+
return files
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function fileExists(filePath) {
|
|
221
|
+
try {
|
|
222
|
+
await fsPromises.access(filePath, fs.constants.F_OK)
|
|
223
|
+
return true
|
|
224
|
+
} catch {
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = {
|
|
230
|
+
getVideoDuration,
|
|
231
|
+
computeKeepSegments,
|
|
232
|
+
processVideoFile,
|
|
233
|
+
findVideoFiles
|
|
234
|
+
}
|