11#!/usr/bin/env dotnet run
2- #: package CliWrap @3.6 .6
32
43using System ;
54using System . Diagnostics ;
65using System . IO ;
76using System . Linq ;
7+ using System . Net . Http ;
8+ using System . Text . Json ;
9+ using System . Text . RegularExpressions ;
810using System . Threading . Tasks ;
9- using CliWrap ;
10- using CliWrap . Buffered ;
1111
1212// Parse arguments
1313var args = Environment . GetCommandLineArgs ( ) . Skip ( 1 ) . ToArray ( ) ;
4040// Download video
4141if ( ! framesOnly )
4242{
43- if ( ! await DownloadVideoAsync ( url , videoPath ) )
43+ if ( ! await DownloadBilibiliVideoAsync ( url , videoPath ) )
4444 {
4545 Environment . Exit ( 1 ) ;
4646 }
7070
7171// === Functions ===
7272
73- async Task < bool > DownloadVideoAsync ( string url , string outputPath )
73+ async Task < bool > DownloadBilibiliVideoAsync ( string url , string outputPath )
7474{
7575 Console . WriteLine ( $ "[INFO] Downloading video: { url } ") ;
7676
77- var ytDlp = FindExecutable ( "yt-dlp" , "yt-dlp.exe" ) ;
78- if ( ytDlp == null )
79- {
80- Console . WriteLine ( "[ERROR] yt-dlp not found!" ) ;
81- Console . WriteLine ( " Install with: pip install yt-dlp" ) ;
82- Console . WriteLine ( " Or download from: https://github.com/yt-dlp/yt-dlp/releases" ) ;
83- return false ;
84- }
85-
8677 try
8778 {
88- Console . WriteLine ( $ "[INFO] Running yt-dlp...") ;
89- var result = await Cli . Wrap ( ytDlp )
90- . WithArguments ( new [ ]
91- {
92- "-f" , "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" ,
93- "-o" , outputPath ,
94- "--no-warnings" ,
95- url
96- } )
97- . WithValidation ( CommandResultValidation . None )
98- . ExecuteBufferedAsync ( ) ;
99-
100- if ( result . ExitCode != 0 )
79+ // Extract BV ID from URL
80+ var bvid = ExtractBvid ( url ) ;
81+ if ( string . IsNullOrEmpty ( bvid ) )
82+ {
83+ Console . WriteLine ( "[ERROR] Invalid Bilibili URL, cannot extract BV ID" ) ;
84+ return false ;
85+ }
86+ Console . WriteLine ( $ "[INFO] BV ID: { bvid } ") ;
87+
88+ using var client = CreateHttpClient ( ) ;
89+
90+ // Step 1: Get video info to obtain cid
91+ Console . WriteLine ( "[INFO] Fetching video info..." ) ;
92+ var infoUrl = $ "https://api.bilibili.com/x/web-interface/view?bvid={ bvid } ";
93+ var infoJson = await client . GetStringAsync ( infoUrl ) ;
94+ using var infoDoc = JsonDocument . Parse ( infoJson ) ;
95+
96+ var code = infoDoc . RootElement . GetProperty ( "code" ) . GetInt32 ( ) ;
97+ if ( code != 0 )
10198 {
102- Console . WriteLine ( $ "[ERROR] Download failed: { result . StandardError } ") ;
99+ var message = infoDoc . RootElement . GetProperty ( "message" ) . GetString ( ) ;
100+ Console . WriteLine ( $ "[ERROR] Failed to get video info: { message } ") ;
103101 return false ;
104102 }
105103
104+ var data = infoDoc . RootElement . GetProperty ( "data" ) ;
105+ var title = data . GetProperty ( "title" ) . GetString ( ) ;
106+ var cid = data . GetProperty ( "cid" ) . GetInt64 ( ) ;
107+ Console . WriteLine ( $ "[INFO] Title: { title } ") ;
108+ Console . WriteLine ( $ "[INFO] CID: { cid } ") ;
109+
110+ // Step 2: Get playback URL
111+ Console . WriteLine ( "[INFO] Fetching playback URL..." ) ;
112+ var playUrl = $ "https://api.bilibili.com/x/player/playurl?bvid={ bvid } &cid={ cid } &qn=80&fnval=1";
113+ var playJson = await client . GetStringAsync ( playUrl ) ;
114+ using var playDoc = JsonDocument . Parse ( playJson ) ;
115+
116+ var playCode = playDoc . RootElement . GetProperty ( "code" ) . GetInt32 ( ) ;
117+ if ( playCode != 0 )
118+ {
119+ var message = playDoc . RootElement . GetProperty ( "message" ) . GetString ( ) ;
120+ Console . WriteLine ( $ "[ERROR] Failed to get playback URL: { message } ") ;
121+ return false ;
122+ }
123+
124+ var playData = playDoc . RootElement . GetProperty ( "data" ) ;
125+ var durl = playData . GetProperty ( "durl" ) [ 0 ] ;
126+ var videoUrl = durl . GetProperty ( "url" ) . GetString ( ) ;
127+ var size = durl . GetProperty ( "size" ) . GetInt64 ( ) ;
128+
129+ Console . WriteLine ( $ "[INFO] Video size: { size / 1024 / 1024 : F1} MB") ;
130+
131+ // Step 3: Download video
132+ Console . WriteLine ( "[INFO] Downloading video file..." ) ;
133+
134+ using var request = new HttpRequestMessage ( HttpMethod . Get , videoUrl ) ;
135+ request . Headers . Add ( "Referer" , $ "https://www.bilibili.com/video/{ bvid } ") ;
136+
137+ using var response = await client . SendAsync ( request , HttpCompletionOption . ResponseHeadersRead ) ;
138+ response . EnsureSuccessStatusCode ( ) ;
139+
140+ var totalBytes = response . Content . Headers . ContentLength ?? size ;
141+
142+ await using var contentStream = await response . Content . ReadAsStreamAsync ( ) ;
143+ await using var fileStream = new FileStream ( outputPath , FileMode . Create , FileAccess . Write , FileShare . None , 8192 , true ) ;
144+
145+ var buffer = new byte [ 8192 ] ;
146+ var totalRead = 0L ;
147+ var lastProgress = 0 ;
148+ int bytesRead ;
149+
150+ while ( ( bytesRead = await contentStream . ReadAsync ( buffer , 0 , buffer . Length ) ) > 0 )
151+ {
152+ await fileStream . WriteAsync ( buffer , 0 , bytesRead ) ;
153+ totalRead += bytesRead ;
154+
155+ var progress = ( int ) ( totalRead * 100 / totalBytes ) ;
156+ if ( progress > lastProgress && progress % 10 == 0 )
157+ {
158+ Console . WriteLine ( $ "[INFO] Progress: { progress } %") ;
159+ lastProgress = progress ;
160+ }
161+ }
162+
106163 Console . WriteLine ( $ "[OK] Video downloaded: { outputPath } ") ;
107164 return true ;
108165 }
@@ -113,6 +170,45 @@ async Task<bool> DownloadVideoAsync(string url, string outputPath)
113170 }
114171}
115172
173+ string ? ExtractBvid ( string url )
174+ {
175+ // Match BV ID from various URL formats
176+ // https://www.bilibili.com/video/BV1xx411c7mD
177+ // https://b23.tv/BV1xx411c7mD
178+ // BV1xx411c7mD
179+ var patterns = new [ ]
180+ {
181+ @"BV[a-zA-Z0-9]+" ,
182+ } ;
183+
184+ foreach ( var pattern in patterns )
185+ {
186+ var match = Regex . Match ( url , pattern ) ;
187+ if ( match . Success )
188+ {
189+ return match . Value ;
190+ }
191+ }
192+
193+ return null ;
194+ }
195+
196+ HttpClient CreateHttpClient ( )
197+ {
198+ var handler = new HttpClientHandler
199+ {
200+ AutomaticDecompression = System . Net . DecompressionMethods . GZip | System . Net . DecompressionMethods . Deflate
201+ } ;
202+
203+ var client = new HttpClient ( handler ) ;
204+ client . DefaultRequestHeaders . Add ( "User-Agent" , "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ) ;
205+ client . DefaultRequestHeaders . Add ( "Referer" , "https://www.bilibili.com" ) ;
206+ client . DefaultRequestHeaders . Add ( "Accept" , "application/json, text/plain, */*" ) ;
207+ client . Timeout = TimeSpan . FromMinutes ( 30 ) ;
208+
209+ return client ;
210+ }
211+
116212async Task < bool > ExtractFramesAsync ( string videoPath , string outputDir , double fps )
117213{
118214 Console . WriteLine ( $ "[INFO] Extracting frames (fps={ fps } )") ;
@@ -133,21 +229,30 @@ async Task<bool> ExtractFramesAsync(string videoPath, string outputDir, double f
133229 try
134230 {
135231 Console . WriteLine ( $ "[INFO] Running ffmpeg...") ;
136- var result = await Cli . Wrap ( ffmpeg )
137- . WithArguments ( new [ ]
138- {
139- "-i" , videoPath ,
140- "-vf" , $ "fps={ fps } ",
141- "-q:v" , "2" ,
142- "-y" ,
143- outputPattern
144- } )
145- . WithValidation ( CommandResultValidation . None )
146- . ExecuteBufferedAsync ( ) ;
147-
148- if ( result . ExitCode != 0 )
232+
233+ var psi = new ProcessStartInfo
234+ {
235+ FileName = ffmpeg ,
236+ Arguments = $ "-i \" { videoPath } \" -vf \" fps={ fps } \" -q:v 2 -y \" { outputPattern } \" ",
237+ RedirectStandardOutput = true ,
238+ RedirectStandardError = true ,
239+ UseShellExecute = false ,
240+ CreateNoWindow = true
241+ } ;
242+
243+ using var process = Process . Start ( psi ) ;
244+ if ( process == null )
245+ {
246+ Console . WriteLine ( "[ERROR] Failed to start ffmpeg" ) ;
247+ return false ;
248+ }
249+
250+ var stderr = await process . StandardError . ReadToEndAsync ( ) ;
251+ await process . WaitForExitAsync ( ) ;
252+
253+ if ( process . ExitCode != 0 )
149254 {
150- Console . WriteLine ( $ "[ERROR] Frame extraction failed: { result . StandardError } ") ;
255+ Console . WriteLine ( $ "[ERROR] Frame extraction failed: { stderr } ") ;
151256 return false ;
152257 }
153258
@@ -164,13 +269,11 @@ async Task<bool> ExtractFramesAsync(string videoPath, string outputDir, double f
164269
165270string ? FindExecutable ( params string [ ] names )
166271{
167- // Check PATH
168272 var pathEnv = Environment . GetEnvironmentVariable ( "PATH" ) ?? "" ;
169273 var paths = pathEnv . Split ( Path . PathSeparator ) ;
170274
171275 foreach ( var name in names )
172276 {
173- // Direct check
174277 foreach ( var path in paths )
175278 {
176279 var fullPath = Path . Combine ( path , name ) ;
@@ -184,7 +287,6 @@ async Task<bool> ExtractFramesAsync(string videoPath, string outputDir, double f
184287 @"C:\ffmpeg\bin" ,
185288 @"C:\Program Files\ffmpeg\bin" ,
186289 @"C:\tools\ffmpeg\bin" ,
187- Environment . ExpandEnvironmentVariables ( @"%LOCALAPPDATA%\Microsoft\WinGet\Packages" ) ,
188290 Environment . ExpandEnvironmentVariables ( @"%USERPROFILE%\scoop\shims" ) ,
189291 } ;
190292
@@ -197,23 +299,25 @@ async Task<bool> ExtractFramesAsync(string videoPath, string outputDir, double f
197299 }
198300 }
199301
200- // Try which/ where command
302+ // Try where command on Windows
201303 try
202304 {
203305 var cmd = OperatingSystem . IsWindows ( ) ? "where" : "which" ;
204- var result = Process . Start ( new ProcessStartInfo
306+ var psi = new ProcessStartInfo
205307 {
206308 FileName = cmd ,
207309 Arguments = names [ 0 ] ,
208310 RedirectStandardOutput = true ,
209311 UseShellExecute = false ,
210312 CreateNoWindow = true
211- } ) ;
212- result ? . WaitForExit ( ) ;
213- var output = result ? . StandardOutput . ReadToEnd ( ) ? . Trim ( ) ;
214- if ( ! string . IsNullOrEmpty ( output ) && File . Exists ( output . Split ( '\n ' ) [ 0 ] ) )
313+ } ;
314+ using var process = Process . Start ( psi ) ;
315+ process ? . WaitForExit ( ) ;
316+ var output = process ? . StandardOutput . ReadToEnd ( ) ? . Trim ( ) ;
317+ if ( ! string . IsNullOrEmpty ( output ) )
215318 {
216- return output . Split ( '\n ' ) [ 0 ] . Trim ( ) ;
319+ var firstLine = output . Split ( '\n ' ) [ 0 ] . Trim ( ) ;
320+ if ( File . Exists ( firstLine ) ) return firstLine ;
217321 }
218322 }
219323 catch { }
@@ -257,7 +361,7 @@ url Bilibili video URL (required)
257361 dotnet run prepare.cs ""https://www.bilibili.com/video/BV1xx411c7mD"" -o ./output
258362
259363Requirements:
260- - yt-dlp: pip install yt-dlp
364+ - .NET 10 SDK
261365 - ffmpeg: https://ffmpeg.org/download.html
262366" ) ;
263367}
0 commit comments