Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
8fd2d25
Merge pull request #89 from mateof/sync/main-to-develop-v3.6.2
mateof Feb 9, 2026
682d9d8
fix: fix folder creation logic to ensure CurrentFolder is valid befor…
mateof Apr 14, 2026
8baf50c
fix: solve upload to empty folder and upload to root folder
mateof Apr 14, 2026
0c50bd0
fix: solve folder problem
mateof Apr 14, 2026
f991d16
Sync develop with main after v3.6.3 (#93)
github-actions[bot] Apr 14, 2026
d1c60e1
fix: update MongoDB.Driver and System.IO.Hashing package versions
mateof May 23, 2026
f37761c
feat: wire progressive download cache into tfm streaming endpoint
mateof Jul 12, 2026
0b8072b
Merge pull request #94 from mateof/feat/progressive-streaming-cache
mateof Jul 12, 2026
128776a
feat: configurable STRM streaming mode with progressive disk cache
mateof Jul 13, 2026
2ca1bb8
Merge pull request #95 from mateof/feature/configurable-strm-streamin…
mateof Jul 13, 2026
13bf92f
fix: show progress and honor cancel for progressive cache downloads
mateof Jul 13, 2026
6e61d7d
Merge pull request #96 from mateof/feature/configurable-strm-streamin…
mateof Jul 13, 2026
e64b8eb
feat: audio transcoding endpoint for offline downloads (MP3/AAC)
mateof Jul 19, 2026
543e66f
Merge pull request #97 from mateof/feat/audio-transcode-downloads
mateof Jul 19, 2026
05fdae6
fix: reliable live task updates in Tasks Manager
mateof Jul 21, 2026
893269c
feat: configurable parallel chunk transfers for faster downloads/uploads
mateof Jul 21, 2026
e4e29dc
Merge pull request #98 from mateof/fix/tasks-live-refresh
mateof Jul 21, 2026
a2129ee
Merge pull request #99 from mateof/feat/configurable-parallel-transfers
mateof Jul 21, 2026
11181c1
feat: multi-connection downloads to bypass per-connection speed limit
mateof Jul 21, 2026
fbf90db
Merge pull request #100 from mateof/feat/multi-connection-downloads
mateof Jul 21, 2026
1c4a832
fix: enable multi-connection mode on the file-manager download path
mateof Jul 21, 2026
33807ab
Merge pull request #101 from mateof/fix/multi-connection-download-path
mateof Jul 21, 2026
51ec46b
fix: bootstrap download pool via neighbor DC to avoid DC_ID_INVALID
mateof Jul 21, 2026
370da09
fix: bootstrap download pool via neighbor DC to avoid DC_ID_INVALID (…
mateof Jul 21, 2026
39e2b52
fix: finalize pool client login and drop probe RPCs after import
mateof Jul 21, 2026
c78ba87
Merge pull request #103 from mateof/fix/pool-imported-auth
mateof Jul 21, 2026
1f87e13
fix: tolerate re-import on an already-authorized pool session key
mateof Jul 21, 2026
b453f1f
fix: tolerate re-import on an already-authorized pool session key (#104)
mateof Jul 21, 2026
d9e673a
feat: pool clients clone the main session instead of importing auth
mateof Jul 21, 2026
51d0537
Merge pull request #105 from mateof/feat/pool-session-clone
mateof Jul 22, 2026
09f60c9
fix: clone the session despite the live file lock
mateof Jul 22, 2026
1179efb
fix: clone the session despite the live file lock (#106)
mateof Jul 22, 2026
895097b
feat: pipeline file parts within each download connection (#107)
mateof Jul 22, 2026
7df75a6
feat: real sea-wave effect on the top bar download/upload buttons
mateof Jul 22, 2026
0715286
Merge pull request #108 from mateof/feature/water-fill-wave-effect
mateof Jul 22, 2026
5adb1eb
feat: auth guard before rendering with return to the requested page
mateof Jul 22, 2026
b6874fb
Merge pull request #109 from mateof/feature/auth-guard-return-url
mateof Jul 22, 2026
f86d1e2
feat: QR code login option in the web login page
mateof Jul 22, 2026
8ff954e
Merge pull request #110 from mateof/feature/qr-login
mateof Jul 22, 2026
e432b95
feat: live refresh in the download/upload/task info modals
mateof Jul 22, 2026
6d937ea
Merge pull request #111 from mateof/feature/live-info-modals
mateof Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion TFMAudioApp/TFMAudioApp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
<ApplicationId>com.tfm.audioapp</ApplicationId>

<!-- Versions - Updated during CI/CD -->
<ApplicationDisplayVersion>3.6.2</ApplicationDisplayVersion>
<ApplicationDisplayVersion>3.6.3</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>

<!-- Windows unpackaged for development -->
Expand Down
268 changes: 267 additions & 1 deletion TelegramDownloader/Controllers/FileController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,22 @@ public class FileController : ControllerBase
string root = FileService.RELATIVELOCALDIR;

private static SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1);

// Limit concurrent direct range fetches against Telegram (seeks ahead of the cache)
private static readonly SemaphoreSlim telegramRangeSemaphore = new SemaphoreSlim(4);

// If the requested range starts within this distance of the background download
// position, wait for the download instead of opening a duplicate Telegram fetch
private const long WAIT_PROXIMITY_BYTES = 8 * 1024 * 1024;

IDbService _db { get; set; }
ITelegramService _ts { get; set; }
IFileService _fs { get; set; }
TransactionInfoService _tis { get; set; }
private readonly IProgressiveDownloadService _progressiveDownload;
private ILogger<IFileService> _logger { get; set; }

public FileController(IDbService db, ITelegramService ts, IFileService fs, TransactionInfoService tis, ILogger<IFileService> logger)
public FileController(IDbService db, ITelegramService ts, IFileService fs, TransactionInfoService tis, IProgressiveDownloadService progressiveDownload, ILogger<IFileService> logger)
{
this.basePath = Environment.CurrentDirectory;
if (!System.IO.Directory.Exists(Path.Combine(basePath, root)))
Expand All @@ -48,6 +57,7 @@ public FileController(IDbService db, ITelegramService ts, IFileService fs, Trans
_ts = ts;
_db = db;
_tis = tis;
_progressiveDownload = progressiveDownload;
_logger = logger;

this.operation = new PhysicalFileProvider();
Expand Down Expand Up @@ -662,6 +672,262 @@ public async Task<IActionResult> GetFileStream(string idChannel, string idFile,

}

/// <summary>
/// Stream file from Telegram while caching it to disk in the background (progressive streaming).
/// Playback starts immediately; once fully cached, ranges are served from disk.
/// Supports split (multi-message) files: parts are cached sequentially into a single file
/// and seeks ahead of the cache are fetched from the part that contains the requested range.
/// </summary>
/// <param name="idChannel">Telegram channel ID</param>
/// <param name="idFile">TFM database file ID</param>
/// <param name="name">File name (used for mime type / Content-Disposition)</param>
[HttpGet]
[Route("GetFileStreamCached/{idChannel}/{idFile}/{name}")]
[ProducesResponseType(typeof(FileStreamResult), 200)]
[ProducesResponseType(206)]
[ProducesResponseType(404)]
[ProducesResponseType(416)]
public async Task<IActionResult> GetFileStreamCached(string idChannel, string idFile, string name)
{
var mimeType = FileService.getMimeType(name.Split(".").Last());

var dbFile = await _fs.getItemById(idChannel, idFile);
if (dbFile == null)
{
return NotFound();
}

// Ordered Telegram messages that make up the file (several for split files)
var messageIds = ProgressiveDownloadService.GetMessageIds(dbFile);
if (messageIds == null || messageIds.Count == 0)
{
return NotFound();
}

var fileName = dbFile.Name;
var cacheFileName = $"{idChannel}-{(dbFile.MessageId != null ? dbFile.MessageId.ToString() : dbFile.Id)}-{fileName}";
var tempPath = Path.Combine(FileService.TEMPDIR, "_temp");
var filePath = Path.Combine(tempPath, cacheFileName);
Directory.CreateDirectory(tempPath);

long totalLength = dbFile.Size;

// Fully cached: serve straight from disk with full range support
if (System.IO.File.Exists(filePath) && new FileInfo(filePath).Length >= totalLength)
{
_logger.LogDebug("GetFileStreamCached - serving fully cached file {FileName}", fileName);
Response.Headers["Content-Disposition"] = $"inline; filename=\"{HttpUtility.UrlEncode(fileName)}\"";
return PhysicalFile(filePath, mimeType, enableRangeProcessing: true);
}

// Parse range header (RFC 7233: bytes=X-, bytes=X-Y, bytes=-N)
var rangeHeader = Request.Headers["Range"].ToString();
long from = 0;
long? to = null;
bool hasRange = false;

if (!string.IsNullOrEmpty(rangeHeader) && rangeHeader.StartsWith("bytes=") && !rangeHeader.Contains(','))
{
var parts = rangeHeader.Substring("bytes=".Length).Split('-');
if (parts.Length == 2)
{
if (string.IsNullOrEmpty(parts[0]))
{
// Suffix range: last N bytes
if (long.TryParse(parts[1], out var suffixLength) && suffixLength > 0)
{
from = Math.Max(0, totalLength - suffixLength);
to = totalLength - 1;
hasRange = true;
}
}
else if (long.TryParse(parts[0], out var parsedFrom) && parsedFrom >= 0)
{
from = parsedFrom;
hasRange = true;
if (!string.IsNullOrEmpty(parts[1]) && long.TryParse(parts[1], out var parsedTo))
to = parsedTo;
}
}
// Malformed ranges fall through as "no range" (initial chunk)
}

if (hasRange && (from >= totalLength || (to.HasValue && to.Value < from)))
{
Response.Headers["Content-Range"] = $"bytes */{totalLength}";
return StatusCode(StatusCodes.Status416RangeNotSatisfiable);
}

// Initial request without Range: return the first chunk as 206 with total size info
if (!hasRange)
{
from = 0;
to = Math.Min(6 * 1024 * 1024, totalLength - 1);
}

// Open-ended or oversized ranges: cap the response so the player keeps asking
long rangeEnd = (!to.HasValue || to.Value >= totalLength)
? Math.Min(from + (4 * 1024 * 1024), totalLength - 1)
: to.Value;

// Kick off (or attach to) the background download that fills the disk cache,
// so the file is downloaded from Telegram only once
ProgressiveDownloadInfo downloadInfo = null;
try
{
downloadInfo = await _progressiveDownload.StartOrGetDownloadAsync(cacheFileName, idChannel, dbFile, filePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not start background cache download for {FileName}", fileName);
}

long CachedBytes()
{
long onDisk = System.IO.File.Exists(filePath) ? new FileInfo(filePath).Length : 0;
return Math.Max(onDisk, downloadInfo?.DownloadedBytes ?? 0);
}

try
{
// If the background download is nearby, wait briefly for it to cover the range
// start instead of opening a duplicate Telegram fetch (normal sequential playback)
if (downloadInfo != null && downloadInfo.IsDownloading &&
from - CachedBytes() <= WAIT_PROXIMITY_BYTES)
{
var waitTarget = Math.Min(rangeEnd, from + 524288); // at least 512KB past the start
var deadline = DateTime.UtcNow.AddSeconds(15);
while (downloadInfo.IsDownloading &&
downloadInfo.DownloadedBytes <= waitTarget &&
DateTime.UtcNow < deadline)
{
await Task.Delay(150, HttpContext.RequestAborted);
}
}

var cachedBytes = CachedBytes();

// Serve from the (possibly still growing) cache file
if (from < cachedBytes)
{
var availableEnd = Math.Min(rangeEnd, cachedBytes - 1);
var length = availableEnd - from + 1;

_logger.LogDebug("GetFileStreamCached - serving from cache: bytes {From}-{To} of {Total}", from, availableEnd, totalLength);

using var cacheStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
cacheStream.Seek(from, SeekOrigin.Begin);
var buffer = new byte[length];
var bytesRead = await cacheStream.ReadAsync(buffer, 0, (int)length, HttpContext.RequestAborted);

Response.StatusCode = StatusCodes.Status206PartialContent;
Response.ContentType = mimeType;
Response.ContentLength = bytesRead;
Response.Headers["Content-Range"] = $"bytes {from}-{from + bytesRead - 1}/{totalLength}";
Response.Headers["Accept-Ranges"] = "bytes";
Response.Headers["Content-Disposition"] = $"inline; filename=\"{HttpUtility.UrlEncode(fileName)}\"";

await Response.Body.WriteAsync(buffer, 0, bytesRead, HttpContext.RequestAborted);
return new EmptyResult();
}

// Range far ahead of the background download (seek): fetch it directly from
// Telegram, streaming each 512KB chunk to the response as it arrives
_logger.LogDebug("GetFileStreamCached - streaming from Telegram: bytes {From}-{To} of {Total}", from, rangeEnd, totalLength);

// Locate the part (Telegram message) containing `from`. Like GetFileStream,
// assume uniform part sizes (the splitter uses a fixed split size)
TL.Message message;
long partStart = 0;
long partSize;
if (messageIds.Count == 1)
{
message = await _ts.getMessageFile(idChannel, messageIds[0]);
partSize = ProgressiveDownloadService.GetDocumentSize(message);
}
else
{
var firstMessage = await _ts.getMessageFile(idChannel, messageIds[0]);
long firstPartSize = ProgressiveDownloadService.GetDocumentSize(firstMessage);
if (firstPartSize <= 0)
{
return NotFound();
}
int partIndex = (int)Math.Min(from / firstPartSize, messageIds.Count - 1);
partStart = (long)partIndex * firstPartSize;
message = partIndex == 0 ? firstMessage : await _ts.getMessageFile(idChannel, messageIds[partIndex]);
partSize = ProgressiveDownloadService.GetDocumentSize(message);
}

if (message == null || partSize <= 0)
{
return NotFound();
}

// Serve only up to the end of this part; the player asks for the rest itself
rangeEnd = Math.Min(rangeEnd, partStart + partSize - 1);
if (rangeEnd < from)
{
Response.Headers["Content-Range"] = $"bytes */{totalLength}";
return StatusCode(StatusCodes.Status416RangeNotSatisfiable);
}

// Align down to 512KB for Telegram; skip the prefix when writing the response
var localFrom = from - partStart;
var alignedFrom = (localFrom / 524288) * 524288;
var skipBytes = localFrom - alignedFrom;
var responseLength = rangeEnd - from + 1;

await telegramRangeSemaphore.WaitAsync(HttpContext.RequestAborted);
try
{
Response.StatusCode = StatusCodes.Status206PartialContent;
Response.ContentType = mimeType;
Response.ContentLength = responseLength;
Response.Headers["Content-Range"] = $"bytes {from}-{rangeEnd}/{totalLength}";
Response.Headers["Accept-Ranges"] = "bytes";
Response.Headers["Content-Disposition"] = $"inline; filename=\"{HttpUtility.UrlEncode(fileName)}\"";

long remainingSkip = skipBytes;
long remainingWrite = responseLength;

await foreach (var chunk in _ts.DownloadFileStreamChunks(
message, alignedFrom, skipBytes + responseLength, HttpContext.RequestAborted))
{
int start = 0;
int length = chunk.Length;

if (remainingSkip > 0)
{
var toSkip = (int)Math.Min(remainingSkip, length);
start += toSkip;
length -= toSkip;
remainingSkip -= toSkip;
}

if (length <= 0) continue;

var toWrite = (int)Math.Min(length, remainingWrite);
await Response.Body.WriteAsync(chunk, start, toWrite, HttpContext.RequestAborted);
remainingWrite -= toWrite;

if (remainingWrite <= 0) break;
}
}
finally
{
telegramRangeSemaphore.Release();
}

return new EmptyResult();
}
catch (OperationCanceledException)
{
_logger.LogDebug("Client closed connection during cached stream - File: {FileName}", fileName);
return new EmptyResult();
}
}

/// <summary>
/// Export channel database to JSON file
/// </summary>
Expand Down
Loading
Loading