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,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
+ }