diff --git a/TFMAudioApp/TFMAudioApp.csproj b/TFMAudioApp/TFMAudioApp.csproj index 35883e0..a93a84e 100644 --- a/TFMAudioApp/TFMAudioApp.csproj +++ b/TFMAudioApp/TFMAudioApp.csproj @@ -34,7 +34,7 @@ com.tfm.audioapp - 3.6.2 + 3.6.3 1 diff --git a/TelegramDownloader/Controllers/FileController.cs b/TelegramDownloader/Controllers/FileController.cs index e507a2f..c24c799 100644 --- a/TelegramDownloader/Controllers/FileController.cs +++ b/TelegramDownloader/Controllers/FileController.cs @@ -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 _logger { get; set; } - public FileController(IDbService db, ITelegramService ts, IFileService fs, TransactionInfoService tis, ILogger logger) + public FileController(IDbService db, ITelegramService ts, IFileService fs, TransactionInfoService tis, IProgressiveDownloadService progressiveDownload, ILogger logger) { this.basePath = Environment.CurrentDirectory; if (!System.IO.Directory.Exists(Path.Combine(basePath, root))) @@ -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(); @@ -662,6 +672,262 @@ public async Task GetFileStream(string idChannel, string idFile, } + /// + /// 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. + /// + /// Telegram channel ID + /// TFM database file ID + /// File name (used for mime type / Content-Disposition) + [HttpGet] + [Route("GetFileStreamCached/{idChannel}/{idFile}/{name}")] + [ProducesResponseType(typeof(FileStreamResult), 200)] + [ProducesResponseType(206)] + [ProducesResponseType(404)] + [ProducesResponseType(416)] + public async Task 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(); + } + } + /// /// Export channel database to JSON file /// diff --git a/TelegramDownloader/Controllers/Mobile/MobileStreamController.cs b/TelegramDownloader/Controllers/Mobile/MobileStreamController.cs index b0b841a..03917ff 100644 --- a/TelegramDownloader/Controllers/Mobile/MobileStreamController.cs +++ b/TelegramDownloader/Controllers/Mobile/MobileStreamController.cs @@ -4,6 +4,8 @@ using TelegramDownloader.Models; using TelegramDownloader.Models.Mobile; using TelegramDownloader.Services; +using System.Collections.Concurrent; +using System.Diagnostics; using System.Web; namespace TelegramDownloader.Controllers.Mobile @@ -26,6 +28,13 @@ public class MobileStreamController : ControllerBase private readonly ILogger _logger; private static readonly SemaphoreSlim _downloadSemaphore = new(5); // Allow 5 concurrent downloads for preload + // Limit concurrent direct range fetches against Telegram (seeks ahead of the cache) + private static readonly SemaphoreSlim _telegramRangeSemaphore = new(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 = 4 * 1024 * 1024; + public MobileStreamController( ITelegramService ts, IDbService db, @@ -238,28 +247,43 @@ public async Task StreamAudioByTfmId(string channelId, string tfm } } - // Parse range header + // Parse range header (RFC 7233: bytes=X-, bytes=X-Y, bytes=-N) var rangeHeader = Request.Headers["Range"].ToString(); + long totalLength = dbFile.Size; long from = 0; - long to = 0; + long? to = null; bool hasRange = false; - if (!string.IsNullOrEmpty(rangeHeader) && rangeHeader.StartsWith("bytes=")) + if (!string.IsNullOrEmpty(rangeHeader) && rangeHeader.StartsWith("bytes=") && !rangeHeader.Contains(',')) { - hasRange = true; - var range = rangeHeader.Replace("bytes=", "").Split('-'); - from = long.Parse(range[0]); - if (range.Length > 1 && !string.IsNullOrEmpty(range[1])) - to = long.Parse(range[1]); + 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) } - long totalLength = dbFile.Size; - - // Check how much is already cached - long cachedBytes = 0; - if (System.IO.File.Exists(filePath)) + if (hasRange && (from >= totalLength || (to.HasValue && to.Value < from))) { - cachedBytes = new FileInfo(filePath).Length; + Response.Headers["Content-Range"] = $"bytes */{totalLength}"; + return StatusCode(StatusCodes.Status416RangeNotSatisfiable); } // For initial request without Range, return first chunk as 206 with full size info @@ -270,23 +294,59 @@ public async Task StreamAudioByTfmId(string channelId, string tfm to = Math.Min(2 * 1024 * 1024, totalLength - 1); // First 2MB } - // Handle Range request - if (to == 0 || to >= totalLength) + // Open-ended or oversized ranges: cap the response to ~2.5MB + long rangeEnd = (!to.HasValue || to.Value >= totalLength) + ? Math.Min(from + (5 * 524288), totalLength - 1) + : to.Value; + + // Kick off (or attach to) the background download that fills the disk cache, + // so every streamed track ends up cached and is downloaded from Telegram once + ProgressiveDownloadInfo downloadInfo = null; + if (dbFile.MessageId.HasValue) + { + try + { + downloadInfo = await _progressiveDownload.StartOrGetDownloadAsync(cacheFileName, channelId, dbFile, filePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not start background cache download for {FileName}", name); + } + } + + long CachedBytes() + { + long onDisk = System.IO.File.Exists(filePath) ? new FileInfo(filePath).Length : 0; + return Math.Max(onDisk, downloadInfo?.DownloadedBytes ?? 0); + } + + // If the background download is nearby, wait briefly for it to cover the + // range start instead of opening a duplicate Telegram download. This is the + // normal sequential-playback path: same latency as a direct fetch (both pull + // sequential 512KB chunks), but the bytes get persisted. + if (downloadInfo != null && downloadInfo.IsDownloading && + from - CachedBytes() <= WAIT_PROXIMITY_BYTES) { - // Open-ended range - to = Math.Min(from + (5 * 524288), totalLength - 1); // ~2.5MB chunk + var waitTarget = Math.Min(rangeEnd, from + 524288); // at least 512KB past the start + var deadline = DateTime.UtcNow.AddSeconds(12); + while (downloadInfo.IsDownloading && + downloadInfo.DownloadedBytes <= waitTarget && + DateTime.UtcNow < deadline) + { + await Task.Delay(150, HttpContext.RequestAborted); + } } - // Check if range is available locally + var cachedBytes = CachedBytes(); + + // Serve from the (possibly still growing) cache file if (from < cachedBytes) { - // Part or all of range is in cache - var availableEnd = Math.Min(to, cachedBytes - 1); + var availableEnd = Math.Min(rangeEnd, cachedBytes - 1); var length = availableEnd - from + 1; _logger.LogDebug("Serving from cache: bytes {From}-{To} of {Total}", from, availableEnd, totalLength); - // Read from cache file using var cacheStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); cacheStream.Seek(from, SeekOrigin.Begin); var buffer = new byte[length]; @@ -303,10 +363,10 @@ public async Task StreamAudioByTfmId(string channelId, string tfm return new EmptyResult(); } - // Range not in cache - stream from Telegram - _logger.LogDebug("Streaming from Telegram: bytes {From}-{To} of {Total}", from, to, totalLength); + // 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("Streaming from Telegram: bytes {From}-{To} of {Total}", from, rangeEnd, totalLength); - // Get message from Telegram if (!dbFile.MessageId.HasValue) { return NotFound(ApiResponse.Fail("File has no MessageId")); @@ -318,34 +378,52 @@ public async Task StreamAudioByTfmId(string channelId, string tfm return NotFound(ApiResponse.Fail("Message not found in Telegram")); } - // Align to 512KB boundaries for Telegram + // Align down to 512KB for Telegram; skip the prefix when writing the response var alignedFrom = (from / 524288) * 524288; - var alignedTo = ((to + 524288) / 524288) * 524288; - if (alignedTo > totalLength) alignedTo = totalLength; + var skipBytes = from - 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(name)}\""; - var downloadLength = alignedTo - alignedFrom; + long remainingSkip = skipBytes; + long remainingWrite = responseLength; - // Download the range from Telegram - byte[] data = await _ts.DownloadFileStream(message, alignedFrom, (int)downloadLength); + await foreach (var chunk in _ts.DownloadFileStreamChunks( + message, alignedFrom, skipBytes + responseLength, HttpContext.RequestAborted)) + { + int start = 0; + int length = chunk.Length; - // Calculate skip bytes to get to the exact requested position - var skipBytes = from - alignedFrom; - var responseLength = Math.Min(to - from + 1, data.Length - skipBytes); + if (remainingSkip > 0) + { + var toSkip = (int)Math.Min(remainingSkip, length); + start += toSkip; + length -= toSkip; + remainingSkip -= toSkip; + } - if (skipBytes < 0 || skipBytes >= data.Length) + 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 { - _logger.LogError("Invalid skip calculation: skipBytes={Skip}, dataLength={DataLen}", skipBytes, data.Length); - return StatusCode(500, "Error calculating response range"); + _telegramRangeSemaphore.Release(); } - Response.StatusCode = StatusCodes.Status206PartialContent; - Response.ContentType = mimeType; - Response.ContentLength = responseLength; - Response.Headers["Content-Range"] = $"bytes {from}-{from + responseLength - 1}/{totalLength}"; - Response.Headers["Accept-Ranges"] = "bytes"; - Response.Headers["Content-Disposition"] = $"inline; filename=\"{HttpUtility.UrlEncode(name)}\""; - - await Response.Body.WriteAsync(data, (int)skipBytes, (int)responseLength); return new EmptyResult(); } catch (OperationCanceledException) @@ -781,5 +859,259 @@ private static string GetMimeType(string extension) _ => "application/octet-stream" }; } + + // ============ Audio transcoding (offline downloads in MP3/AAC) ============ + + private static bool? _ffmpegAvailable; + private static readonly SemaphoreSlim _transcodeSemaphore = new(2); // Max 2 concurrent FFmpeg processes + private static readonly ConcurrentDictionary _transcodeLocks = new(); + + private static bool IsFFmpegAvailable() + { + if (_ffmpegAvailable.HasValue) return _ffmpegAvailable.Value; + try + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "ffmpeg", + Arguments = "-version", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + process.Start(); + process.WaitForExit(5000); + _ffmpegAvailable = process.ExitCode == 0; + } + catch + { + _ffmpegAvailable = false; + } + return _ffmpegAvailable.Value; + } + + /// + /// Capacidades de transcodificación del servidor + /// + /// + /// Permite al cliente comprobar si el servidor tiene FFmpeg disponible antes de + /// ofrecer descargas transcodificadas. Si no lo está, el cliente debe avisar al + /// usuario y descargar en formato original. + /// + [HttpGet("transcode/info")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult GetTranscodeInfo() + { + var available = IsFFmpegAvailable(); + return Ok(ApiResponse.Ok(new + { + ffmpegAvailable = available, + formats = available ? new[] { "mp3", "aac" } : Array.Empty() + })); + } + + /// + /// Descargar audio transcodificado a MP3 o AAC (para descargas offline) + /// + /// + /// Transcodifica el archivo original (p.ej. FLAC) al formato y bitrate indicados + /// usando FFmpeg y lo sirve con soporte Range. El resultado se cachea en disco, + /// por lo que peticiones posteriores son inmediatas. + /// + /// Requiere FFmpeg instalado en el servidor: si no está disponible responde + /// **501 Not Implemented** y el cliente debe descargar el original. + /// + /// La primera petición puede tardar: descarga el original de Telegram (si no está + /// cacheado) y ejecuta la transcodificación antes de responder. + /// + /// ID del canal de Telegram + /// ID del archivo en la base de datos TFM + /// Formato destino: mp3 | aac + /// Bitrate en kbps (64-320) + /// Nombre del archivo original (opcional) + [HttpGet("tfm/{channelId}/{tfmId}/transcoded")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status206PartialContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status501NotImplemented)] + public async Task StreamTranscodedAudio( + string channelId, + string tfmId, + [FromQuery] string format = "mp3", + [FromQuery] int bitrate = 192, + [FromQuery] string? fileName = null) + { + try + { + format = format.ToLowerInvariant(); + if (format != "mp3" && format != "aac") + { + return BadRequest(ApiResponse.Fail("Unsupported format. Use mp3 or aac.")); + } + bitrate = Math.Clamp(bitrate, 64, 320); + + if (!IsFFmpegAvailable()) + { + return StatusCode(StatusCodes.Status501NotImplemented, + ApiResponse.Fail("FFmpeg is not available on the server")); + } + + var dbFile = await _fs.getItemById(channelId, tfmId); + if (dbFile == null) + { + return NotFound(ApiResponse.Fail("File not found in database")); + } + + var name = fileName ?? dbFile.Name; + var cacheFileName = $"{channelId}-{(dbFile.MessageId != null ? dbFile.MessageId.ToString() : dbFile.Id)}-{name}"; + var tempPath = Path.Combine(FileService.TEMPDIR, "_temp"); + var sourcePath = Path.Combine(tempPath, cacheFileName); + var transcodedDir = Path.Combine(tempPath, "transcoded"); + Directory.CreateDirectory(transcodedDir); + + var targetExt = format == "mp3" ? "mp3" : "m4a"; + var mimeType = format == "mp3" ? "audio/mpeg" : "audio/mp4"; + var targetName = $"{Path.GetFileNameWithoutExtension(cacheFileName)}-{format}{bitrate}.{targetExt}"; + var targetPath = Path.Combine(transcodedDir, targetName); + var downloadName = $"{Path.GetFileNameWithoutExtension(name)}.{targetExt}"; + + // Cached transcode: serve immediately with Range support + if (System.IO.File.Exists(targetPath)) + { + return PhysicalFile(targetPath, mimeType, downloadName, enableRangeProcessing: true); + } + + // Per-target lock so concurrent requests don't transcode twice + var fileLock = _transcodeLocks.GetOrAdd(targetName, _ => new SemaphoreSlim(1, 1)); + await fileLock.WaitAsync(HttpContext.RequestAborted); + try + { + if (!System.IO.File.Exists(targetPath)) + { + // Ensure the original is fully cached first + if (!System.IO.File.Exists(sourcePath) || new FileInfo(sourcePath).Length < dbFile.Size) + { + _logger.LogInformation("Downloading original before transcode: {FileName}", name); + await DownloadOriginalToCache(channelId, dbFile, sourcePath); + } + + await _transcodeSemaphore.WaitAsync(HttpContext.RequestAborted); + try + { + _logger.LogInformation("Transcoding {FileName} to {Format} {Bitrate}k", name, format, bitrate); + await TranscodeAudioFile(sourcePath, targetPath, format, bitrate, HttpContext.RequestAborted); + } + finally + { + _transcodeSemaphore.Release(); + } + } + } + finally + { + fileLock.Release(); + } + + return PhysicalFile(targetPath, mimeType, downloadName, enableRangeProcessing: true); + } + catch (OperationCanceledException) + { + _logger.LogDebug("Transcoded download cancelled for {TfmId}", tfmId); + return new EmptyResult(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error transcoding {TfmId} from channel {ChannelId}", tfmId, channelId); + return StatusCode(500, ApiResponse.Fail("Error transcoding audio")); + } + } + + // Sequential full download of the original file into the streaming cache + private async Task DownloadOriginalToCache(string channelId, BsonFileManagerModel dbFile, string path) + { + if (!dbFile.MessageId.HasValue) + { + throw new InvalidOperationException("File has no MessageId"); + } + + var message = await _ts.getMessageFile(channelId, dbFile.MessageId.Value); + if (message == null) + { + throw new InvalidOperationException("Message not found in Telegram"); + } + + using var fileStream = new FileStream(path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read); + var offset = fileStream.Length; + fileStream.Seek(0, SeekOrigin.End); + + const int chunkSize = 512 * 1024; + while (offset < dbFile.Size) + { + HttpContext.RequestAborted.ThrowIfCancellationRequested(); + var toDownload = (int)Math.Min(chunkSize, dbFile.Size - offset); + var chunk = await _ts.DownloadFileStream(message, offset, toDownload); + if (chunk.Length == 0) break; + await fileStream.WriteAsync(chunk, 0, chunk.Length, HttpContext.RequestAborted); + offset += chunk.Length; + } + await fileStream.FlushAsync(); + } + + // Run FFmpeg to a temp file, then move into place so partial results + // are never served + private async Task TranscodeAudioFile(string sourcePath, string targetPath, string format, int bitrate, CancellationToken ct) + { + var tempOut = targetPath + ".part"; + + // mp3: keep embedded cover art (optional video stream copied as attached pic) + // aac/m4a: audio only (cover copying into ipod container is less reliable) + var codecArgs = format == "mp3" + ? $"-map 0:a:0 -map 0:v? -c:v copy -disposition:v:0 attached_pic -codec:a libmp3lame -b:a {bitrate}k -id3v2_version 3 -f mp3" + : $"-vn -codec:a aac -b:a {bitrate}k -movflags +faststart -f ipod"; + + var args = $"-y -i \"{sourcePath}\" -map_metadata 0 {codecArgs} \"{tempOut}\""; + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "ffmpeg", + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + var stderrTask = process.StandardError.ReadToEndAsync(); + _ = process.StandardOutput.ReadToEndAsync(); + + try + { + await process.WaitForExitAsync(ct); + } + catch (OperationCanceledException) + { + try { process.Kill(entireProcessTree: true); } catch { /* already exited */ } + try { System.IO.File.Delete(tempOut); } catch { /* best effort */ } + throw; + } + + if (process.ExitCode != 0) + { + var stderr = await stderrTask; + try { System.IO.File.Delete(tempOut); } catch { /* best effort */ } + throw new InvalidOperationException( + $"FFmpeg exited with code {process.ExitCode}: {stderr.Substring(0, Math.Min(500, stderr.Length))}"); + } + + System.IO.File.Move(tempOut, targetPath, overwrite: true); + } } } diff --git a/TelegramDownloader/Data/FileService.cs b/TelegramDownloader/Data/FileService.cs index f33befd..28695d7 100644 --- a/TelegramDownloader/Data/FileService.cs +++ b/TelegramDownloader/Data/FileService.cs @@ -1073,12 +1073,18 @@ public async Task importSharedData(ShareFilesModel sfm, GenericNotificationProgr public async Task> createFolder(string dbName, FolderCreateEventArgs args) { - return (await _db.createEntry(dbName, await _db.toBasonFile(args.Path, args.FolderName, args.ParentFolder))).Select(x => x.toFileManagerContent()).ToList(); + var result = (await _db.createEntry(dbName, await _db.toBasonFile(args.Path, args.FolderName, args.ParentFolder))).Select(x => x.toFileManagerContent()).ToList(); + if (!string.IsNullOrEmpty(args.ParentFolder?.Id)) + await _db.setDirectoryHasChild(dbName, args.ParentFolder.Id); + return result; } public async Task> createFolder(string dbName, string path, string folderName, Syncfusion.Blazor.FileManager.FileManagerDirectoryContent? parentFolder) { - return (await _db.createEntry(dbName, await _db.toBasonFile(path, folderName, parentFolder))).Select(x => x.toFileManagerContent()).ToList(); + var result = (await _db.createEntry(dbName, await _db.toBasonFile(path, folderName, parentFolder))).Select(x => x.toFileManagerContent()).ToList(); + if (!string.IsNullOrEmpty(parentFolder?.Id)) + await _db.setDirectoryHasChild(dbName, parentFolder.Id); + return result; } public async Task CreateDatabase(string id) @@ -1246,6 +1252,21 @@ public async Task AddUploadFileFromServer(string dbName, string currentPath, Lis _tis.CheckPendingUploadInfoTasks(); } + /// + /// Builds the URL written inside a .strm file according to the configured streaming mode. + /// Files smaller than MaxPreloadFileSizeInMb are always fully preloaded. + /// + private static string BuildStrmUrl(string host, string dbName, BsonFileManagerModel file) + { + StreamingMode mode = GeneralConfigStatic.config.GetEffectiveStreamingMode(); + if (mode == StreamingMode.Preload || HelperService.bytesToMegaBytes(file.Size) < GeneralConfigStatic.config.MaxPreloadFileSizeInMb) + { + return Path.Combine(host, "api/file/GetFileByTfmId", Uri.EscapeDataString(file.Name)).Replace("\\", "/") + $"?idChannel={dbName}&idFile={file.Id}"; + } + string endpoint = mode == StreamingMode.ProgressiveCache ? "GetFileStreamCached" : "GetFileStream"; + return Path.Combine(host, "api/file", endpoint, dbName, file.Id, "file" + file.Type).Replace("\\", "/"); + } + public async Task CreateStrmFiles(string path, string dbName, string host) { String folderPathName = Path.GetFileName(path.TrimEnd('/')); @@ -1294,11 +1315,7 @@ public async Task CreateStrmFiles(string path, string dbName, string hos { if (FileExtensionTypeTest.isVideoExtension(file.Type) || FileExtensionTypeTest.isAudioExtension(file.Type)) { - string contenido = Path.Combine(host, "api/file/GetFileStream", dbName, file.Id, "file" + file.Type).Replace("\\", "/"); - if (GeneralConfigStatic.config.PreloadFilesOnStream || HelperService.bytesToMegaBytes(file.Size) < GeneralConfigStatic.config.MaxPreloadFileSizeInMb) - { - contenido = Path.Combine(host, "api/file/GetFileByTfmId", Uri.EscapeDataString(file.Name)).Replace("\\", "/") + $"?idChannel={dbName}&idFile={file.Id}"; - } + string contenido = BuildStrmUrl(host, dbName, file); string pattern = $@"\.({file.Type.Replace(".", "")})$"; File.WriteAllText(Regex.Replace(filePath, pattern, ".strm"), contenido); } @@ -1332,11 +1349,7 @@ public async Task CreateStrmFilesToLocal(string path, string dbName, string host { if (FileExtensionTypeTest.isVideoExtension(file.Type) || FileExtensionTypeTest.isAudioExtension(file.Type)) { - string contenido = Path.Combine(host, "api/file/GetFileStream", dbName, file.Id, "file" + file.Type).Replace("\\", "/"); - if (GeneralConfigStatic.config.PreloadFilesOnStream || HelperService.bytesToMegaBytes(file.Size) < GeneralConfigStatic.config.MaxPreloadFileSizeInMb) - { - contenido = Path.Combine(host, "api/file/GetFileByTfmId", Uri.EscapeDataString(file.Name)).Replace("\\", "/") + $"?idChannel={dbName}&idFile={file.Id}"; - } + string contenido = BuildStrmUrl(host, dbName, file); string pattern = $@"\.({file.Type.Replace(".", "")})$"; // Ensure parent directory exists var parentDir = Path.GetDirectoryName(Regex.Replace(filePath, pattern, ".strm")); @@ -1608,12 +1621,18 @@ public async Task UploadFileFromServer(string dbName, string currentPath, List getInvitationHash(long id); Task joinChatInvitationHash(string? hash); Task CallQrGenerator(Action func, CancellationToken ct, bool logoutFirst = false); + void ProvideQrLoginPassword(string password); Task DownloadFile(ChatMessages message, string fileName = null, string folder = null, DownloadModel model = null, bool shouldAddToList = false); Task DownloadFileStream(Message message, long offset, int limit); + IAsyncEnumerable DownloadFileStreamChunks(Message message, long offset, long limit, CancellationToken ct = default); Task DownloadFileAndReturn(ChatMessages message, Stream ms = null, string fileName = null, string folder = null, DownloadModel model = null); Task DownloadFileAndReturnWithOffset(ChatMessages message, Stream ms = null, string fileName = null, string folder = null, DownloadModel model = null, long offset = 0); Task> GetFouriteChannels(bool mustRefresh = true); diff --git a/TelegramDownloader/Data/TelegramService.cs b/TelegramDownloader/Data/TelegramService.cs index 186bc62..d115e7e 100644 --- a/TelegramDownloader/Data/TelegramService.cs +++ b/TelegramDownloader/Data/TelegramService.cs @@ -8,6 +8,7 @@ using Syncfusion.Blazor.Sparkline.Internal; using System.Collections.Generic; using System.Reflection.Metadata; +using System.Runtime.CompilerServices; using System.Threading.Channels; using System.Threading.Tasks; using TelegramDownloader.Data.db; @@ -159,7 +160,34 @@ private void createDownloadFolder() private void newClient() { - client = new WTelegram.Client(Convert.ToInt32(GeneralConfigStatic.tlconfig?.api_id ?? Environment.GetEnvironmentVariable("api_id")), GeneralConfigStatic.tlconfig?.hash_id ?? Environment.GetEnvironmentVariable("hash_id"), UserService.USERDATAFOLDER + "/WTelegram.session"); + // Snapshot the session file BEFORE the client opens (and locks) it, + // so the multi-connection download pool can clone the session even + // while the live file is held exclusively by this client. + try + { + string mainSession = UserService.USERDATAFOLDER + "/WTelegram.session"; + if (File.Exists(mainSession)) + File.Copy(mainSession, mainSession + ".snapshot", overwrite: true); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not snapshot the session file"); + } + string apiId = GeneralConfigStatic.tlconfig?.api_id ?? Environment.GetEnvironmentVariable("api_id"); + string apiHash = GeneralConfigStatic.tlconfig?.hash_id ?? Environment.GetEnvironmentVariable("hash_id"); + // Custom config callback instead of the convenience constructor: the + // QR login flow (LoginWithQRCode) asks for the 2FA password through + // Config("password") - with the default config that prompt would go + // to the console. Route it to the web UI instead. + client = new WTelegram.Client(what => what switch + { + "api_id" => apiId, + "api_hash" => apiHash, + "session_pathname" => UserService.USERDATAFOLDER + "/WTelegram.session", + "password" => RequestLoginPassword(), + _ => null + }); + ApplyConfiguredParallelTransfers(client); if (GeneralConfigStatic.config.ShouldShowLogInTerminal) { // WTelegram.Helpers.Log = (lvl, str) => Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [{"TDIWE!"[lvl]}] {str}"); @@ -170,9 +198,441 @@ private void newClient() } + // WTelegramClient requests file parts through a per-client semaphore that + // defaults to 2 parts in flight, capping transfers at ~1MB per round-trip. + // Tracks the value applied to each client instance (main client and the + // media-DC clients WTelegram creates internally, which do NOT inherit the + // main client's ParallelTransfers). + private static readonly ConditionalWeakTable> appliedParallelTransfers = new(); + private const int WTELEGRAM_DEFAULT_PARALLEL_TRANSFERS = 2; + + public static int GetConfiguredParallelTransfers() + { + return Math.Clamp(GeneralConfigStatic.config?.ParallelTransfers ?? 4, 1, 16); + } + + private static void ApplyConfiguredParallelTransfers(WTelegram.Client c) + { + if (c == null) + return; + int desired = GetConfiguredParallelTransfers(); + int delta; + lock (appliedParallelTransfers) + { + StrongBox applied = appliedParallelTransfers.GetOrCreateValue(c); + if (applied.Value == 0) + applied.Value = WTELEGRAM_DEFAULT_PARALLEL_TRANSFERS; + delta = desired - applied.Value; + if (delta == 0) + return; + applied.Value = desired; + } + try + { + // The ParallelTransfers setter adjusts the semaphore relative to its + // CURRENT count, which is lower while parts are in flight. Applying + // our delta on top of the current value keeps the configured maximum + // correct even if a transfer is running on this client. + c.ParallelTransfers = c.ParallelTransfers + delta; + } + catch (Exception) + { + // Never let a tuning failure break a transfer. + } + } + + /// + /// Resolves the client instance that WTelegram's DownloadFileAsync will use + /// for the given file DC (dc_id == 0 means the main client) and applies the + /// configured chunk parallelism to it before the transfer starts. + /// + private async Task PrepareTransferClientAsync(int dcId) + { + try + { + WTelegram.Client c = dcId == 0 ? client : await client.GetClientForDC(-dcId, true); + ApplyConfiguredParallelTransfers(c); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not prepare transfer client for DC {DcId}", dcId); + } + } + + #region Multi-connection downloads + + // Telegram enforces its throughput limit PER CONNECTION (~5-6 MB/s), so a + // single MTProto connection cannot go faster regardless of how many chunks + // are pipelined. Official clients reach high speeds by opening several + // sessions to the file's DC and splitting the file between them. + // + // This pool holds CLONES of the main session: each pool client loads a + // copy of the main session file, sharing its fully-authorized auth key, + // while WTelegram assigns a fresh MTProto session id per client instance + // (the id is transient, never persisted). The server sees them as extra + // connections of the existing authorization - the exact model official + // clients use for parallel downloads - so no new authorization is + // created, nothing shows up in the account's device list, and the + // auth.exportAuthorization/importAuthorization route (whose imported + // sessions turned out to be limited: file requests work but probe RPCs + // and re-exports answer AUTH_KEY_UNREGISTERED) is not needed at all. + private class DownloadPool + { + public readonly SemaphoreSlim initLock = new SemaphoreSlim(1, 1); + public readonly List clients = new List(); + public bool bootstrapFailed = false; + } + + private static readonly DownloadPool downloadPool = new DownloadPool(); + + public static int GetConfiguredDownloadConnections() + { + return Math.Clamp(GeneralConfigStatic.config?.DownloadConnections ?? 4, 2, 8); + } + + /// + /// Chunk size for upload.getFile: Telegram only accepts limits that are + /// divisible by 4KB and divide 1MB evenly, so the configured value is + /// snapped to 128/256/512/1024 KB. 512 is WTelegramClient's own default; + /// 1024 (app default) halves the number of round-trips. + /// + private static int GetConfiguredPartSize() + { + int kb = GeneralConfigStatic.config?.MultiConnectionPartSizeKB ?? 1024; + if (kb >= 1024) return 1024 * 1024; + if (kb >= 512) return 512 * 1024; + if (kb >= 256) return 256 * 1024; + return 128 * 1024; + } + + private static int GetConfiguredBlockSize(int partSize) + { + int mb = Math.Clamp(GeneralConfigStatic.config?.MultiConnectionBlockSizeMB ?? 4, 1, 16); + return Math.Max(mb * 1024 * 1024, partSize); + } + + private static long GetConfiguredMinFileSize() + { + return Math.Max(1, GeneralConfigStatic.config?.MultiConnectionMinFileSizeMB ?? 32) * 1024L * 1024L; + } + + private static bool ShouldUseMultiConnection(TL.Document document) + { + GeneralConfig cfg = GeneralConfigStatic.config; + return cfg != null && cfg.EnableMultiConnectionDownloads && document.size >= GetConfiguredMinFileSize(); + } + + private async Task> GetDownloadPoolAsync(int count) + { + if (downloadPool.bootstrapFailed) + return new List(); + await downloadPool.initLock.WaitAsync(); + try + { + downloadPool.clients.RemoveAll(c => + { + if (!c.Disconnected) return false; + try { c.Dispose(); } catch { } + return true; + }); + while (downloadPool.clients.Count < count) + { + WTelegram.Client pc = await CreateDownloadPoolClientAsync(downloadPool.clients.Count); + if (pc == null) + { + // Do not retry the bootstrap on every download if the + // server refuses it. + if (downloadPool.clients.Count == 0) + downloadPool.bootstrapFailed = true; + break; + } + downloadPool.clients.Add(pc); + } + return downloadPool.clients.Take(count).ToList(); + } + finally + { + downloadPool.initLock.Release(); + } + } + + private async Task CreateDownloadPoolClientAsync(int index) + { + string mainSessionPath = UserService.USERDATAFOLDER + "/WTelegram.session"; + string sessionPath = Path.Combine(UserService.USERDATAFOLDER, $"WTelegram_dl_{index}.session"); + try + { + // Clone the main session file. Always copy fresh so leftovers from + // older bootstrap strategies (or a re-login of the main account) + // are overwritten. The file is encrypted with a key derived from + // the same api_hash, so the clone can read it as its own. + CopySessionWithRetry(mainSessionPath, sessionPath); + + string apiId = GeneralConfigStatic.tlconfig?.api_id ?? Environment.GetEnvironmentVariable("api_id"); + string apiHash = GeneralConfigStatic.tlconfig?.hash_id ?? Environment.GetEnvironmentVariable("hash_id"); + WTelegram.Client pc = new WTelegram.Client(what => what switch + { + "api_id" => apiId, + "api_hash" => apiHash, + "session_pathname" => sessionPath, + _ => null + }); + try + { + await pc.ConnectAsync(); + if (pc.UserId == 0) + throw new InvalidOperationException("Cloned session has no logged-in user"); + _logger.LogInformation("Download pool client {Index} ready (cloned session, user {UserId})", index, pc.UserId); + return pc; + } + catch + { + try { pc.Dispose(); } catch { } + throw; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not create download pool client {Index}: {Error} - falling back to single-connection downloads", index, ex.Message); + return null; + } + } + + private static void CopySessionWithRetry(string source, string destination) + { + // The main client keeps its session file open (often exclusively), so + // File.Copy cannot read it. Try a share-friendly stream copy of the + // live file first, then fall back to the snapshot taken at startup + // before the client opened the file. Session state staleness is fine: + // the auth key never changes and salts are renegotiated automatically. + IOException lastError = null; + for (int attempt = 1; attempt <= 3; attempt++) + { + try + { + using (FileStream src = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + using (FileStream dst = new FileStream(destination, FileMode.Create, FileAccess.Write)) + src.CopyTo(dst); + return; + } + catch (IOException ex) + { + lastError = ex; + Thread.Sleep(150 * attempt); + } + } + string snapshot = source + ".snapshot"; + if (File.Exists(snapshot)) + { + File.Copy(snapshot, destination, overwrite: true); + return; + } + throw lastError; + } + + /// + /// Returns a client of connected to the given DC. + /// The clone's home DC is the account's home DC, so files there are served + /// by the clone's own connection; for files on other DCs, the clone is a + /// fully logged session and WTelegram's GetClientForDC handles the + /// cross-DC authorization automatically. + /// + private async Task GetAuthorizedTransferClientAsync(WTelegram.Client owner, int dcId) + { + return await owner.GetClientForDC(dcId, true); + } + + /// + /// Downloads a document by splitting it in blocks served concurrently by + /// several pool connections, writing each part at its absolute offset. + /// Returns false (leaving the destination empty) when the pool is not + /// available or the transfer failed in a recoverable way, so the caller + /// can fall back to the standard sequential download. + /// + private async Task TryMultiConnectionDownloadAsync(TL.Document document, FileStream dest, DownloadModel model) + { + long size = document.size; + List transfers = new List(); + try + { + List pool = await GetDownloadPoolAsync(GetConfiguredDownloadConnections()); + foreach (WTelegram.Client owner in pool) + { + try + { + transfers.Add(await GetAuthorizedTransferClientAsync(owner, document.dc_id)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Pool connection unavailable for DC {Dc}: {Error}", document.dc_id, ex.Message); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Download pool unavailable for DC {Dc}", document.dc_id); + return false; + } + if (transfers.Count < 2) + return false; + + var location = new InputDocumentFileLocation + { + id = document.id, + access_hash = document.access_hash, + file_reference = document.file_reference, + thumb_size = "" + }; + + // Capture the tuning values once so a config change mid-download + // cannot desynchronize offsets. + int partSize = GetConfiguredPartSize(); + int blockSize = GetConfiguredBlockSize(partSize); + + _logger.LogInformation("Multi-connection download - FileName: {Name}, Size: {SizeMB:F2}MB, Connections: {Connections}, Part: {PartKB}KB, Block: {BlockMB}MB", + model.name, size / (1024.0 * 1024.0), transfers.Count, partSize / 1024, blockSize / (1024.0 * 1024.0)); + + long blockCount = (size + blockSize - 1) / blockSize; + bool[] blockDone = new bool[blockCount]; + long confirmedBlocks = 0; + long nextBlock = -1; + object progressLock = new object(); + using CancellationTokenSource cts = new CancellationTokenSource(); + dest.SetLength(size); + var handle = dest.SafeFileHandle; + DateTime started = DateTime.Now; + + void ReportBytes(int received) + { + long confirmed; + lock (progressLock) + confirmed = Math.Min(size, confirmedBlocks * (long)blockSize); + // Throws when the task gets canceled or paused, stopping the workers. + model.ReportParallelProgress(confirmed, received, size); + } + + void ReportBlockDone(long block) + { + long confirmed; + lock (progressLock) + { + blockDone[block] = true; + while (confirmedBlocks < blockCount && blockDone[confirmedBlocks]) + confirmedBlocks++; + confirmed = Math.Min(size, confirmedBlocks * (long)blockSize); + } + model.ReportParallelProgress(confirmed, 0, size); + } + + async Task DownloadPart(WTelegram.Client pc, long offset) + { + int expected = (int)Math.Min(partSize, size - offset); + for (int attempt = 1; ; attempt++) + { + cts.Token.ThrowIfCancellationRequested(); + try + { + Upload_FileBase resp = await pc.Upload_GetFile(location, offset, limit: partSize); + if (resp is not Upload_File part) + throw new InvalidOperationException($"Unexpected {resp?.GetType().Name} from Upload_GetFile (CDN-served files are not supported)"); + if (part.bytes.Length < expected) + throw new InvalidOperationException($"Short chunk at offset {offset}: {part.bytes.Length} < {expected}"); + RandomAccess.Write(handle, part.bytes.AsSpan(0, expected), offset); + ReportBytes(expected); + return; + } + catch (Exception) when (attempt < 3 && !cts.IsCancellationRequested) + { + await Task.Delay(1000 * attempt); + } + } + } + + async Task Worker(WTelegram.Client pc) + { + while (!cts.IsCancellationRequested) + { + long block = Interlocked.Increment(ref nextBlock); + if (block >= blockCount) + return; + long blockStart = block * (long)blockSize; + long blockEnd = Math.Min(size, blockStart + blockSize); + // Request every part of the block concurrently on this + // connection: sequential parts pay a full round-trip of dead + // time each, capping a connection well below its server-side + // allowance. Pipelining keeps the connection's pipe full. + List parts = new List(); + for (long offset = blockStart; offset < blockEnd; offset += partSize) + parts.Add(DownloadPart(pc, offset)); + await Task.WhenAll(parts); + ReportBlockDone(block); + } + } + + async Task GuardedWorker(WTelegram.Client pc) + { + try { await Worker(pc); } + catch { cts.Cancel(); throw; } + } + + try + { + await Task.WhenAll(transfers.Select(pc => Task.Run(() => GuardedWorker(pc)))); + await dest.FlushAsync(); + double seconds = Math.Max(0.001, (DateTime.Now - started).TotalSeconds); + _logger.LogInformation("Multi-connection download completed - FileName: {Name}, {SizeMB:F1}MB in {Seconds:F1}s = {Speed:F1} MB/s over {Connections} connections", + model.name, size / (1024.0 * 1024.0), seconds, size / (1024.0 * 1024.0) / seconds, transfers.Count); + return true; + } + catch (Exception ex) + { + if (model.state == StateTask.Canceled || model.state == StateTask.Paused) + throw; + _logger.LogWarning(ex, "Multi-connection download failed, falling back to sequential - FileName: {Name}", model.name); + dest.SetLength(0); + dest.Position = 0; + model._transmitted = 0; + return false; + } + } + + #endregion + + // 2FA password bridge for the QR login flow: WTelegram asks for the + // password via Config("password") on a background task; the UI is + // notified through QrPasswordNeeded and supplies the value with + // ProvideQrLoginPassword, unblocking the pending request. + private static TaskCompletionSource qrPasswordRequest; + public static event EventHandler QrPasswordNeeded; + + private static string RequestLoginPassword() + { + TaskCompletionSource tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + qrPasswordRequest = tcs; + try + { + QrPasswordNeeded?.Invoke(null, EventArgs.Empty); + } + catch (Exception) + { + } + // Blocks the QR login background task, never the UI thread. + if (!tcs.Task.Wait(TimeSpan.FromMinutes(5))) + throw new TimeoutException("2FA password was not provided in time"); + return tcs.Task.Result; + } + + public void ProvideQrLoginPassword(string password) + { + qrPasswordRequest?.TrySetResult(password); + } + public async Task CallQrGenerator(Action func, CancellationToken ct, bool logoutFirst = false) { - return await client.LoginWithQRCode(func, logoutFirst: logoutFirst, ct: ct); + User user = await client.LoginWithQRCode(func, logoutFirst: logoutFirst, ct: ct); + if (user != null) + await CompleteLogin(); + return user; } public async Task GetUser() @@ -217,6 +677,16 @@ async Task DoLogin(string loginInfo) // (add this method to your code) return "pass"; // if user has enabled 2FA default: break; } + return await CompleteLogin(); + } + + /// + /// Post-login initialization shared by the interactive login flow and the + /// QR login flow: loads chats, resolves premium limits and notifies + /// subscribers. + /// + private async Task CompleteLogin() + { await getAllChats(); SetSplitSizeGB(); if (client.User.flags.HasFlag(User.Flags.premium)) @@ -281,8 +751,20 @@ public async Task checkAuth(string number, bool isPhone = false) } else { - _logger.LogInformation("No saved user data found, requesting phone"); - return "phone"; + // No saved phone (e.g. the session was created via QR login): + // try to restore the session directly - Login(null) completes + // silently when the stored session is still authorized and + // returns the "phone" step otherwise. + try + { + _logger.LogInformation("No saved user data found, attempting session restore"); + return await DoLogin(null) ?? "phone"; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Session restore without saved user data failed, requesting phone"); + return "phone"; + } } } @@ -700,6 +1182,7 @@ public async Task uploadFile(string chatId, Stream file, string fileNam try { + ApplyConfiguredParallelTransfers(client); var inputFile = await client.UploadFileAsync(file, fileName, um.ProgressCallback); var result = await client.SendMediaAsync(peer, caption ?? fileName, inputFile, mimeType); _logger.LogInformation("File upload completed - FileName: {FileName}, MessageId: {MessageId}", fileName, result.id); @@ -1126,6 +1609,62 @@ public async Task DownloadFileStream(Message message, long offset, int l throw new ArgumentException("Invalid message or media type."); } + /// + /// Streams a byte range from Telegram in 512KB chunks, yielding each chunk as it + /// arrives instead of buffering the whole range in memory. Offset must be 4KB-aligned + /// (callers align to 512KB). Stops early if Telegram signals end of file. + /// + public async IAsyncEnumerable DownloadFileStreamChunks(Message message, long offset, long limit, [EnumeratorCancellation] CancellationToken ct = default) + { + _logger.LogDebug("DownloadFileStreamChunks - Offset: {Offset}, Limit: {Limit}", offset, limit); + + if (message is not Message msg || msg.media is not MessageMediaDocument doc) + throw new ArgumentException("Invalid message or media type."); + + InputDocument inputFile = doc.document; + long currentOffset = offset; + long remaining = limit; + + while (remaining > 0) + { + ct.ThrowIfCancellationRequested(); + + var location = new InputDocumentFileLocation + { + id = inputFile.id, + access_hash = inputFile.access_hash, + file_reference = inputFile.file_reference, + thumb_size = "" + }; + + Upload_FileBase file; + try + { + file = await client.Upload_GetFile(location, currentOffset, limit: FILESPLITSIZE); + } + catch (RpcException ex) when (ex.Code == 303 && ex.Message == "FILE_MIGRATE_X") + { + var dcClient = await client.GetClientForDC(-ex.X, true); + file = await dcClient.Upload_GetFile(location, currentOffset, limit: FILESPLITSIZE); + } + + if (file is not Upload_File uploadFile) + throw new InvalidOperationException("Unexpected file type returned."); + + if (uploadFile.bytes.Length == 0) + yield break; + + yield return uploadFile.bytes; + + currentOffset += uploadFile.bytes.Length; + remaining -= uploadFile.bytes.Length; + + // Telegram returns fewer bytes than requested only at end of file + if (uploadFile.bytes.Length < FILESPLITSIZE) + yield break; + } + } + public async Task DownloadFileAndReturn(ChatMessages message, Stream ms = null, string fileName = null, string folder = null, DownloadModel model = null) { if (model == null) @@ -1148,7 +1687,16 @@ public async Task DownloadFileAndReturn(ChatMessages message, Stream ms model.name = filename; _logger.LogInformation("Starting document download - FileName: {FileName}, Size: {SizeMB:F2}MB", filename, document.size / (1024.0 * 1024.0)); MemoryStream dest = new MemoryStream(); - await client.DownloadFileAsync(document, ms ?? dest, (PhotoSizeBase)null, model.ProgressCallback); + // File-manager download tasks land here with a FileStream target, + // which supports positional writes for multi-connection mode. + bool multiConnDone = false; + if (ms is FileStream fileDest && ShouldUseMultiConnection(document)) + multiConnDone = await TryMultiConnectionDownloadAsync(document, fileDest, model); + if (!multiConnDone) + { + await PrepareTransferClientAsync(document.dc_id); + await client.DownloadFileAsync(document, ms ?? dest, (PhotoSizeBase)null, model.ProgressCallback); + } _logger.LogInformation("Document download completed - FileName: {FileName}", filename); return ms ?? dest; } @@ -1196,7 +1744,14 @@ public async Task DownloadFileAndReturnWithOffset(ChatMessages message, if (offset == 0) { MemoryStream dest = new MemoryStream(); - await client.DownloadFileAsync(document, ms ?? dest, (PhotoSizeBase)null, model.ProgressCallback); + bool multiConnDone = false; + if (ms is FileStream fileDest && ShouldUseMultiConnection(document)) + multiConnDone = await TryMultiConnectionDownloadAsync(document, fileDest, model); + if (!multiConnDone) + { + await PrepareTransferClientAsync(document.dc_id); + await client.DownloadFileAsync(document, ms ?? dest, (PhotoSizeBase)null, model.ProgressCallback); + } _logger.LogInformation("Document download completed - FileName: {FileName}", filename); return ms ?? dest; } @@ -1308,7 +1863,14 @@ public async Task DownloadFile(ChatMessages message, string fileName = n _tis.addToDownloadList(model); _logger.LogInformation("Starting file download to disk - FileName: {FileName}, Size: {SizeMB:F2}MB", filename, document.size / (1024.0 * 1024.0)); using var dest = new FileStream($"{Path.Combine(folder != null ? folder : Path.Combine(Environment.CurrentDirectory, "local", "temp"), filename)}", FileMode.Create, FileAccess.Write); - await client.DownloadFileAsync(document, dest, (PhotoSizeBase)null, model.ProgressCallback); + bool multiConnDone = false; + if (ShouldUseMultiConnection(document)) + multiConnDone = await TryMultiConnectionDownloadAsync(document, dest, model); + if (!multiConnDone) + { + await PrepareTransferClientAsync(document.dc_id); + await client.DownloadFileAsync(document, dest, (PhotoSizeBase)null, model.ProgressCallback); + } _logger.LogInformation("File download to disk completed - FileName: {FileName}", filename); } else if (message.message.media is MessageMediaPhoto { photo: Photo photo }) diff --git a/TelegramDownloader/Models/DownloadModel.cs b/TelegramDownloader/Models/DownloadModel.cs index 5a3d848..36cc668 100644 --- a/TelegramDownloader/Models/DownloadModel.cs +++ b/TelegramDownloader/Models/DownloadModel.cs @@ -214,6 +214,46 @@ public void ProgressCallback(long transmitted, long totalSize) mutex.ReleaseMutex(); } + /// + /// Progress reporting for multi-connection downloads, where parts arrive + /// out of order. is the contiguous + /// number of bytes completed from the start of the file (the only safe + /// resume offset for persistence), while is + /// the size of the part just received, used for live speed accounting. + /// Throws like ProgressCallback when the task is canceled or paused. + /// + public void ReportParallelProgress(long confirmedPrefix, int chunkBytes, long totalSize) + { + if (state == StateTask.Canceled) + throw new Exception($"Canceled {name}"); + if (state == StateTask.Paused) + { + state = StateTask.Working; + tis.deleteDownloadInList(this); + throw new Exception($"Paused {name}"); + } + tis.addDownloadBytes(chunkBytes); + mutex.WaitOne(); + _transmitted = confirmedPrefix; + _sizeString = HelperService.SizeSuffix(totalSize); + _transmittedString = HelperService.SizeSuffix(confirmedPrefix); + progress = Convert.ToInt32(confirmedPrefix * 100 / totalSize); + EventChanged?.Invoke(this, new DownloadEventArgs()); + + OnProgressPersist?.Invoke(_transmitted, progress, state); + + if (confirmedPrefix == totalSize) + { + endnDate = DateTime.Now; + state = StateTask.Completed; + EventStatechanged?.Invoke(this, EventArgs.Empty); + NotificationModel nm = new NotificationModel(); + nm.sendEvent(new Notification($"Download {name} completed", "Download Completed", NotificationTypes.Success)); + tis.CheckPendingDownloads(); + } + mutex.ReleaseMutex(); + } + public void Cancel() { mutex.WaitOne(); diff --git a/TelegramDownloader/Models/GeneralConfig.cs b/TelegramDownloader/Models/GeneralConfig.cs index 4d1a715..9cf4911 100644 --- a/TelegramDownloader/Models/GeneralConfig.cs +++ b/TelegramDownloader/Models/GeneralConfig.cs @@ -12,6 +12,27 @@ namespace TelegramDownloader.Models { + /// + /// How exported STRM files (Emby/Kodi/etc.) play media from Telegram. + /// + public enum StreamingMode + { + /// + /// Stream chunks directly from Telegram on demand. Playback starts immediately, + /// nothing is stored on disk, but every play re-downloads from Telegram. + /// + DirectStream = 0, + /// + /// Stream immediately while a background download fills the local cache. + /// Playback starts from second one and subsequent plays are served from disk. + /// + ProgressiveCache = 1, + /// + /// Download the whole file to the local cache before playback starts (legacy behavior). + /// + Preload = 2 + } + public class GeneralConfigStatic { public static GeneralConfig config { get; set; } = new GeneralConfig(); @@ -31,6 +52,10 @@ public static async Task SaveChanges(IDbService db, GeneralConfig gc) if (gc.MemorySplitSizeGB < 1) gc.MemorySplitSizeGB = 1; if (gc.MemorySplitSizeGB > maxAllowedSize) gc.MemorySplitSizeGB = maxAllowedSize; + // Keep the legacy flag in sync so older builds reading this config behave the same + if (gc.StrmStreamingMode.HasValue) + gc.PreloadFilesOnStream = gc.StrmStreamingMode.Value == StreamingMode.Preload; + await db.SaveConfig(gc); config = gc; if (gc.SplitSize > 0) @@ -97,6 +122,19 @@ public class GeneralConfig public bool ShouldShowCaptionPath { get; set; } = false; public bool ShouldShowLogInTerminal { get; set; } = false; public bool PreloadFilesOnStream { get; set; } = false; + /// + /// Streaming mode used by exported STRM files. Null means "not set yet": + /// the effective mode is then derived from the legacy PreloadFilesOnStream flag. + /// + [BsonRepresentation(BsonType.String)] + public StreamingMode? StrmStreamingMode { get; set; } = null; + + public StreamingMode GetEffectiveStreamingMode() + { + if (StrmStreamingMode.HasValue) + return StrmStreamingMode.Value; + return PreloadFilesOnStream ? StreamingMode.Preload : StreamingMode.DirectStream; + } public bool ShouldShowPaginatedFileChannel { get; set; } = false; public bool hasFileManagerVirtualScroll { get; set; } = false; public bool UseMobileFileManagerAlways { get; set; } = false; @@ -140,6 +178,62 @@ public class GeneralConfig /// public int MemorySplitSizeGB { get; set; } = 2; + // Transfer Speed Settings + /// + /// Number of 512KB file chunks requested in parallel per transfer (1-16). + /// WTelegramClient's default of 2 caps throughput at roughly 1MB per + /// round-trip to Telegram's data center (~5-7 MB/s on typical latency). + /// Higher values remove that latency bottleneck; the server-side speed + /// limit for non-Premium accounts still applies. Takes effect on the + /// next transfer, no restart needed. + /// + public int ParallelTransfers { get; set; } = 4; + + // Multi-connection download settings + /// + /// EXPERIMENTAL: download large files using several parallel MTProto + /// connections, the same technique Telegram Desktop uses to reach high + /// speeds. Telegram limits throughput per connection (~5-6 MB/s), so a + /// single connection cannot go faster no matter how many chunks are in + /// flight; multiple connections each get their own allowance. The extra + /// connections are clones of the main session (same authorization, own + /// connection), so no new device entries are created on the account. + /// + public bool EnableMultiConnectionDownloads { get; set; } = false; + + /// + /// Number of parallel connections used per file download (2-8). + /// Only used when EnableMultiConnectionDownloads is true. + /// App default: 4. Recommended: 4-8. + /// + public int DownloadConnections { get; set; } = 4; + + /// + /// Size in KB of each file chunk requested (upload.getFile limit). + /// Telegram only allows 128, 256, 512 or 1024 (values are snapped to + /// the nearest allowed one). WTelegramClient's own default is 512; + /// app default is 1024 (fewer round-trips). + /// Only used when EnableMultiConnectionDownloads is true. + /// + public int MultiConnectionPartSizeKB { get; set; } = 1024; + + /// + /// Size in MB (1-16) of the work unit assigned to each connection. + /// All parts of a block are requested in parallel on the same + /// connection, so BlockSize / PartSize = requests in flight per + /// connection. App default: 4 (= 4 x 1MB in flight). + /// Only used when EnableMultiConnectionDownloads is true. + /// + public int MultiConnectionBlockSizeMB { get; set; } = 4; + + /// + /// Files smaller than this size in MB use the normal single-connection + /// download (the setup cost is not worth it for small files). + /// App default: 32. + /// Only used when EnableMultiConnectionDownloads is true. + /// + public int MultiConnectionMinFileSizeMB { get; set; } = 32; + } public class TLConfig diff --git a/TelegramDownloader/Pages/Config.razor b/TelegramDownloader/Pages/Config.razor index 2d0fcad..3be4e2e 100644 --- a/TelegramDownloader/Pages/Config.razor +++ b/TelegramDownloader/Pages/Config.razor @@ -230,6 +230,119 @@ +
+
+
+ + Parallel Chunk Transfers +
+
+ Number of chunks requested in parallel per transfer (1-16), used by standard (single-connection) downloads and uploads. Higher values remove the latency bottleneck; Premium accounts benefit the most. Applied on the next transfer. +
WTelegramClient default: 2 · App default: 4 · Recommended: 4-8 +
+
+
+ +
+
+ +
+
+
+ + Multi-Connection Downloads +
+
+ Download large files using several parallel connections, like Telegram Desktop does. + Telegram limits speed per connection (~5-6 MB/s), so this is the way to go faster. + The extra connections share your existing session authorization, so nothing new appears in your Telegram device list. + Experimental +
Default: disabled (library behavior: one connection per download) · Recommended: enabled for large files on fast lines +
+
+
+ +
+
+ + @if (Model!.EnableMultiConnectionDownloads) + { +
+
+
+ + Download Connections +
+
+ Number of parallel connections used per file download (2-8). +
App default: 4 · Recommended: 4-8 (Telegram Desktop uses up to 8) +
+
+
+ +
+
+ +
+
+
+ + Chunk Size +
+
+ Size of each file chunk requested from Telegram. Bigger chunks mean fewer round-trips. +
WTelegramClient default: 512 KB · App default and recommended: 1024 KB +
+
+
+ + + + + + +
+
+ +
+
+
+ + Block Size +
+
+ Work unit assigned to each connection (1-16 MB). All chunks of a block are requested in parallel on the same connection, so Block ÷ Chunk = requests in flight per connection. +
App default: 4 MB (= 4 chunks of 1024 KB in flight) · Recommended: 4-8 MB +
+
+
+
+ + MB +
+
+
+ +
+
+
+ + Min. File Size +
+
+ Files smaller than this use the normal single-connection download (the setup is not worth it for small files). +
App default and recommended: 32 MB +
+
+
+
+ + MB +
+
+
+ } +
@@ -252,14 +365,24 @@
- Preload Files on Stream + Streaming Mode (STRM / Emby / Kodi)
- Automatically preload files to cache when streaming + How exported .strm files play media from Telegram: +
    +
  • Direct streaming: plays instantly, chunks are fetched from Telegram on demand, nothing is stored on disk. Each play re-downloads from Telegram.
  • +
  • Progressive cache: plays instantly while the file is downloaded to the local cache in the background. Next plays are served from disk. Recommended
  • +
  • Full preload: downloads the whole file before playback starts (legacy behavior).
  • +
+ Applies to newly exported STRM files and to playback through the streaming endpoints. Files smaller than "Max. Preload File Size" are always fully preloaded.
- + + + + +
@@ -913,6 +1036,23 @@ @code { public GeneralConfig Model { get; set; } + + /// + /// Proxy for the STRM streaming mode: reads the effective mode (falling back to the + /// legacy PreloadFilesOnStream flag) and keeps that flag in sync when changed. + /// + private StreamingMode StreamingModeSetting + { + get => Model?.GetEffectiveStreamingMode() ?? StreamingMode.DirectStream; + set + { + if (Model != null) + { + Model.StrmStreamingMode = value; + Model.PreloadFilesOnStream = value == StreamingMode.Preload; + } + } + } private bool _disposed = false; private bool isRestarting = false; diff --git a/TelegramDownloader/Pages/Downloads.razor b/TelegramDownloader/Pages/Downloads.razor index 69b01a3..8c3ac3f 100644 --- a/TelegramDownloader/Pages/Downloads.razor +++ b/TelegramDownloader/Pages/Downloads.razor @@ -6,6 +6,8 @@ @using TelegramDownloader.Services @using Microsoft.AspNetCore.WebUtilities +@implements IDisposable + @inject TransactionInfoService tis @inject NavigationManager NavigationManager @inject ITaskPersistenceService taskPersistence @@ -182,9 +184,24 @@ protected override async Task OnInitializedAsync() { + tis.TransactionsChanged += OnTransactionsChanged; await LoadPersistedTasksCount(); } + private async void OnTransactionsChanged(object sender, System.EventArgs e) + { + try + { + await InvokeAsync(StateHasChanged); + } + catch { } + } + + public void Dispose() + { + tis.TransactionsChanged -= OnTransactionsChanged; + } + private async Task LoadPersistedTasksCount() { try diff --git a/TelegramDownloader/Pages/Index.razor b/TelegramDownloader/Pages/Index.razor index 5bffe0b..d3e3707 100644 --- a/TelegramDownloader/Pages/Index.razor +++ b/TelegramDownloader/Pages/Index.razor @@ -3,6 +3,7 @@ @implements IDisposable @using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.WebUtilities @using QRCoder @using TelegramDownloader.Data @using TelegramDownloader.Models @@ -43,6 +44,10 @@

Two-Factor Auth

break; + case "qr": +

Scan QR Code

+ + break; case "ok":

Welcome Back!

@@ -81,6 +86,15 @@ @bind-Value="Model!.value" placeholder="Enter your 2FA password" />
break; + case "qr": +
+ +
+ break; case "ok":
@@ -98,6 +112,12 @@ Disconnect } + else if (Model.type == "qr") + { + + } else { } } + @if (Model.type == "phone") + { + + }
@if (!string.IsNullOrEmpty(imageString)) @@ -248,8 +274,75 @@ } } + private bool qrMode = false; + + private async Task StartQrLogin() + { + qrMode = true; + imageString = null; + Model.type = "qr"; + source?.Cancel(); + source = new CancellationTokenSource(); + StateHasChanged(); + TelegramService.QrPasswordNeeded += OnQrPasswordNeeded; + try + { + var user = await ts.CallQrGenerator(url => { _ = InvokeAsync(() => GenerateQR(url)); }, source.Token); + if (user != null) + { + imageString = null; + Model.type = "ok"; + await InvokeAsync(StateHasChanged); + await isLogin(); + } + } + catch (OperationCanceledException) + { + // User went back to phone login or left the page + } + catch (Exception ex) + { + Console.WriteLine($"QR login failed: {ex.Message}"); + qrMode = false; + imageString = null; + Model.type = "phone"; + await InvokeAsync(StateHasChanged); + } + finally + { + TelegramService.QrPasswordNeeded -= OnQrPasswordNeeded; + } + } + + private void OnQrPasswordNeeded(object sender, System.EventArgs e) + { + // The QR was accepted on the phone but the account has 2FA: switch the + // form to the password step; Submit routes the value back to the + // pending QR login. + imageString = null; + Model.value = ""; + Model.type = "pass"; + _ = InvokeAsync(StateHasChanged); + } + + private void CancelQrLogin() + { + qrMode = false; + imageString = null; + source?.Cancel(); + Model.type = "phone"; + } + private async void Submit() { + if (qrMode && Model.type == "pass") + { + // 2FA password for a QR login: hand it to the waiting login task, + // which finishes the flow and navigates from StartQrLogin. + ts.ProvideQrLoginPassword(Model.value); + Model.value = ""; + return; + } if (Model.type == "phone") { var phone = await JSRuntime.InvokeAsync("getNumber"); @@ -276,8 +369,32 @@ source.Cancel(); } } - NavManager.NavigateTo("/fetchdata"); + NavManager.NavigateTo(GetReturnUrl() ?? "/fetchdata"); + } + } + + /// + /// Returns the page the user originally requested (passed by the layouts' + /// auth guard as ?returnUrl=...), so a successful login lands back on it. + /// Only local paths are accepted, to avoid open redirects. + /// + private string GetReturnUrl() + { + try + { + var uri = NavManager.ToAbsoluteUri(NavManager.Uri); + if (QueryHelpers.ParseQuery(uri.Query).TryGetValue("returnUrl", out var value)) + { + string url = value.ToString(); + if (url.StartsWith('/') && !url.StartsWith("//") && url != "/" && !url.StartsWith("/?")) + return url; + } + } + catch + { + // Malformed query - fall back to the default landing page } + return null; } private void GenerateQR(string data) @@ -308,6 +425,7 @@ public void Dispose() { + TelegramService.QrPasswordNeeded -= OnQrPasswordNeeded; if (source != null) { if (source.Token.CanBeCanceled) diff --git a/TelegramDownloader/Pages/Modals/InfoModals/DownloadFileInfoModal.razor b/TelegramDownloader/Pages/Modals/InfoModals/DownloadFileInfoModal.razor index 767b216..58a0f4a 100644 --- a/TelegramDownloader/Pages/Modals/InfoModals/DownloadFileInfoModal.razor +++ b/TelegramDownloader/Pages/Modals/InfoModals/DownloadFileInfoModal.razor @@ -1,5 +1,8 @@ @using TelegramDownloader.Models @using TelegramDownloader.Services +@implements IDisposable + +@inject TransactionInfoService tis - + @@ -136,7 +136,17 @@
- @Body + @if (authResolved) + { + @Body + } + else + { +
+ + Checking session... +
+ }
@@ -333,6 +343,7 @@ private VersionBadge versionBadge { get; set; } private bool active { get; set; } = true; private bool sidebarCollapsed { get; set; } = false; + private bool authResolved { get; set; } = false; private bool navMenuCollapsed { get; set; } = true; private bool showVersionModal { get; set; } = false; private static MainLayout? _instance; @@ -514,30 +525,43 @@ } } - protected override async Task OnAfterRenderAsync(bool firstRender) + protected override async Task OnInitializedAsync() { - if (firstRender) + // Check if setup is complete using async method to avoid deadlock + var status = await SetupService.GetSetupStatusAsync(); + if (status.CurrentStep != SetupStep.Complete) + { + NavManager.NavigateTo("/setup", forceLoad: true); + return; + } + + // Auth guard: resolve the session BEFORE any page content renders (the + // page body is gated on authResolved, so page components are not even + // instantiated until this completes). After an app restart the client + // exists but the user is not loaded yet, so attempt a silent session + // restore first; only bounce to the login page - keeping the requested + // URL so login can come back to it - when interactive login is needed. + if (!ts.checkUserLogin()) { + string authType = null; try { - if (!ts.checkUserLogin()) NavManager.NavigateTo("/", true); + authType = await ts.checkAuth(null); } catch (Exception e) { Console.WriteLine(e); } + if (authType != "ok") + { + string returnUrl = new Uri(NavManager.Uri).PathAndQuery; + NavManager.NavigateTo(string.IsNullOrEmpty(returnUrl) || returnUrl == "/" + ? "/" + : $"/?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: true); + return; + } } - } - - protected override async Task OnInitializedAsync() - { - // Check if setup is complete using async method to avoid deadlock - var status = await SetupService.GetSetupStatusAsync(); - if (status.CurrentStep != SetupStep.Complete) - { - NavManager.NavigateTo("/setup", forceLoad: true); - return; - } + authResolved = true; await checkWorkingTasks(); tis.TaskEventChanged += eventChangedWorkingTasks; diff --git a/TelegramDownloader/Shared/MobileFileManager/MobileFileManager.razor.cs b/TelegramDownloader/Shared/MobileFileManager/MobileFileManager.razor.cs index cf0347a..d6ad4f1 100644 --- a/TelegramDownloader/Shared/MobileFileManager/MobileFileManager.razor.cs +++ b/TelegramDownloader/Shared/MobileFileManager/MobileFileManager.razor.cs @@ -892,29 +892,27 @@ private async Task ConfirmNewFolder() { if (string.IsNullOrWhiteSpace(NewFolderName)) return; - // Use CurrentFolder which has the correct Id from LoadFiles response - FileManagerDirectoryContent parentFolder; - if (CurrentFolder != null) + // If CurrentFolder is missing or has no Id, refresh from the server first + if (CurrentFolder == null || string.IsNullOrEmpty(CurrentFolder.Id)) { - parentFolder = new FileManagerDirectoryContent - { - Id = CurrentFolder.Id, - Name = CurrentFolder.Name, - FilterPath = NormalizePath(CurrentFolder.FilterPath ?? ""), - FilterId = CurrentFolder.FilterId, - IsFile = false - }; + await LoadFiles(); } - else + + // After refresh, if we still don't have a valid CurrentFolder with Id, abort + if (CurrentFolder == null || string.IsNullOrEmpty(CurrentFolder.Id)) { - // Fallback for root - this shouldn't normally happen as LoadFiles sets CurrentFolder - parentFolder = new FileManagerDirectoryContent - { - FilterPath = NormalizePath(CurrentPath), - IsFile = false - }; + return; } + var parentFolder = new FileManagerDirectoryContent + { + Id = CurrentFolder.Id, + Name = CurrentFolder.Name, + FilterPath = string.IsNullOrEmpty(CurrentFolder.FilterPath) ? "" : NormalizePath(CurrentFolder.FilterPath), + FilterId = CurrentFolder.FilterId ?? "", + IsFile = false + }; + var args = new MfmFolderCreateEventArgs { FolderName = NewFolderName, diff --git a/TelegramDownloader/TelegramDownloader.csproj b/TelegramDownloader/TelegramDownloader.csproj index 4bbcc70..972b7fc 100644 --- a/TelegramDownloader/TelegramDownloader.csproj +++ b/TelegramDownloader/TelegramDownloader.csproj @@ -1,7 +1,7 @@ - 3.6.2.0 + 3.6.3.0 Mateo TelegramFileManager net10.0 @@ -23,7 +23,7 @@ - + @@ -33,8 +33,8 @@ - - + + diff --git a/TelegramDownloader/wwwroot/css/custombuttons.css b/TelegramDownloader/wwwroot/css/custombuttons.css index f400bdf..1807602 100644 --- a/TelegramDownloader/wwwroot/css/custombuttons.css +++ b/TelegramDownloader/wwwroot/css/custombuttons.css @@ -76,7 +76,8 @@ button.btn.water-fill-button.download:hover { box-shadow: 0 4px 12px rgba(23, 162, 184, 0.2) !important; } -/* Water fill background layer */ +/* Water fill background layer: deeper color at the bottom, lighter towards + the surface, so the liquid reads as translucent */ button.btn.water-fill-button > .water-fill { position: absolute !important; bottom: 0 !important; @@ -84,7 +85,9 @@ button.btn.water-fill-button > .water-fill { right: 0 !important; top: auto !important; width: 100% !important; - background: rgba(0, 123, 255, 0.7) !important; + background: linear-gradient(180deg, + rgba(0, 123, 255, 0.55) 0%, + rgba(0, 123, 255, 0.85) 100%) !important; transition: height 0.4s ease-out !important; pointer-events: none !important; z-index: 0 !important; @@ -92,14 +95,22 @@ button.btn.water-fill-button > .water-fill { } button.btn.water-fill-button.upload > .water-fill { - background: rgba(40, 167, 69, 0.7) !important; + background: linear-gradient(180deg, + rgba(40, 167, 69, 0.55) 0%, + rgba(40, 167, 69, 0.85) 100%) !important; } button.btn.water-fill-button.download > .water-fill { - background: rgba(23, 162, 184, 0.7) !important; + background: linear-gradient(180deg, + rgba(23, 162, 184, 0.55) 0%, + rgba(23, 162, 184, 0.85) 100%) !important; } -/* Wave effect on top of water */ +/* Waterline waves: the .water-wave element is an invisible box whose top edge + sits exactly at the fill level (inline height = progress). Two repeating + SVG wave strips ride on that edge, drifting horizontally at different + speeds and in opposite directions, which is what creates the sea-wave + motion; they overlap the fill by 1px so no seam shows */ button.btn.water-fill-button > .water-wave { position: absolute !important; bottom: 0 !important; @@ -107,23 +118,70 @@ button.btn.water-fill-button > .water-wave { right: 0 !important; top: auto !important; width: 100% !important; - background: linear-gradient(90deg, - transparent 0%, - rgba(255,255,255,0.4) 50%, - transparent 100%) !important; - background-size: 50% 100% !important; - animation: waveShine 1.5s ease-in-out infinite !important; + background: none !important; + animation: none !important; + transition: height 0.4s ease-out !important; pointer-events: none !important; z-index: 1 !important; display: block !important; + overflow: visible !important; +} + +button.btn.water-fill-button > .water-wave::before, +button.btn.water-fill-button > .water-wave::after { + content: ""; + position: absolute; + left: 0; + right: 0; + height: 8px; + background-repeat: repeat-x; + background-size: 80px 8px; + will-change: background-position-x; +} + +button.btn.water-fill-button > .water-wave::before { + top: -7px; + animation: waterWaveDrift 2.2s linear infinite; +} + +button.btn.water-fill-button > .water-wave::after { + top: -5px; + opacity: 0.5; + animation: waterWaveDrift 3.6s linear infinite reverse; +} + +/* Default (blue) wave crests */ +button.btn.water-fill-button > .water-wave::before, +button.btn.water-fill-button > .water-wave::after { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 80 8' preserveAspectRatio='none'%3E%3Cpath d='M0 4 Q10 0.5 20 4 T40 4 T60 4 T80 4 V8 H0 Z' fill='%23007bff' fill-opacity='0.75'/%3E%3C/svg%3E"); +} + +/* Upload (green) wave crests */ +button.btn.water-fill-button.upload > .water-wave::before, +button.btn.water-fill-button.upload > .water-wave::after { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 80 8' preserveAspectRatio='none'%3E%3Cpath d='M0 4 Q10 0.5 20 4 T40 4 T60 4 T80 4 V8 H0 Z' fill='%2328a745' fill-opacity='0.75'/%3E%3C/svg%3E"); +} + +/* Download (cyan) wave crests */ +button.btn.water-fill-button.download > .water-wave::before, +button.btn.water-fill-button.download > .water-wave::after { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 80 8' preserveAspectRatio='none'%3E%3Cpath d='M0 4 Q10 0.5 20 4 T40 4 T60 4 T80 4 V8 H0 Z' fill='%2317a2b8' fill-opacity='0.75'/%3E%3C/svg%3E"); } -@keyframes waveShine { - 0% { - background-position: -100% 0; +@keyframes waterWaveDrift { + from { + background-position-x: 0; + } + to { + background-position-x: 80px; } - 100% { - background-position: 200% 0; +} + +@media (prefers-reduced-motion: reduce) { + button.btn.water-fill-button > .water-wave::before, + button.btn.water-fill-button > .water-wave::after, + button.btn.water-fill-button .arrow { + animation: none !important; } }