+
+
+
+ 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)
+ {
+
@@ -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
Enter your 2FA password
break;
+ case "qr":
+
Scan QR Code
+
Log in with your phone, no number needed
+ break;
case "ok":
Welcome Back!
You are successfully authenticated
@@ -81,6 +86,15 @@
@bind-Value="Model!.value" placeholder="Enter your 2FA password" />
break;
+ case "qr":
+
+
+ Open Telegram on your phone and go to
+ Settings > Devices > Link Desktop Device,
+ then scan this code.
+
+
+ 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;
}
}