@@ -26,6 +26,13 @@ public class MobileStreamController : ControllerBase
2626 private readonly ILogger < MobileStreamController > _logger ;
2727 private static readonly SemaphoreSlim _downloadSemaphore = new ( 5 ) ; // Allow 5 concurrent downloads for preload
2828
29+ // Limit concurrent direct range fetches against Telegram (seeks ahead of the cache)
30+ private static readonly SemaphoreSlim _telegramRangeSemaphore = new ( 4 ) ;
31+
32+ // If the requested range starts within this distance of the background download
33+ // position, wait for the download instead of opening a duplicate Telegram fetch
34+ private const long WAIT_PROXIMITY_BYTES = 4 * 1024 * 1024 ;
35+
2936 public MobileStreamController (
3037 ITelegramService ts ,
3138 IDbService db ,
@@ -238,28 +245,43 @@ public async Task<IActionResult> StreamAudioByTfmId(string channelId, string tfm
238245 }
239246 }
240247
241- // Parse range header
248+ // Parse range header (RFC 7233: bytes=X-, bytes=X-Y, bytes=-N)
242249 var rangeHeader = Request . Headers [ "Range" ] . ToString ( ) ;
250+ long totalLength = dbFile . Size ;
243251 long from = 0 ;
244- long to = 0 ;
252+ long ? to = null ;
245253 bool hasRange = false ;
246254
247- if ( ! string . IsNullOrEmpty ( rangeHeader ) && rangeHeader . StartsWith ( "bytes=" ) )
255+ if ( ! string . IsNullOrEmpty ( rangeHeader ) && rangeHeader . StartsWith ( "bytes=" ) && ! rangeHeader . Contains ( ',' ) )
248256 {
249- hasRange = true ;
250- var range = rangeHeader . Replace ( "bytes=" , "" ) . Split ( '-' ) ;
251- from = long . Parse ( range [ 0 ] ) ;
252- if ( range . Length > 1 && ! string . IsNullOrEmpty ( range [ 1 ] ) )
253- to = long . Parse ( range [ 1 ] ) ;
257+ var parts = rangeHeader . Substring ( "bytes=" . Length ) . Split ( '-' ) ;
258+ if ( parts . Length == 2 )
259+ {
260+ if ( string . IsNullOrEmpty ( parts [ 0 ] ) )
261+ {
262+ // Suffix range: last N bytes
263+ if ( long . TryParse ( parts [ 1 ] , out var suffixLength ) && suffixLength > 0 )
264+ {
265+ from = Math . Max ( 0 , totalLength - suffixLength ) ;
266+ to = totalLength - 1 ;
267+ hasRange = true ;
268+ }
269+ }
270+ else if ( long . TryParse ( parts [ 0 ] , out var parsedFrom ) && parsedFrom >= 0 )
271+ {
272+ from = parsedFrom ;
273+ hasRange = true ;
274+ if ( ! string . IsNullOrEmpty ( parts [ 1 ] ) && long . TryParse ( parts [ 1 ] , out var parsedTo ) )
275+ to = parsedTo ;
276+ }
277+ }
278+ // Malformed ranges fall through as "no range" (initial chunk)
254279 }
255280
256- long totalLength = dbFile . Size ;
257-
258- // Check how much is already cached
259- long cachedBytes = 0 ;
260- if ( System . IO . File . Exists ( filePath ) )
281+ if ( hasRange && ( from >= totalLength || ( to . HasValue && to . Value < from ) ) )
261282 {
262- cachedBytes = new FileInfo ( filePath ) . Length ;
283+ Response . Headers [ "Content-Range" ] = $ "bytes */{ totalLength } ";
284+ return StatusCode ( StatusCodes . Status416RangeNotSatisfiable ) ;
263285 }
264286
265287 // For initial request without Range, return first chunk as 206 with full size info
@@ -270,23 +292,59 @@ public async Task<IActionResult> StreamAudioByTfmId(string channelId, string tfm
270292 to = Math . Min ( 2 * 1024 * 1024 , totalLength - 1 ) ; // First 2MB
271293 }
272294
273- // Handle Range request
274- if ( to == 0 || to >= totalLength )
295+ // Open-ended or oversized ranges: cap the response to ~2.5MB
296+ long rangeEnd = ( ! to . HasValue || to . Value >= totalLength )
297+ ? Math . Min ( from + ( 5 * 524288 ) , totalLength - 1 )
298+ : to . Value ;
299+
300+ // Kick off (or attach to) the background download that fills the disk cache,
301+ // so every streamed track ends up cached and is downloaded from Telegram once
302+ ProgressiveDownloadInfo downloadInfo = null ;
303+ if ( dbFile . MessageId . HasValue )
275304 {
276- // Open-ended range
277- to = Math . Min ( from + ( 5 * 524288 ) , totalLength - 1 ) ; // ~2.5MB chunk
305+ try
306+ {
307+ downloadInfo = await _progressiveDownload . StartOrGetDownloadAsync ( cacheFileName , channelId , dbFile , filePath ) ;
308+ }
309+ catch ( Exception ex )
310+ {
311+ _logger . LogWarning ( ex , "Could not start background cache download for {FileName}" , name ) ;
312+ }
278313 }
279314
280- // Check if range is available locally
315+ long CachedBytes ( )
316+ {
317+ long onDisk = System . IO . File . Exists ( filePath ) ? new FileInfo ( filePath ) . Length : 0 ;
318+ return Math . Max ( onDisk , downloadInfo ? . DownloadedBytes ?? 0 ) ;
319+ }
320+
321+ // If the background download is nearby, wait briefly for it to cover the
322+ // range start instead of opening a duplicate Telegram download. This is the
323+ // normal sequential-playback path: same latency as a direct fetch (both pull
324+ // sequential 512KB chunks), but the bytes get persisted.
325+ if ( downloadInfo != null && downloadInfo . IsDownloading &&
326+ from - CachedBytes ( ) <= WAIT_PROXIMITY_BYTES )
327+ {
328+ var waitTarget = Math . Min ( rangeEnd , from + 524288 ) ; // at least 512KB past the start
329+ var deadline = DateTime . UtcNow . AddSeconds ( 12 ) ;
330+ while ( downloadInfo . IsDownloading &&
331+ downloadInfo . DownloadedBytes <= waitTarget &&
332+ DateTime . UtcNow < deadline )
333+ {
334+ await Task . Delay ( 150 , HttpContext . RequestAborted ) ;
335+ }
336+ }
337+
338+ var cachedBytes = CachedBytes ( ) ;
339+
340+ // Serve from the (possibly still growing) cache file
281341 if ( from < cachedBytes )
282342 {
283- // Part or all of range is in cache
284- var availableEnd = Math . Min ( to , cachedBytes - 1 ) ;
343+ var availableEnd = Math . Min ( rangeEnd , cachedBytes - 1 ) ;
285344 var length = availableEnd - from + 1 ;
286345
287346 _logger . LogDebug ( "Serving from cache: bytes {From}-{To} of {Total}" , from , availableEnd , totalLength ) ;
288347
289- // Read from cache file
290348 using var cacheStream = new FileStream ( filePath , FileMode . Open , FileAccess . Read , FileShare . ReadWrite ) ;
291349 cacheStream . Seek ( from , SeekOrigin . Begin ) ;
292350 var buffer = new byte [ length ] ;
@@ -303,10 +361,10 @@ public async Task<IActionResult> StreamAudioByTfmId(string channelId, string tfm
303361 return new EmptyResult ( ) ;
304362 }
305363
306- // Range not in cache - stream from Telegram
307- _logger . LogDebug ( "Streaming from Telegram: bytes {From}-{To} of {Total}" , from , to , totalLength ) ;
364+ // Range far ahead of the background download (seek): fetch it directly from
365+ // Telegram, streaming each 512KB chunk to the response as it arrives
366+ _logger . LogDebug ( "Streaming from Telegram: bytes {From}-{To} of {Total}" , from , rangeEnd , totalLength ) ;
308367
309- // Get message from Telegram
310368 if ( ! dbFile . MessageId . HasValue )
311369 {
312370 return NotFound ( ApiResponse < object > . Fail ( "File has no MessageId" ) ) ;
@@ -318,34 +376,52 @@ public async Task<IActionResult> StreamAudioByTfmId(string channelId, string tfm
318376 return NotFound ( ApiResponse < object > . Fail ( "Message not found in Telegram" ) ) ;
319377 }
320378
321- // Align to 512KB boundaries for Telegram
379+ // Align down to 512KB for Telegram; skip the prefix when writing the response
322380 var alignedFrom = ( from / 524288 ) * 524288 ;
323- var alignedTo = ( ( to + 524288 ) / 524288 ) * 524288 ;
324- if ( alignedTo > totalLength ) alignedTo = totalLength ;
381+ var skipBytes = from - alignedFrom ;
382+ var responseLength = rangeEnd - from + 1 ;
383+
384+ await _telegramRangeSemaphore . WaitAsync ( HttpContext . RequestAborted ) ;
385+ try
386+ {
387+ Response . StatusCode = StatusCodes . Status206PartialContent ;
388+ Response . ContentType = mimeType ;
389+ Response . ContentLength = responseLength ;
390+ Response . Headers [ "Content-Range" ] = $ "bytes { from } -{ rangeEnd } /{ totalLength } ";
391+ Response . Headers [ "Accept-Ranges" ] = "bytes" ;
392+ Response . Headers [ "Content-Disposition" ] = $ "inline; filename=\" { HttpUtility . UrlEncode ( name ) } \" ";
325393
326- var downloadLength = alignedTo - alignedFrom ;
394+ long remainingSkip = skipBytes ;
395+ long remainingWrite = responseLength ;
327396
328- // Download the range from Telegram
329- byte [ ] data = await _ts . DownloadFileStream ( message , alignedFrom , ( int ) downloadLength ) ;
397+ await foreach ( var chunk in _ts . DownloadFileStreamChunks (
398+ message , alignedFrom , skipBytes + responseLength , HttpContext . RequestAborted ) )
399+ {
400+ int start = 0 ;
401+ int length = chunk . Length ;
330402
331- // Calculate skip bytes to get to the exact requested position
332- var skipBytes = from - alignedFrom ;
333- var responseLength = Math . Min ( to - from + 1 , data . Length - skipBytes ) ;
403+ if ( remainingSkip > 0 )
404+ {
405+ var toSkip = ( int ) Math . Min ( remainingSkip , length ) ;
406+ start += toSkip ;
407+ length -= toSkip ;
408+ remainingSkip -= toSkip ;
409+ }
410+
411+ if ( length <= 0 ) continue ;
334412
335- if ( skipBytes < 0 || skipBytes >= data . Length )
413+ var toWrite = ( int ) Math . Min ( length , remainingWrite ) ;
414+ await Response . Body . WriteAsync ( chunk , start , toWrite , HttpContext . RequestAborted ) ;
415+ remainingWrite -= toWrite ;
416+
417+ if ( remainingWrite <= 0 ) break ;
418+ }
419+ }
420+ finally
336421 {
337- _logger . LogError ( "Invalid skip calculation: skipBytes={Skip}, dataLength={DataLen}" , skipBytes , data . Length ) ;
338- return StatusCode ( 500 , "Error calculating response range" ) ;
422+ _telegramRangeSemaphore . Release ( ) ;
339423 }
340424
341- Response . StatusCode = StatusCodes . Status206PartialContent ;
342- Response . ContentType = mimeType ;
343- Response . ContentLength = responseLength ;
344- Response . Headers [ "Content-Range" ] = $ "bytes { from } -{ from + responseLength - 1 } /{ totalLength } ";
345- Response . Headers [ "Accept-Ranges" ] = "bytes" ;
346- Response . Headers [ "Content-Disposition" ] = $ "inline; filename=\" { HttpUtility . UrlEncode ( name ) } \" ";
347-
348- await Response . Body . WriteAsync ( data , ( int ) skipBytes , ( int ) responseLength ) ;
349425 return new EmptyResult ( ) ;
350426 }
351427 catch ( OperationCanceledException )
0 commit comments