Skip to content

Commit 59717e1

Browse files
authored
Develop to main (#112)
* fix: fix folder creation logic to ensure CurrentFolder is valid before proceeding * fix: solve upload to empty folder and upload to root folder * fix: solve folder problem * Sync develop with main after v3.6.3 (#93) * Develop to main (#90) * fix: fix folder creation logic to ensure CurrentFolder is valid before proceeding * fix: solve upload to empty folder and upload to root folder * fix: solve folder problem * Bump version to 3.6.3 --------- Co-authored-by: Mateo <mateof@gmail.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix: update MongoDB.Driver and System.IO.Hashing package versions * feat: wire progressive download cache into tfm streaming endpoint - StreamAudioByTfmId now starts the background ProgressiveDownloadService download (previously injected but never invoked) so every streamed track is fetched from Telegram once and persisted to the disk cache - Serve ranges from the growing cache file, waiting briefly when the background download is close instead of opening duplicate fetches - Direct Telegram fetches (far seeks) now stream 512KB chunks to the response via new DownloadFileStreamChunks and are limited by a semaphore - Robust Range parsing: TryParse, suffix ranges (bytes=-N), 416 for unsatisfiable ranges, removed ambiguous to==0 sentinel - ProgressiveDownloadService: fix IsRangeAvailable null check, drop stale entries when cache files are evicted, cap retries with backoff, and trim the cache directory to 10GB (oldest first, throttled) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: configurable STRM streaming mode with progressive disk cache Add a 'Streaming Mode' setting (Direct streaming / Progressive cache / Full preload) selectable in the web Config page, used when generating STRM files for Emby/Kodi. - New GetFileStreamCached endpoint: streams from Telegram with Range support while a background download fills the local cache; later ranges and replays are served from disk. - ProgressiveDownloadService now supports split (multi-message) files, caching parts sequentially into a single file with resume support. - Direct seeks ahead of the cache locate the Telegram part containing the requested range and cap the response at the part boundary. - STRM generation picks the endpoint from the configured mode; files smaller than MaxPreloadFileSizeInMb are still fully preloaded. - Backwards compatible with the legacy PreloadFilesOnStream flag (kept in sync on save; used as fallback when the new setting is unset). * fix: show progress and honor cancel for progressive cache downloads The background cache download appeared in the downloads list but never updated its progress and could not be stopped: - Report progress through DownloadModel.ProgressCallback (updates percentage, transmitted/size strings, global speed stats and fires the UI events) instead of writing _transmitted directly. The task is now also marked Completed when the download finishes. - Check the task state on every chunk so pressing Cancel (or Pause) in the downloads list actually stops the background download. - A user cancel is remembered for 1 hour per file so ongoing playback range requests don't immediately restart the cache download; the stream keeps working through direct Telegram fetches. A later playback caches again. - Start the progress counter at the resume offset so resumed downloads don't inflate the global speed stats. - Incomplete downloads no longer linger as 'Working' in the list. * feat: audio transcoding endpoint for offline downloads (MP3/AAC) - GET api/mobile/stream/tfm/{channelId}/{tfmId}/transcoded?format=mp3|aac&bitrate=N downloads the original into the streaming cache if needed, transcodes it with FFmpeg and serves the result with Range support; transcodes are cached on disk under _temp/transcoded so repeat requests are instant - GET api/mobile/stream/transcode/info reports FFmpeg availability so clients can warn the user and fall back to original downloads - Returns 501 when FFmpeg is missing; per-target locks avoid duplicate transcodes and a semaphore caps concurrent FFmpeg processes at 2 - MP3 keeps embedded cover art and tags; AAC (m4a) keeps tags * fix: reliable live task updates in Tasks Manager Task, download and upload tables required a full page reload to show progress because per-model event subscriptions were only wired when the component's static cached list was empty, so newly created models never got subscribed. Static lists were also shared between the Active and Queue table instances, and grid refreshes were fired from background threads without dispatcher marshaling. TransactionInfoService now owns all per-model subscriptions (hooked when a model enters any list, unhooked on remove/clear) and exposes a single aggregated TransactionsChanged event, throttled to 250ms with a guaranteed trailing raise. Tables use instance lists and subscribe only to that event, refreshing via InvokeAsync. The Tasks Manager header stats now update live as well. * feat: configurable parallel chunk transfers for faster downloads/uploads Transfers were capped at ~5-7 MB/s because WTelegramClient requests 512KB file parts with only 2 requests in flight (its default since v4.x), making throughput latency-bound at roughly 1MB per round-trip to Telegram's data center. Add a ParallelTransfers setting (default 4, range 1-16) applied to the main client and, before each document download, to the media-DC client that WTelegram resolves internally - secondary DC clients do not inherit the main client's setting, so it must be propagated per instance. The applied value is tracked per client and adjusted by delta, which stays correct even while parts are in flight. Changes take effect on the next transfer without restarting. * feat: multi-connection downloads to bypass per-connection speed limit Telegram enforces its throughput limit per MTProto connection (~5-6 MB/s), so pipelining more chunks on one connection cannot go faster; pushing harder only triggers FLOOD_PREMIUM_WAIT penalties. Official clients reach 50+ MB/s by opening several sessions to the file DC and splitting the file between them. Add an experimental multi-connection download mode (off by default): a per-DC pool of extra authorized clients, bootstrapped once from the main client via auth.exportAuthorization/importAuthorization, persisted as session files and reused across restarts. Files >=32MB are split in 4MB blocks served concurrently by 2-8 connections (configurable), each part written at its absolute offset via RandomAccess. Progress reports the contiguous completed prefix so persistence keeps a safe resume offset, while speed accounting counts every received part. Any failure falls back transparently to the standard sequential download. * fix: enable multi-connection mode on the file-manager download path Multi-connection downloads were only hooked into DownloadFile, but file-manager download tasks go through DownloadFileNow, which calls DownloadFileAndReturn with a FileStream target, so the feature never triggered on the most common path. Hook DownloadFileAndReturn (and the offset==0 branch of DownloadFileAndReturnWithOffset) when the destination is a FileStream, which supports the positional writes multi-connection mode requires. Memory and non-seekable targets keep the sequential path. * fix: bootstrap download pool via neighbor DC to avoid DC_ID_INVALID Telegram rejects auth.exportAuthorization towards the DC the caller is already connected to, so the pool bootstrap failed with DC_ID_INVALID whenever the file lived on the account home DC (the common case) and downloads always fell back to a single connection. Home the pool clients on a neighbor DC instead: exporting from the main client to the neighbor is always cross-DC, and from there the pool client can export towards any file DC, including the account home DC - both hops are cross-DC and accepted. This also makes the pool global rather than per-DC (one set of sessions serves every DC through per-file-DC transfer clients resolved with GetClientForDC), and the bootstrap failure log now includes the error message. * fix: finalize pool client login and drop probe RPCs after import The pool bootstrap verified the imported authorization with users.getUsers, which answers AUTH_KEY_UNREGISTERED on an imported session even though the import succeeded and file requests work, so the bootstrap always aborted and downloads fell back to a single connection. Import the exported authorization as the first call on the fresh client, finalize it client-side with LoginAlreadyDone (recording the UserId in the session), and make no verification RPC - workers verify by use and fall back on error. With the UserId recorded, WTelegram's GetClientForDC now handles the per-file-DC authorization automatically, replacing the manual transfer-client export/import logic; persisted sessions skip the import on later runs via the recorded UserId. * fix: tolerate re-import on an already-authorized pool session key Session files persisted by earlier runs (where the import succeeded but the login was never finalized client-side) hold a key that already carries the account authorization; re-importing onto it is rejected by the server with AUTH_BYTES_INVALID and the bootstrap aborted. Treat AUTH_BYTES_INVALID on import as already-authorized: finalize the login client-side with the user id returned by exportAuthorization and let the workers verify the session by use, falling back to the sequential download if it turns out to be broken. * feat: pool clients clone the main session instead of importing auth The exportAuthorization/importAuthorization bootstrap turned out to be a dead end: an imported session is a limited authorization - file requests work, but probe RPCs and re-exports answer AUTH_KEY_UNREGISTERED - so the pool client could never authorize its per-file-DC transfer connections (GetClientForDC's automatic export failed with AUTH_KEY_UNREGISTERED) and downloads always fell back. Clone the main session file instead: each pool client loads a copy of WTelegram.session, sharing the fully-authorized main auth key, while WTelegram assigns a fresh transient MTProto session id per client instance. The server sees extra connections of the existing authorization - the exact model official clients use for parallel downloads. Telegram's throughput limit is per connection/session, not per auth key, so each clone gets its own allowance. No authorizations are created, nothing appears in the device list, and cross-DC files keep working because the clones are fully logged sessions. * fix: clone the session despite the live file lock The main client keeps WTelegram.session open (often exclusively), so File.Copy failed with a sharing violation and the pool bootstrap always fell back to single-connection downloads. Snapshot the session file at client creation, before WTelegram opens and locks it, and make the clone routine try a share-friendly stream copy of the live file first, falling back to the startup snapshot. Session state staleness is harmless: the auth key never changes and server salts are renegotiated automatically. * feat: pipeline file parts within each download connection (#107) * feat: pipeline file parts within each download connection Workers requested their block's 1MB parts sequentially, paying a full round-trip of dead time per part and capping each connection around 3-4 MB/s regardless of its server-side allowance - the multi-connection download barely improved on a single connection (~10 MB/s with dips). Request all parts of a block concurrently on the block's connection, keeping each connection's pipe full (the same pipelining official clients use), and log a completion summary with the effective average speed to make future tuning measurable. Part writes stay positional and block completion still gates the contiguous confirmed prefix used for progress and resume persistence. * feat: make multi-connection tuning configurable with documented defaults Expose the remaining hardcoded transfer knobs in General Config: chunk size (snapped to Telegram's allowed 128/256/512/1024 KB values, 512 being WTelegramClient's own default), block size per connection (which determines the requests in flight per connection) and the minimum file size for multi-connection mode. Values are captured once per download so a config change cannot desynchronize offsets mid-transfer. The Config page now states the library default, app default and recommended value for each transfer setting, so the stock WTelegramClient behavior (2 parallel chunks, 512KB parts, single connection) can be restored by configuration alone. * feat: real sea-wave effect on the top bar download/upload buttons The water-fill buttons showed a flat colored block with a horizontal shine sweep - no actual wave at the waterline. Replace the shine with two repeating SVG wave crests that ride exactly on the fill level (the .water-wave element's top edge), drifting horizontally at different speeds and in opposite directions, overlapping the fill by 1px so no seam shows. The fill itself now uses a vertical gradient (deeper at the bottom, lighter at the surface) per variant color, wave motion is disabled under prefers-reduced-motion, and the stylesheet link gets a version query so cached browsers pick up the change. No markup changes to the buttons themselves. * feat: auth guard before rendering with return to the requested page After an app restart the Telegram client exists but the user is not loaded, so pages rendered first and only then (on first render) the layout noticed the missing user and hard-redirected to the login page, losing the URL the user was visiting; after authenticating, login always landed on /fetchdata. Resolve the session in the layouts' OnInitializedAsync, before any page content renders: the body is gated behind the check (page components are not instantiated until it completes), a silent session restore via checkAuth(null) is attempted first, and only when interactive login is really needed does the guard bounce to the login page, passing the requested URL as ?returnUrl. The login page navigates back to that URL (local paths only, to avoid open redirects) after a successful login. ConfigLayout gets the same guard; the old after-render check is removed. * feat: QR code login option in the web login page The QR login plumbing (CallQrGenerator wrapping LoginWithQRCode, QR rendering in Index) existed but nothing invoked it, and it could not have worked from the web anyway: when the account has 2FA, LoginWithQRCode asks for the password through Config("password"), which with the convenience constructor prompts on the console - QR login only worked when driving the library from a terminal. Wire it end to end: - The main client is now built with a custom config callback that routes the "password" request to the web UI: a QrPasswordNeeded event switches the login form to the 2FA password step and the value is handed back to the waiting login task, which blocks on a TaskCompletionSource (5 minute timeout) on its background thread. - The login page offers "Log in with QR code" next to the phone step: it shows the tg://login QR (auto-refreshed by the library when each token expires), instructions and a back button; on success the shared post-login initialization runs and navigation proceeds as with the normal flow. - DoLogin's post-login tail is extracted into CompleteLogin, reused by the QR flow. - checkAuth: when a session exists but no user data file is saved (the QR case - there is no phone to save), attempt a silent session restore via DoLogin(null) instead of always bouncing to the phone step, so QR sessions survive app restarts. * feat: live refresh in the download/upload/task info modals The info modals held a live reference to their model but only rendered a snapshot taken when opened, so progress, state, duration and speed froze until the modal was reopened. Subscribe each modal to the aggregated throttled TransactionsChanged event while it is visible (subscribed on ShowModal, unsubscribed on HideModal and Dispose) and re-render on the UI thread, following the same pattern the transfer tables use.
1 parent 9ecbf0c commit 59717e1

22 files changed

Lines changed: 2286 additions & 270 deletions

TelegramDownloader/Controllers/FileController.cs

Lines changed: 267 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,22 @@ public class FileController : ControllerBase
3131
string root = FileService.RELATIVELOCALDIR;
3232

3333
private static SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1);
34+
35+
// Limit concurrent direct range fetches against Telegram (seeks ahead of the cache)
36+
private static readonly SemaphoreSlim telegramRangeSemaphore = new SemaphoreSlim(4);
37+
38+
// If the requested range starts within this distance of the background download
39+
// position, wait for the download instead of opening a duplicate Telegram fetch
40+
private const long WAIT_PROXIMITY_BYTES = 8 * 1024 * 1024;
41+
3442
IDbService _db { get; set; }
3543
ITelegramService _ts { get; set; }
3644
IFileService _fs { get; set; }
3745
TransactionInfoService _tis { get; set; }
46+
private readonly IProgressiveDownloadService _progressiveDownload;
3847
private ILogger<IFileService> _logger { get; set; }
3948

40-
public FileController(IDbService db, ITelegramService ts, IFileService fs, TransactionInfoService tis, ILogger<IFileService> logger)
49+
public FileController(IDbService db, ITelegramService ts, IFileService fs, TransactionInfoService tis, IProgressiveDownloadService progressiveDownload, ILogger<IFileService> logger)
4150
{
4251
this.basePath = Environment.CurrentDirectory;
4352
if (!System.IO.Directory.Exists(Path.Combine(basePath, root)))
@@ -48,6 +57,7 @@ public FileController(IDbService db, ITelegramService ts, IFileService fs, Trans
4857
_ts = ts;
4958
_db = db;
5059
_tis = tis;
60+
_progressiveDownload = progressiveDownload;
5161
_logger = logger;
5262

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

663673
}
664674

675+
/// <summary>
676+
/// Stream file from Telegram while caching it to disk in the background (progressive streaming).
677+
/// Playback starts immediately; once fully cached, ranges are served from disk.
678+
/// Supports split (multi-message) files: parts are cached sequentially into a single file
679+
/// and seeks ahead of the cache are fetched from the part that contains the requested range.
680+
/// </summary>
681+
/// <param name="idChannel">Telegram channel ID</param>
682+
/// <param name="idFile">TFM database file ID</param>
683+
/// <param name="name">File name (used for mime type / Content-Disposition)</param>
684+
[HttpGet]
685+
[Route("GetFileStreamCached/{idChannel}/{idFile}/{name}")]
686+
[ProducesResponseType(typeof(FileStreamResult), 200)]
687+
[ProducesResponseType(206)]
688+
[ProducesResponseType(404)]
689+
[ProducesResponseType(416)]
690+
public async Task<IActionResult> GetFileStreamCached(string idChannel, string idFile, string name)
691+
{
692+
var mimeType = FileService.getMimeType(name.Split(".").Last());
693+
694+
var dbFile = await _fs.getItemById(idChannel, idFile);
695+
if (dbFile == null)
696+
{
697+
return NotFound();
698+
}
699+
700+
// Ordered Telegram messages that make up the file (several for split files)
701+
var messageIds = ProgressiveDownloadService.GetMessageIds(dbFile);
702+
if (messageIds == null || messageIds.Count == 0)
703+
{
704+
return NotFound();
705+
}
706+
707+
var fileName = dbFile.Name;
708+
var cacheFileName = $"{idChannel}-{(dbFile.MessageId != null ? dbFile.MessageId.ToString() : dbFile.Id)}-{fileName}";
709+
var tempPath = Path.Combine(FileService.TEMPDIR, "_temp");
710+
var filePath = Path.Combine(tempPath, cacheFileName);
711+
Directory.CreateDirectory(tempPath);
712+
713+
long totalLength = dbFile.Size;
714+
715+
// Fully cached: serve straight from disk with full range support
716+
if (System.IO.File.Exists(filePath) && new FileInfo(filePath).Length >= totalLength)
717+
{
718+
_logger.LogDebug("GetFileStreamCached - serving fully cached file {FileName}", fileName);
719+
Response.Headers["Content-Disposition"] = $"inline; filename=\"{HttpUtility.UrlEncode(fileName)}\"";
720+
return PhysicalFile(filePath, mimeType, enableRangeProcessing: true);
721+
}
722+
723+
// Parse range header (RFC 7233: bytes=X-, bytes=X-Y, bytes=-N)
724+
var rangeHeader = Request.Headers["Range"].ToString();
725+
long from = 0;
726+
long? to = null;
727+
bool hasRange = false;
728+
729+
if (!string.IsNullOrEmpty(rangeHeader) && rangeHeader.StartsWith("bytes=") && !rangeHeader.Contains(','))
730+
{
731+
var parts = rangeHeader.Substring("bytes=".Length).Split('-');
732+
if (parts.Length == 2)
733+
{
734+
if (string.IsNullOrEmpty(parts[0]))
735+
{
736+
// Suffix range: last N bytes
737+
if (long.TryParse(parts[1], out var suffixLength) && suffixLength > 0)
738+
{
739+
from = Math.Max(0, totalLength - suffixLength);
740+
to = totalLength - 1;
741+
hasRange = true;
742+
}
743+
}
744+
else if (long.TryParse(parts[0], out var parsedFrom) && parsedFrom >= 0)
745+
{
746+
from = parsedFrom;
747+
hasRange = true;
748+
if (!string.IsNullOrEmpty(parts[1]) && long.TryParse(parts[1], out var parsedTo))
749+
to = parsedTo;
750+
}
751+
}
752+
// Malformed ranges fall through as "no range" (initial chunk)
753+
}
754+
755+
if (hasRange && (from >= totalLength || (to.HasValue && to.Value < from)))
756+
{
757+
Response.Headers["Content-Range"] = $"bytes */{totalLength}";
758+
return StatusCode(StatusCodes.Status416RangeNotSatisfiable);
759+
}
760+
761+
// Initial request without Range: return the first chunk as 206 with total size info
762+
if (!hasRange)
763+
{
764+
from = 0;
765+
to = Math.Min(6 * 1024 * 1024, totalLength - 1);
766+
}
767+
768+
// Open-ended or oversized ranges: cap the response so the player keeps asking
769+
long rangeEnd = (!to.HasValue || to.Value >= totalLength)
770+
? Math.Min(from + (4 * 1024 * 1024), totalLength - 1)
771+
: to.Value;
772+
773+
// Kick off (or attach to) the background download that fills the disk cache,
774+
// so the file is downloaded from Telegram only once
775+
ProgressiveDownloadInfo downloadInfo = null;
776+
try
777+
{
778+
downloadInfo = await _progressiveDownload.StartOrGetDownloadAsync(cacheFileName, idChannel, dbFile, filePath);
779+
}
780+
catch (Exception ex)
781+
{
782+
_logger.LogWarning(ex, "Could not start background cache download for {FileName}", fileName);
783+
}
784+
785+
long CachedBytes()
786+
{
787+
long onDisk = System.IO.File.Exists(filePath) ? new FileInfo(filePath).Length : 0;
788+
return Math.Max(onDisk, downloadInfo?.DownloadedBytes ?? 0);
789+
}
790+
791+
try
792+
{
793+
// If the background download is nearby, wait briefly for it to cover the range
794+
// start instead of opening a duplicate Telegram fetch (normal sequential playback)
795+
if (downloadInfo != null && downloadInfo.IsDownloading &&
796+
from - CachedBytes() <= WAIT_PROXIMITY_BYTES)
797+
{
798+
var waitTarget = Math.Min(rangeEnd, from + 524288); // at least 512KB past the start
799+
var deadline = DateTime.UtcNow.AddSeconds(15);
800+
while (downloadInfo.IsDownloading &&
801+
downloadInfo.DownloadedBytes <= waitTarget &&
802+
DateTime.UtcNow < deadline)
803+
{
804+
await Task.Delay(150, HttpContext.RequestAborted);
805+
}
806+
}
807+
808+
var cachedBytes = CachedBytes();
809+
810+
// Serve from the (possibly still growing) cache file
811+
if (from < cachedBytes)
812+
{
813+
var availableEnd = Math.Min(rangeEnd, cachedBytes - 1);
814+
var length = availableEnd - from + 1;
815+
816+
_logger.LogDebug("GetFileStreamCached - serving from cache: bytes {From}-{To} of {Total}", from, availableEnd, totalLength);
817+
818+
using var cacheStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
819+
cacheStream.Seek(from, SeekOrigin.Begin);
820+
var buffer = new byte[length];
821+
var bytesRead = await cacheStream.ReadAsync(buffer, 0, (int)length, HttpContext.RequestAborted);
822+
823+
Response.StatusCode = StatusCodes.Status206PartialContent;
824+
Response.ContentType = mimeType;
825+
Response.ContentLength = bytesRead;
826+
Response.Headers["Content-Range"] = $"bytes {from}-{from + bytesRead - 1}/{totalLength}";
827+
Response.Headers["Accept-Ranges"] = "bytes";
828+
Response.Headers["Content-Disposition"] = $"inline; filename=\"{HttpUtility.UrlEncode(fileName)}\"";
829+
830+
await Response.Body.WriteAsync(buffer, 0, bytesRead, HttpContext.RequestAborted);
831+
return new EmptyResult();
832+
}
833+
834+
// Range far ahead of the background download (seek): fetch it directly from
835+
// Telegram, streaming each 512KB chunk to the response as it arrives
836+
_logger.LogDebug("GetFileStreamCached - streaming from Telegram: bytes {From}-{To} of {Total}", from, rangeEnd, totalLength);
837+
838+
// Locate the part (Telegram message) containing `from`. Like GetFileStream,
839+
// assume uniform part sizes (the splitter uses a fixed split size)
840+
TL.Message message;
841+
long partStart = 0;
842+
long partSize;
843+
if (messageIds.Count == 1)
844+
{
845+
message = await _ts.getMessageFile(idChannel, messageIds[0]);
846+
partSize = ProgressiveDownloadService.GetDocumentSize(message);
847+
}
848+
else
849+
{
850+
var firstMessage = await _ts.getMessageFile(idChannel, messageIds[0]);
851+
long firstPartSize = ProgressiveDownloadService.GetDocumentSize(firstMessage);
852+
if (firstPartSize <= 0)
853+
{
854+
return NotFound();
855+
}
856+
int partIndex = (int)Math.Min(from / firstPartSize, messageIds.Count - 1);
857+
partStart = (long)partIndex * firstPartSize;
858+
message = partIndex == 0 ? firstMessage : await _ts.getMessageFile(idChannel, messageIds[partIndex]);
859+
partSize = ProgressiveDownloadService.GetDocumentSize(message);
860+
}
861+
862+
if (message == null || partSize <= 0)
863+
{
864+
return NotFound();
865+
}
866+
867+
// Serve only up to the end of this part; the player asks for the rest itself
868+
rangeEnd = Math.Min(rangeEnd, partStart + partSize - 1);
869+
if (rangeEnd < from)
870+
{
871+
Response.Headers["Content-Range"] = $"bytes */{totalLength}";
872+
return StatusCode(StatusCodes.Status416RangeNotSatisfiable);
873+
}
874+
875+
// Align down to 512KB for Telegram; skip the prefix when writing the response
876+
var localFrom = from - partStart;
877+
var alignedFrom = (localFrom / 524288) * 524288;
878+
var skipBytes = localFrom - alignedFrom;
879+
var responseLength = rangeEnd - from + 1;
880+
881+
await telegramRangeSemaphore.WaitAsync(HttpContext.RequestAborted);
882+
try
883+
{
884+
Response.StatusCode = StatusCodes.Status206PartialContent;
885+
Response.ContentType = mimeType;
886+
Response.ContentLength = responseLength;
887+
Response.Headers["Content-Range"] = $"bytes {from}-{rangeEnd}/{totalLength}";
888+
Response.Headers["Accept-Ranges"] = "bytes";
889+
Response.Headers["Content-Disposition"] = $"inline; filename=\"{HttpUtility.UrlEncode(fileName)}\"";
890+
891+
long remainingSkip = skipBytes;
892+
long remainingWrite = responseLength;
893+
894+
await foreach (var chunk in _ts.DownloadFileStreamChunks(
895+
message, alignedFrom, skipBytes + responseLength, HttpContext.RequestAborted))
896+
{
897+
int start = 0;
898+
int length = chunk.Length;
899+
900+
if (remainingSkip > 0)
901+
{
902+
var toSkip = (int)Math.Min(remainingSkip, length);
903+
start += toSkip;
904+
length -= toSkip;
905+
remainingSkip -= toSkip;
906+
}
907+
908+
if (length <= 0) continue;
909+
910+
var toWrite = (int)Math.Min(length, remainingWrite);
911+
await Response.Body.WriteAsync(chunk, start, toWrite, HttpContext.RequestAborted);
912+
remainingWrite -= toWrite;
913+
914+
if (remainingWrite <= 0) break;
915+
}
916+
}
917+
finally
918+
{
919+
telegramRangeSemaphore.Release();
920+
}
921+
922+
return new EmptyResult();
923+
}
924+
catch (OperationCanceledException)
925+
{
926+
_logger.LogDebug("Client closed connection during cached stream - File: {FileName}", fileName);
927+
return new EmptyResult();
928+
}
929+
}
930+
665931
/// <summary>
666932
/// Export channel database to JSON file
667933
/// </summary>

0 commit comments

Comments
 (0)