4
4
using System . Globalization ;
5
5
using System . IO ;
6
6
using System . Text ;
7
+ using System . Text . RegularExpressions ;
7
8
using Microsoft . Extensions . Logging ;
8
9
9
10
namespace ConfusedPolarBear . Plugin . IntroSkipper ;
@@ -13,6 +14,16 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
13
14
/// </summary>
14
15
public static class FFmpegWrapper
15
16
{
17
+ // FFmpeg logs lines similar to the following:
18
+ // [silencedetect @ 0x000000000000] silence_start: 12.34
19
+ // [silencedetect @ 0x000000000000] silence_end: 56.123 | silence_duration: 43.783
20
+
21
+ /// <summary>
22
+ /// Used with FFmpeg's silencedetect filter to extract the start and end times of silence.
23
+ /// </summary>
24
+ private static readonly Regex SilenceDetectionExpression = new (
25
+ "silence_(?<type>start|end): (?<time>[0-9\\ .]+)" ) ;
26
+
16
27
/// <summary>
17
28
/// Gets or sets the logger.
18
29
/// </summary>
@@ -31,11 +42,11 @@ public static bool CheckFFmpegVersion()
31
42
try
32
43
{
33
44
// Log the output of "ffmpeg -version".
34
- ChromaprintLogs [ "version" ] = Encoding . UTF8 . GetString ( GetOutput ( "-version" , 2000 ) ) ;
45
+ ChromaprintLogs [ "version" ] = Encoding . UTF8 . GetString ( GetOutput ( "-version" , string . Empty , false , 2000 ) ) ;
35
46
Logger ? . LogDebug ( "ffmpeg version information: {Version}" , ChromaprintLogs [ "version" ] ) ;
36
47
37
48
// First, validate that the installed version of ffmpeg supports chromaprint at all.
38
- var muxers = Encoding . UTF8 . GetString ( GetOutput ( "-muxers" , 2000 ) ) ;
49
+ var muxers = Encoding . UTF8 . GetString ( GetOutput ( "-muxers" , string . Empty , false , 2000 ) ) ;
39
50
ChromaprintLogs [ "muxer list" ] = muxers ;
40
51
Logger ? . LogTrace ( "ffmpeg muxers: {Muxers}" , muxers ) ;
41
52
@@ -47,7 +58,7 @@ public static bool CheckFFmpegVersion()
47
58
}
48
59
49
60
// Second, validate that ffmpeg understands the "-fp_format raw" option.
50
- var muxerHelp = Encoding . UTF8 . GetString ( GetOutput ( "-h muxer=chromaprint" , 2000 ) ) ;
61
+ var muxerHelp = Encoding . UTF8 . GetString ( GetOutput ( "-h muxer=chromaprint" , string . Empty , false , 2000 ) ) ;
51
62
ChromaprintLogs [ "muxer options" ] = muxerHelp ;
52
63
Logger ? . LogTrace ( "ffmpeg chromaprint help: {MuxerHelp}" , muxerHelp ) ;
53
64
@@ -90,10 +101,9 @@ public static uint[] Fingerprint(QueuedEpisode episode)
90
101
}
91
102
92
103
Logger ? . LogDebug (
93
- "Fingerprinting {Duration} seconds from \" {File}\" (length {Length}, id {Id})" ,
104
+ "Fingerprinting {Duration} seconds from \" {File}\" (id {Id})" ,
94
105
episode . FingerprintDuration ,
95
106
episode . Path ,
96
- episode . Path . Length ,
97
107
episode . EpisodeId ) ;
98
108
99
109
var args = string . Format (
@@ -103,7 +113,7 @@ public static uint[] Fingerprint(QueuedEpisode episode)
103
113
episode . FingerprintDuration ) ;
104
114
105
115
// Returns all fingerprint points as raw 32 bit unsigned integers (little endian).
106
- var rawPoints = GetOutput ( args ) ;
116
+ var rawPoints = GetOutput ( args , string . Empty ) ;
107
117
if ( rawPoints . Length == 0 || rawPoints . Length % 4 != 0 )
108
118
{
109
119
Logger ? . LogWarning ( "Chromaprint returned {Count} points for \" {Path}\" " , rawPoints . Length , episode . Path ) ;
@@ -153,34 +163,116 @@ public static Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerpr
153
163
}
154
164
155
165
/// <summary>
156
- /// Runs ffmpeg and returns standard output.
166
+ /// Detect ranges of silence in the provided episode.
167
+ /// </summary>
168
+ /// <param name="episode">Queued episode.</param>
169
+ /// <param name="limit">Maximum amount of audio (in seconds) to detect silence in.</param>
170
+ /// <returns>Array of TimeRange objects that are silent in the queued episode.</returns>
171
+ public static TimeRange [ ] DetectSilence ( QueuedEpisode episode , int limit )
172
+ {
173
+ Logger ? . LogTrace (
174
+ "Detecting silence in \" {File}\" (limit {Limit}, id {Id})" ,
175
+ episode . Path ,
176
+ limit ,
177
+ episode . EpisodeId ) ;
178
+
179
+ // TODO: select the audio track that matches the user's preferred language, falling
180
+ // back to the first track if nothing matches
181
+
182
+ // -vn, -sn, -dn: ignore video, subtitle, and data tracks
183
+ var args = string . Format (
184
+ CultureInfo . InvariantCulture ,
185
+ "-vn -sn -dn " +
186
+ "-i \" {0}\" -to {1} -af \" silencedetect=noise={2}dB:duration=0.1\" -f null -" ,
187
+ episode . Path ,
188
+ limit ,
189
+ Plugin . Instance ? . Configuration . SilenceDetectionMaximumNoise ?? - 50 ) ;
190
+
191
+ // Cache the output of this command to "GUID-intro-silence-v1"
192
+ var cacheKey = episode . EpisodeId . ToString ( "N" ) + "-intro-silence-v1" ;
193
+
194
+ var currentRange = new TimeRange ( ) ;
195
+ var silenceRanges = new List < TimeRange > ( ) ;
196
+
197
+ // Each match will have a type (either "start" or "end") and a timecode (a double).
198
+ var raw = Encoding . UTF8 . GetString ( GetOutput ( args , cacheKey , true ) ) ;
199
+ foreach ( Match match in SilenceDetectionExpression . Matches ( raw ) )
200
+ {
201
+ var isStart = match . Groups [ "type" ] . Value == "start" ;
202
+ var time = Convert . ToDouble ( match . Groups [ "time" ] . Value , CultureInfo . InvariantCulture ) ;
203
+
204
+ if ( isStart )
205
+ {
206
+ currentRange . Start = time ;
207
+ }
208
+ else
209
+ {
210
+ currentRange . End = time ;
211
+ silenceRanges . Add ( new TimeRange ( currentRange ) ) ;
212
+ }
213
+ }
214
+
215
+ return silenceRanges . ToArray ( ) ;
216
+ }
217
+
218
+ /// <summary>
219
+ /// Runs ffmpeg and returns standard output (or error).
220
+ /// If caching is enabled, will use cacheFilename to cache the output of this command.
157
221
/// </summary>
158
222
/// <param name="args">Arguments to pass to ffmpeg.</param>
159
- /// <param name="timeout">Timeout (in seconds) to wait for ffmpeg to exit.</param>
160
- private static ReadOnlySpan < byte > GetOutput ( string args , int timeout = 60 * 1000 )
223
+ /// <param name="cacheFilename">Filename to cache the output of this command to, or string.Empty if this command should not be cached.</param>
224
+ /// <param name="stderr">If standard error should be returned.</param>
225
+ /// <param name="timeout">Timeout (in miliseconds) to wait for ffmpeg to exit.</param>
226
+ private static ReadOnlySpan < byte > GetOutput (
227
+ string args ,
228
+ string cacheFilename ,
229
+ bool stderr = false ,
230
+ int timeout = 60 * 1000 )
161
231
{
162
232
var ffmpegPath = Plugin . Instance ? . FFmpegPath ?? "ffmpeg" ;
163
233
234
+ var cacheOutput =
235
+ ( Plugin . Instance ? . Configuration . CacheFingerprints ?? false ) &&
236
+ ! string . IsNullOrEmpty ( cacheFilename ) ;
237
+
238
+ // If caching is enabled, try to load the output of this command from the cached file.
239
+ if ( cacheOutput )
240
+ {
241
+ // Calculate the absolute path to the cached file.
242
+ cacheFilename = Path . Join ( Plugin . Instance ! . FingerprintCachePath , cacheFilename ) ;
243
+
244
+ // If the cached file exists, return whatever it holds.
245
+ if ( File . Exists ( cacheFilename ) )
246
+ {
247
+ Logger ? . LogTrace ( "Returning contents of cache {Cache}" , cacheFilename ) ;
248
+ return File . ReadAllBytes ( cacheFilename ) ;
249
+ }
250
+
251
+ Logger ? . LogTrace ( "Not returning contents of cache {Cache} (not found)" , cacheFilename ) ;
252
+ }
253
+
164
254
// Prepend some flags to prevent FFmpeg from logging it's banner and progress information
165
255
// for each file that is fingerprinted.
166
- var info = new ProcessStartInfo ( ffmpegPath , args . Insert ( 0 , "-hide_banner -loglevel warning " ) )
256
+ var info = new ProcessStartInfo ( ffmpegPath , args . Insert ( 0 , "-hide_banner -loglevel info " ) )
167
257
{
168
258
WindowStyle = ProcessWindowStyle . Hidden ,
169
259
CreateNoWindow = true ,
170
260
UseShellExecute = false ,
171
261
ErrorDialog = false ,
172
262
173
- // We only consume standardOutput.
174
- RedirectStandardOutput = true ,
175
- RedirectStandardError = false
263
+ RedirectStandardOutput = ! stderr ,
264
+ RedirectStandardError = stderr
176
265
} ;
177
266
178
267
var ffmpeg = new Process
179
268
{
180
269
StartInfo = info
181
270
} ;
182
271
183
- Logger ? . LogDebug ( "Starting ffmpeg with the following arguments: {Arguments}" , ffmpeg . StartInfo . Arguments ) ;
272
+ Logger ? . LogDebug (
273
+ "Starting ffmpeg with the following arguments: {Arguments}" ,
274
+ ffmpeg . StartInfo . Arguments ) ;
275
+
184
276
ffmpeg . Start ( ) ;
185
277
186
278
using ( MemoryStream ms = new MemoryStream ( ) )
@@ -190,19 +282,29 @@ private static ReadOnlySpan<byte> GetOutput(string args, int timeout = 60 * 1000
190
282
191
283
do
192
284
{
193
- bytesRead = ffmpeg . StandardOutput . BaseStream . Read ( buf , 0 , buf . Length ) ;
285
+ var streamReader = stderr ? ffmpeg . StandardError : ffmpeg . StandardOutput ;
286
+ bytesRead = streamReader . BaseStream . Read ( buf , 0 , buf . Length ) ;
194
287
ms . Write ( buf , 0 , bytesRead ) ;
195
288
}
196
289
while ( bytesRead > 0 ) ;
197
290
198
291
ffmpeg . WaitForExit ( timeout ) ;
199
292
200
- return ms . ToArray ( ) . AsSpan ( ) ;
293
+ var output = ms . ToArray ( ) ;
294
+
295
+ // If caching is enabled, cache the output of this command.
296
+ if ( cacheOutput )
297
+ {
298
+ File . WriteAllBytes ( cacheFilename , output ) ;
299
+ }
300
+
301
+ return output ;
201
302
}
202
303
}
203
304
204
305
/// <summary>
205
306
/// Tries to load an episode's fingerprint from cache. If caching is not enabled, calling this function is a no-op.
307
+ /// This function was created before the unified caching mechanism was introduced (in v0.1.7).
206
308
/// </summary>
207
309
/// <param name="episode">Episode to try to load from cache.</param>
208
310
/// <param name="fingerprint">Array to store the fingerprint in.</param>
@@ -256,6 +358,7 @@ private static bool LoadCachedFingerprint(QueuedEpisode episode, out uint[] fing
256
358
257
359
/// <summary>
258
360
/// Cache an episode's fingerprint to disk. If caching is not enabled, calling this function is a no-op.
361
+ /// This function was created before the unified caching mechanism was introduced (in v0.1.7).
259
362
/// </summary>
260
363
/// <param name="episode">Episode to store in cache.</param>
261
364
/// <param name="fingerprint">Fingerprint of the episode to store.</param>
@@ -280,6 +383,7 @@ private static void CacheFingerprint(QueuedEpisode episode, List<uint> fingerpri
280
383
281
384
/// <summary>
282
385
/// Determines the path an episode should be cached at.
386
+ /// This function was created before the unified caching mechanism was introduced (in v0.1.7).
283
387
/// </summary>
284
388
/// <param name="episode">Episode.</param>
285
389
private static string GetFingerprintCachePath ( QueuedEpisode episode )
0 commit comments