Skip to content

Commit 0b8072b

Browse files
authored
Merge pull request #94 from mateof/feat/progressive-streaming-cache
feat: wire progressive download cache into tfm streaming endpoint
2 parents d1c60e1 + f37761c commit 0b8072b

4 files changed

Lines changed: 275 additions & 56 deletions

File tree

TelegramDownloader/Controllers/Mobile/MobileStreamController.cs

Lines changed: 121 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

TelegramDownloader/Data/ITelegramService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public interface ITelegramService
2121
Task<User> CallQrGenerator(Action<string> func, CancellationToken ct, bool logoutFirst = false);
2222
Task<string> DownloadFile(ChatMessages message, string fileName = null, string folder = null, DownloadModel model = null, bool shouldAddToList = false);
2323
Task<Byte[]> DownloadFileStream(Message message, long offset, int limit);
24+
IAsyncEnumerable<byte[]> DownloadFileStreamChunks(Message message, long offset, long limit, CancellationToken ct = default);
2425
Task<Stream> DownloadFileAndReturn(ChatMessages message, Stream ms = null, string fileName = null, string folder = null, DownloadModel model = null);
2526
Task<Stream> DownloadFileAndReturnWithOffset(ChatMessages message, Stream ms = null, string fileName = null, string folder = null, DownloadModel model = null, long offset = 0);
2627
Task<List<ChatViewBase>> GetFouriteChannels(bool mustRefresh = true);

TelegramDownloader/Data/TelegramService.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Syncfusion.Blazor.Sparkline.Internal;
99
using System.Collections.Generic;
1010
using System.Reflection.Metadata;
11+
using System.Runtime.CompilerServices;
1112
using System.Threading.Channels;
1213
using System.Threading.Tasks;
1314
using TelegramDownloader.Data.db;
@@ -1126,6 +1127,62 @@ public async Task<Byte[]> DownloadFileStream(Message message, long offset, int l
11261127
throw new ArgumentException("Invalid message or media type.");
11271128
}
11281129

1130+
/// <summary>
1131+
/// Streams a byte range from Telegram in 512KB chunks, yielding each chunk as it
1132+
/// arrives instead of buffering the whole range in memory. Offset must be 4KB-aligned
1133+
/// (callers align to 512KB). Stops early if Telegram signals end of file.
1134+
/// </summary>
1135+
public async IAsyncEnumerable<byte[]> DownloadFileStreamChunks(Message message, long offset, long limit, [EnumeratorCancellation] CancellationToken ct = default)
1136+
{
1137+
_logger.LogDebug("DownloadFileStreamChunks - Offset: {Offset}, Limit: {Limit}", offset, limit);
1138+
1139+
if (message is not Message msg || msg.media is not MessageMediaDocument doc)
1140+
throw new ArgumentException("Invalid message or media type.");
1141+
1142+
InputDocument inputFile = doc.document;
1143+
long currentOffset = offset;
1144+
long remaining = limit;
1145+
1146+
while (remaining > 0)
1147+
{
1148+
ct.ThrowIfCancellationRequested();
1149+
1150+
var location = new InputDocumentFileLocation
1151+
{
1152+
id = inputFile.id,
1153+
access_hash = inputFile.access_hash,
1154+
file_reference = inputFile.file_reference,
1155+
thumb_size = ""
1156+
};
1157+
1158+
Upload_FileBase file;
1159+
try
1160+
{
1161+
file = await client.Upload_GetFile(location, currentOffset, limit: FILESPLITSIZE);
1162+
}
1163+
catch (RpcException ex) when (ex.Code == 303 && ex.Message == "FILE_MIGRATE_X")
1164+
{
1165+
var dcClient = await client.GetClientForDC(-ex.X, true);
1166+
file = await dcClient.Upload_GetFile(location, currentOffset, limit: FILESPLITSIZE);
1167+
}
1168+
1169+
if (file is not Upload_File uploadFile)
1170+
throw new InvalidOperationException("Unexpected file type returned.");
1171+
1172+
if (uploadFile.bytes.Length == 0)
1173+
yield break;
1174+
1175+
yield return uploadFile.bytes;
1176+
1177+
currentOffset += uploadFile.bytes.Length;
1178+
remaining -= uploadFile.bytes.Length;
1179+
1180+
// Telegram returns fewer bytes than requested only at end of file
1181+
if (uploadFile.bytes.Length < FILESPLITSIZE)
1182+
yield break;
1183+
}
1184+
}
1185+
11291186
public async Task<Stream> DownloadFileAndReturn(ChatMessages message, Stream ms = null, string fileName = null, string folder = null, DownloadModel model = null)
11301187
{
11311188
if (model == null)

0 commit comments

Comments
 (0)