diff --git a/.github/workflows/buildrelease.yml b/.github/workflows/buildrelease.yml
index ee44526..9ae6aab 100644
--- a/.github/workflows/buildrelease.yml
+++ b/.github/workflows/buildrelease.yml
@@ -3,9 +3,14 @@ name: Build and Release
# Triggers:
# - Release published with tags:
# - server-v* : Build only Server
-# - app-v* : Build only TFMAudioApp (Android, Windows, macOS)
-# - v* : Build everything
-# - Manual workflow dispatch with checkboxes
+# - app-v* : Build only TFMAudioApp (Android, Windows, macOS) β on demand
+# - v* : Build only Server (mobile apps are NOT built by default)
+# - Manual workflow dispatch with checkboxes (build any target on demand)
+#
+# NOTE: Mobile apps (Android/Windows/macOS) are intentionally NOT built for a
+# plain "v*" release. Build them on demand when they actually change, either
+# with an "app-v*" release tag or via the manual workflow_dispatch checkboxes.
+# See docs/releases.md for the full policy.
on:
release:
@@ -128,10 +133,11 @@ jobs:
build-android:
name: Build Android APK
runs-on: ubuntu-latest
- # Run if: manual with build_android OR release with app-v* or v* (but not server-v*)
+ # On demand only: manual with build_android OR release with an app-v* tag.
+ # A plain v* release does NOT build the mobile apps.
if: |
(github.event_name == 'workflow_dispatch' && inputs.build_android) ||
- (github.event_name == 'release' && (startsWith(github.event.release.tag_name, 'app-v') || (startsWith(github.event.release.tag_name, 'v') && !startsWith(github.event.release.tag_name, 'server-v'))))
+ (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'app-v'))
steps:
- name: 'π Checkout'
uses: actions/checkout@v4
@@ -317,10 +323,11 @@ jobs:
build-windows:
name: Build Windows App
runs-on: windows-2022
- # Run if: manual with build_windows OR release with app-v* or v* (but not server-v*)
+ # On demand only: manual with build_windows OR release with an app-v* tag.
+ # A plain v* release does NOT build the mobile apps.
if: |
(github.event_name == 'workflow_dispatch' && inputs.build_windows) ||
- (github.event_name == 'release' && (startsWith(github.event.release.tag_name, 'app-v') || (startsWith(github.event.release.tag_name, 'v') && !startsWith(github.event.release.tag_name, 'server-v'))))
+ (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'app-v'))
steps:
- name: 'π Checkout'
uses: actions/checkout@v4
@@ -449,10 +456,11 @@ jobs:
build-macos:
name: Build macOS App
runs-on: macos-15
- # Run if: manual with build_macos OR release with app-v* or v* (but not server-v*)
+ # On demand only: manual with build_macos OR release with an app-v* tag.
+ # A plain v* release does NOT build the mobile apps.
if: |
(github.event_name == 'workflow_dispatch' && inputs.build_macos) ||
- (github.event_name == 'release' && (startsWith(github.event.release.tag_name, 'app-v') || (startsWith(github.event.release.tag_name, 'v') && !startsWith(github.event.release.tag_name, 'server-v'))))
+ (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'app-v'))
steps:
- name: 'π Checkout'
uses: actions/checkout@v4
diff --git a/TelegramDownloader/Controllers/Api/V1/ApiV1ControllerBase.cs b/TelegramDownloader/Controllers/Api/V1/ApiV1ControllerBase.cs
new file mode 100644
index 0000000..7a19caa
--- /dev/null
+++ b/TelegramDownloader/Controllers/Api/V1/ApiV1ControllerBase.cs
@@ -0,0 +1,61 @@
+using Microsoft.AspNetCore.Mvc;
+using TelegramDownloader.Models.Api;
+
+namespace TelegramDownloader.Controllers.Api.V1
+{
+ ///
+ /// Shared plumbing for every v1 controller: consistent envelopes, consistent
+ /// status codes and a helper to build absolute URLs behind a reverse proxy.
+ ///
+ [ApiController]
+ [Produces("application/json")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status500InternalServerError)]
+ public abstract class ApiV1ControllerBase : ControllerBase
+ {
+ ///
+ /// Absolute base URL of this server as seen by the client, honouring
+ /// X-Forwarded-Proto /X-Forwarded-Host (the app enables
+ /// forwarded headers at startup).
+ ///
+ protected string BaseUrl => $"{Request.Scheme}://{Request.Host}";
+
+ protected IActionResult OkResult(T data, string? message = null) =>
+ Ok(ApiResult.Ok(data, message));
+
+ protected IActionResult OkPaged(T data, PageInfo page) =>
+ Ok(ApiResult.Ok(data, page));
+
+ protected IActionResult OkEmpty(string? message = null) =>
+ Ok(ApiResult.Done(message));
+
+ protected IActionResult BadRequestResult(string message, string code = ApiErrorCodes.InvalidRequest, string? detail = null) =>
+ BadRequest(ApiResult.Fail(code, message, detail));
+
+ protected IActionResult NotFoundResult(string message, string code = ApiErrorCodes.NotFound) =>
+ NotFound(ApiResult.Fail(code, message));
+
+ protected IActionResult ConflictResult(string message, string code = ApiErrorCodes.Conflict) =>
+ Conflict(ApiResult.Fail(code, message));
+
+ protected IActionResult ForbiddenResult(string message) =>
+ StatusCode(StatusCodes.Status403Forbidden, ApiResult.Fail(ApiErrorCodes.Forbidden, message));
+
+ protected IActionResult ErrorResult(string message, Exception? ex = null, string code = ApiErrorCodes.InternalError) =>
+ StatusCode(StatusCodes.Status500InternalServerError, ApiResult.Fail(code, message, ex?.Message));
+
+ protected IActionResult UnavailableResult(string message, string code = ApiErrorCodes.ServiceUnavailable) =>
+ StatusCode(StatusCodes.Status503ServiceUnavailable, ApiResult.Fail(code, message));
+
+ ///
+ /// Applies in-memory paging to an already materialised list and returns
+ /// both the page and its metadata.
+ ///
+ protected static (List Items, PageInfo Page) Paginate(IReadOnlyList source, PagedQuery query)
+ {
+ var page = PageInfo.Create(query.Page, query.PageSize, source.Count);
+ var items = source.Skip((query.Page - 1) * query.PageSize).Take(query.PageSize).ToList();
+ return (items, page);
+ }
+ }
+}
diff --git a/TelegramDownloader/Controllers/Api/V1/AuthController.cs b/TelegramDownloader/Controllers/Api/V1/AuthController.cs
new file mode 100644
index 0000000..02ad0c8
--- /dev/null
+++ b/TelegramDownloader/Controllers/Api/V1/AuthController.cs
@@ -0,0 +1,260 @@
+using Microsoft.AspNetCore.Mvc;
+using TelegramDownloader.Data;
+using TelegramDownloader.Models.Api;
+using TelegramDownloader.Services;
+using TelegramDownloader.Services.Api;
+
+namespace TelegramDownloader.Controllers.Api.V1
+{
+ ///
+ /// Telegram session lifecycle: sign in with a phone number or a QR code,
+ /// inspect the current session and sign out.
+ ///
+ /// The Telegram session lives on the server and is shared by the web UI and
+ /// every API client: signing in here also signs in the web UI, and signing
+ /// out terminates both.
+ ///
+ [Route("api/v1/auth")]
+ [Tags("Auth")]
+ public class AuthController : ApiV1ControllerBase
+ {
+ private readonly ITelegramService _telegram;
+ private readonly ISetupService _setup;
+ private readonly QrLoginSessionManager _qr;
+ private readonly ILogger _logger;
+
+ public AuthController(
+ ITelegramService telegram,
+ ISetupService setup,
+ QrLoginSessionManager qr,
+ ILogger logger)
+ {
+ _telegram = telegram;
+ _setup = setup;
+ _qr = qr;
+ _logger = logger;
+ }
+
+ /// Current authentication state.
+ ///
+ /// Call this first. Step tells you what the server expects next:
+ /// phone , vc (verification code), pass (2FA
+ /// password), ok (already signed in) or setup_required
+ /// when the application has not been configured yet.
+ ///
+ [HttpGet("status")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task Status()
+ {
+ try
+ {
+ var dto = new AuthStatusDto { IsConfigured = _telegram.IsConfigured };
+
+ if (!_telegram.IsConfigured)
+ {
+ try
+ {
+ _telegram.InitializeClient();
+ dto.IsConfigured = _telegram.IsConfigured;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Telegram client could not be initialized");
+ }
+ }
+
+ if (!dto.IsConfigured)
+ {
+ dto.Step = AuthStep.SetupRequired;
+ return OkResult(dto);
+ }
+
+ dto.Step = await _telegram.checkAuth(null) ?? AuthStep.Phone;
+ dto.IsAuthenticated = dto.Step == AuthStep.Authenticated;
+
+ if (dto.IsAuthenticated)
+ dto.User = await BuildUserAsync();
+
+ return OkResult(dto);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error reading auth status");
+ return ErrorResult("Could not read the authentication status", ex);
+ }
+ }
+
+ /// Signed-in Telegram user.
+ [HttpGet("me")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status401Unauthorized)]
+ public async Task Me()
+ {
+ if (!_telegram.IsConfigured || !_telegram.checkUserLogin())
+ return StatusCode(StatusCodes.Status401Unauthorized,
+ ApiResult.Fail(ApiErrorCodes.NotLoggedIn, "No Telegram session is active"));
+
+ var user = await BuildUserAsync();
+ if (user == null)
+ return NotFoundResult("The Telegram user could not be resolved");
+
+ return OkResult(user);
+ }
+
+ /// Advances the phone login flow one step.
+ ///
+ /// Post the phone number with isPhone: true to start. The response
+ /// tells you the next step; post the verification code (and then, when
+ /// required, the two-factor password) with isPhone: false .
+ ///
+ /// Sample sequence:
+ ///
+ /// POST /api/v1/auth/login { "value": "+34600000000", "isPhone": true } -> step "vc"
+ /// POST /api/v1/auth/login { "value": "12345" } -> step "pass" or "ok"
+ /// POST /api/v1/auth/login { "value": "my-2fa-password" } -> step "ok"
+ ///
+ ///
+ [HttpPost("login")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status400BadRequest)]
+ public async Task Login([FromBody] LoginStepRequest request)
+ {
+ if (request == null || string.IsNullOrWhiteSpace(request.Value))
+ return BadRequestResult("A value is required for the current login step");
+
+ try
+ {
+ if (!_telegram.IsConfigured)
+ _telegram.InitializeClient();
+
+ var step = await _telegram.checkAuth(request.Value.Trim(), request.IsPhone) ?? AuthStep.Phone;
+
+ var dto = new AuthStatusDto
+ {
+ Step = step,
+ IsConfigured = _telegram.IsConfigured,
+ IsAuthenticated = step == AuthStep.Authenticated
+ };
+ if (dto.IsAuthenticated)
+ dto.User = await BuildUserAsync();
+
+ return OkResult(dto);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Login step failed");
+ return BadRequestResult("The login step was rejected by Telegram", ApiErrorCodes.InvalidRequest, ex.Message);
+ }
+ }
+
+ /// Starts a QR login session.
+ ///
+ /// Render qrImageBase64 (a PNG) or encode loginUrl yourself,
+ /// then poll GET /api/v1/auth/qr/{sessionId} . Telegram rotates the
+ /// token every ~30 seconds, so keep repainting the QR from the polled
+ /// value. When the status turns password_required , post the 2FA
+ /// password to /api/v1/auth/qr/{sessionId}/password .
+ ///
+ /// Terminate any existing session before starting.
+ [HttpPost("qr")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task StartQr([FromQuery] bool logoutFirst = false)
+ {
+ try
+ {
+ if (!_telegram.IsConfigured)
+ _telegram.InitializeClient();
+
+ var session = await _qr.StartAsync(_telegram, logoutFirst);
+ return OkResult(session);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Could not start a QR login session");
+ return ErrorResult("Could not start a QR login session", ex);
+ }
+ }
+
+ /// Polls the state of a QR login session.
+ [HttpGet("qr/{sessionId}")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public IActionResult PollQr(string sessionId)
+ {
+ var session = _qr.Get(sessionId);
+ if (session == null)
+ return NotFoundResult("Unknown or expired QR login session");
+ return OkResult(session);
+ }
+
+ /// Supplies the two-factor password a QR session is waiting for.
+ [HttpPost("qr/{sessionId}/password")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public IActionResult ProvideQrPassword(string sessionId, [FromBody] QrPasswordRequest request)
+ {
+ if (request == null || string.IsNullOrEmpty(request.Password))
+ return BadRequestResult("A password is required");
+
+ if (!_qr.ProvidePassword(sessionId, _telegram, request.Password))
+ return NotFoundResult("Unknown or expired QR login session");
+
+ return OkResult(_qr.Get(sessionId)!);
+ }
+
+ /// Cancels a pending QR login session.
+ [HttpDelete("qr/{sessionId}")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public IActionResult CancelQr(string sessionId)
+ {
+ if (!_qr.Cancel(sessionId))
+ return NotFoundResult("Unknown or expired QR login session");
+ return OkEmpty("QR login session cancelled");
+ }
+
+ /// Signs out of Telegram.
+ ///
+ /// This terminates the shared server session: the web UI is signed out
+ /// too and every client has to authenticate again.
+ ///
+ [HttpPost("logout")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task Logout()
+ {
+ try
+ {
+ await _telegram.logOff();
+ return OkEmpty("Signed out");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error signing out");
+ return ErrorResult("Could not sign out", ex);
+ }
+ }
+
+ private async Task BuildUserAsync()
+ {
+ try
+ {
+ var user = await _telegram.GetUser();
+ if (user == null) return null;
+ return new TelegramUserDto
+ {
+ Id = user.id,
+ Username = user.username,
+ FirstName = user.first_name,
+ LastName = user.last_name,
+ Phone = user.phone,
+ IsPremium = TelegramService.isPremium
+ };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Could not resolve the Telegram user");
+ return null;
+ }
+ }
+ }
+}
diff --git a/TelegramDownloader/Controllers/Api/V1/ChannelsController.cs b/TelegramDownloader/Controllers/Api/V1/ChannelsController.cs
new file mode 100644
index 0000000..cba2dec
--- /dev/null
+++ b/TelegramDownloader/Controllers/Api/V1/ChannelsController.cs
@@ -0,0 +1,553 @@
+using Microsoft.AspNetCore.Mvc;
+using TelegramDownloader.Data;
+using TelegramDownloader.Data.db;
+using TelegramDownloader.Models;
+using TelegramDownloader.Models.Api;
+using TL;
+
+namespace TelegramDownloader.Controllers.Api.V1
+{
+ ///
+ /// Telegram chats, channels and groups: discovery, favourites, folders,
+ /// creation and deletion, message history and index refresh.
+ ///
+ /// A "channel" in this API is any Telegram peer the account can see. When
+ /// the app indexes a channel it creates a MongoDB database named after the
+ /// channel id; that database is what the files endpoints browse.
+ ///
+ [Route("api/v1/channels")]
+ [Tags("Channels")]
+ [RequireTelegramSession]
+ public class ChannelsController : ApiV1ControllerBase
+ {
+ private readonly ITelegramService _telegram;
+ private readonly IFileService _files;
+ private readonly IDbService _db;
+ private readonly ILogger _logger;
+
+ public ChannelsController(
+ ITelegramService telegram,
+ IFileService files,
+ IDbService db,
+ ILogger logger)
+ {
+ _telegram = telegram;
+ _files = files;
+ _db = db;
+ _logger = logger;
+ }
+
+ /// Lists the chats the signed-in account can access.
+ ///
+ /// Set to list only the channels that
+ /// already have a local file index, which is what the file manager
+ /// navigates. Sorting accepts name (default) and id .
+ ///
+ /// Paging and sorting.
+ /// Only channels with a local index.
+ /// Only channels marked as favourite.
+ /// Case-insensitive substring match on the name.
+ [HttpGet]
+ [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)]
+ public async Task List(
+ [FromQuery] PagedQuery query,
+ [FromQuery] bool onlySaved = false,
+ [FromQuery] bool favoritesOnly = false,
+ [FromQuery] string? search = null)
+ {
+ try
+ {
+ var chats = onlySaved
+ ? await _telegram.getAllSavedChats()
+ : await _telegram.getAllChats();
+
+ var favourites = GeneralConfigStatic.config.FavouriteChannels ?? new List();
+ var items = (chats ?? new List())
+ .Where(c => c?.chat != null)
+ .Select(c => ApiChannelDto.FromChatViewBase(
+ c,
+ isFavorite: favourites.Contains(c.chat.ID),
+ isOwner: SafeIsOwner(c.chat.ID)))
+ .ToList();
+
+ if (favoritesOnly)
+ items = items.Where(c => c.IsFavorite).ToList();
+
+ if (!string.IsNullOrWhiteSpace(search))
+ items = items.Where(c => c.Name.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ items = (query.SortBy?.ToLowerInvariant(), query.SortDescending) switch
+ {
+ ("id", true) => items.OrderByDescending(c => c.Id).ToList(),
+ ("id", false) => items.OrderBy(c => c.Id).ToList(),
+ (_, true) => items.OrderByDescending(c => c.Name).ToList(),
+ _ => items.OrderBy(c => c.Name).ToList()
+ };
+
+ var (page, info) = Paginate(items, query);
+ return OkPaged(page, info);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error listing channels");
+ return ErrorResult("Could not list the channels", ex);
+ }
+ }
+
+ /// Lists chats grouped by their Telegram folder (chat filter).
+ [HttpGet("folders")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task Folders()
+ {
+ try
+ {
+ var data = await _telegram.getChatsWithFolders();
+ var favourites = GeneralConfigStatic.config.FavouriteChannels ?? new List();
+
+ var dto = new ApiChannelsWithFoldersDto
+ {
+ Folders = (data?.Folders ?? new List()).Select(f => new ApiChannelFolderDto
+ {
+ Id = f.Id,
+ Title = f.Title,
+ IconEmoji = f.IconEmoji,
+ Channels = (f.Chats ?? new List())
+ .Where(c => c?.chat != null)
+ .Select(c => ApiChannelDto.FromChatViewBase(c, favourites.Contains(c.chat.ID), SafeIsOwner(c.chat.ID)))
+ .ToList()
+ }).ToList(),
+ Ungrouped = (data?.UngroupedChats ?? new List())
+ .Where(c => c?.chat != null)
+ .Select(c => ApiChannelDto.FromChatViewBase(c, favourites.Contains(c.chat.ID), SafeIsOwner(c.chat.ID)))
+ .ToList()
+ };
+ dto.TotalChannels = dto.Folders.Sum(f => f.ChannelCount) + dto.Ungrouped.Count;
+
+ return OkResult(dto);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error listing channel folders");
+ return ErrorResult("Could not list the channel folders", ex);
+ }
+ }
+
+ /// Lists the favourite channels.
+ [HttpGet("favorites")]
+ [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)]
+ public async Task Favorites([FromQuery] bool refresh = true)
+ {
+ try
+ {
+ var chats = await _telegram.GetFouriteChannels(refresh);
+ var items = (chats ?? new List())
+ .Where(c => c?.chat != null)
+ .Select(c => ApiChannelDto.FromChatViewBase(c, isFavorite: true, isOwner: SafeIsOwner(c.chat.ID)))
+ .OrderBy(c => c.Name)
+ .ToList();
+ return OkResult(items);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error listing favourite channels");
+ return ErrorResult("Could not list the favourite channels", ex);
+ }
+ }
+
+ /// Marks a channel as favourite.
+ [HttpPost("{id}/favorite")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task AddFavorite(long id)
+ {
+ try
+ {
+ await _telegram.AddFavouriteChannel(id);
+ return OkEmpty("Channel added to favourites");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error adding channel {Id} to favourites", id);
+ return ErrorResult("Could not add the channel to favourites", ex);
+ }
+ }
+
+ /// Removes a channel from the favourites.
+ [HttpDelete("{id}/favorite")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task RemoveFavorite(long id)
+ {
+ try
+ {
+ await _telegram.RemoveFavouriteChannel(id);
+ return OkEmpty("Channel removed from favourites");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error removing channel {Id} from favourites", id);
+ return ErrorResult("Could not remove the channel from favourites", ex);
+ }
+ }
+
+ /// Details and indexed-content statistics of one channel.
+ [HttpGet("{id}")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public async Task Get(string id)
+ {
+ if (!long.TryParse(id, out var channelId))
+ return BadRequestResult("The channel id must be numeric");
+
+ try
+ {
+ var (name, exists) = _telegram.GetChannelInfo(channelId);
+ if (!exists && name == null)
+ return NotFoundResult("Channel not found", ApiErrorCodes.ChannelNotFound);
+
+ var isOwner = SafeIsOwner(channelId);
+ var dto = new ApiChannelDetailDto
+ {
+ Id = channelId,
+ Name = name ?? channelId.ToString(),
+ IsOwner = isOwner,
+ IsFavorite = (GeneralConfigStatic.config.FavouriteChannels ?? new List()).Contains(channelId),
+ ImageUrl = $"/api/channel/image/{channelId}",
+ IsRefreshing = _files.isChannelRefreshing(id),
+ CanRefresh = !_telegram.isMyChat(channelId) || GeneralConfigStatic.config.EnableRefreshOwnChannels
+ };
+
+ try
+ {
+ var all = await _db.getAllDatabaseData(id);
+ dto.HasDatabase = all != null;
+ if (all != null)
+ {
+ var files = all.Where(f => f.IsFile).ToList();
+ dto.FileCount = files.Count;
+ dto.FolderCount = all.Count - files.Count;
+ dto.TotalSize = files.Sum(f => f.Size);
+ dto.TotalSizeText = Services.HelperService.SizeSuffix(dto.TotalSize);
+ dto.AudioCount = files.Count(f => ApiFileDto.CategoryOf(f.Type) == "Audio");
+ dto.VideoCount = files.Count(f => ApiFileDto.CategoryOf(f.Type) == "Video");
+ dto.PhotoCount = files.Count(f => ApiFileDto.CategoryOf(f.Type) == "Photo");
+ dto.DocumentCount = files.Count(f => ApiFileDto.CategoryOf(f.Type) == "Document");
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Channel {Id} has no local index yet", id);
+ dto.HasDatabase = false;
+ }
+
+ return OkResult(dto);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error reading channel {Id}", id);
+ return ErrorResult("Could not read the channel", ex);
+ }
+ }
+
+ /// Creates a Telegram channel owned by the signed-in account.
+ ///
+ /// With createDatabase: true (the default) the local file index is
+ /// created at the same time, so the channel can be used as a storage
+ /// target immediately.
+ ///
+ [HttpPost]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status201Created)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status400BadRequest)]
+ public async Task Create([FromBody] CreateChannelRequest request)
+ {
+ if (request == null || string.IsNullOrWhiteSpace(request.Title))
+ return BadRequestResult("A channel title is required");
+
+ try
+ {
+ var channel = await _telegram.CreateChannel(request.Title.Trim(), request.About ?? string.Empty);
+ if (channel == null)
+ return ErrorResult("Telegram did not return the created channel");
+
+ if (request.CreateDatabase)
+ await _files.CreateDatabase(channel.ID.ToString());
+
+ var dto = new ApiChannelDto
+ {
+ Id = channel.ID,
+ Name = channel.title,
+ Type = channel.IsGroup ? "group" : "channel",
+ IsOwner = true,
+ ImageUrl = $"/api/channel/image/{channel.ID}",
+ HasDatabase = request.CreateDatabase
+ };
+
+ return StatusCode(StatusCodes.Status201Created, ApiResult.Ok(dto, "Channel created"));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error creating channel {Title}", request.Title);
+ return ErrorResult("Could not create the channel", ex);
+ }
+ }
+
+ /// Creates the local file index (MongoDB database) for a channel.
+ [HttpPost("{id}/database")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task CreateDatabase(string id)
+ {
+ try
+ {
+ await _files.CreateDatabase(id);
+ return OkEmpty("Channel database created");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error creating database for channel {Id}", id);
+ return ErrorResult("Could not create the channel database", ex);
+ }
+ }
+
+ /// Drops the local file index of a channel.
+ ///
+ /// Only the local index is removed: the files stay in Telegram, but the
+ /// app forgets the folder structure until the channel is refreshed again.
+ ///
+ [HttpDelete("{id}/database")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task DeleteDatabase(string id)
+ {
+ try
+ {
+ await _db.deleteDatabase(id);
+ return OkEmpty("Channel database deleted");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error deleting database for channel {Id}", id);
+ return ErrorResult("Could not delete the channel database", ex);
+ }
+ }
+
+ /// Leaves a channel, and optionally deletes it.
+ ///
+ /// With deleteOnTelegram: true the channel is deleted for every
+ /// member, which only works when the account owns it. This is
+ /// irreversible and also destroys the files stored inside.
+ ///
+ [HttpPost("{id}/leave")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status403Forbidden)]
+ public async Task Leave(long id, [FromBody] ChannelDeleteRequest? request)
+ {
+ request ??= new ChannelDeleteRequest();
+ try
+ {
+ if (request.DeleteOnTelegram)
+ {
+ if (!_telegram.isChannelOwner(id))
+ return ForbiddenResult("Only the channel owner can delete it");
+ await _telegram.DeleteChannel(id);
+ }
+ else
+ {
+ await _telegram.LeaveChannel(id);
+ }
+
+ if (request.DeleteLocalDatabase)
+ {
+ try { await _db.deleteDatabase(id.ToString()); }
+ catch (Exception ex) { _logger.LogWarning(ex, "Could not drop the local database of channel {Id}", id); }
+ }
+
+ return OkEmpty(request.DeleteOnTelegram ? "Channel deleted" : "Channel left");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error leaving/deleting channel {Id}", id);
+ return ErrorResult("Could not leave or delete the channel", ex);
+ }
+ }
+
+ /// Scans the channel on Telegram and indexes new files.
+ ///
+ /// The scan runs in the background and can take minutes on large
+ /// channels. Poll GET /api/v1/channels/{id}/refresh for the state,
+ /// and watch the transfers hub for the resulting activity. Only
+ /// files that are not indexed yet are added, so calling this repeatedly
+ /// is safe.
+ ///
+ [HttpPost("{id}/refresh")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status202Accepted)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status409Conflict)]
+ public IActionResult Refresh(string id, [FromBody] RefreshChannelRequest? request)
+ {
+ request ??= new RefreshChannelRequest();
+ if (!request.ToOptions().HasAnySelection)
+ return BadRequestResult("Select at least one media type to fetch");
+
+ if (_files.isChannelRefreshing(id))
+ return ConflictResult("This channel is already being refreshed", ApiErrorCodes.AlreadyRunning);
+
+ // Fire and forget: the scan is long running and reports through the
+ // notification/transfer pipeline.
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await _files.refreshChannelFIles(id, request.Force, request.ToOptions());
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Background refresh of channel {Id} failed", id);
+ }
+ });
+
+ return Accepted(ApiResult.Done("Channel refresh started"));
+ }
+
+ /// Tells whether a background refresh is running for a channel.
+ [HttpGet("{id}/refresh")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public IActionResult RefreshStatus(string id) => OkResult(_files.isChannelRefreshing(id));
+
+ /// Reads the recent message history of a chat.
+ ///
+ /// This hits Telegram directly and does not use the local index, so it
+ /// also works for channels that have never been indexed.
+ ///
+ /// Chat id.
+ /// Messages to return (1-100).
+ /// Messages to skip from the newest one.
+ /// Return only messages carrying a file.
+ [HttpGet("{id}/messages")]
+ [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)]
+ public async Task Messages(
+ long id,
+ [FromQuery] int limit = 30,
+ [FromQuery] int offset = 0,
+ [FromQuery] bool onlyMedia = false)
+ {
+ if (limit < 1) limit = 1;
+ if (limit > 100) limit = 100;
+
+ try
+ {
+ var messages = await _telegram.getChatHistory(id, limit, offset);
+ var items = (messages ?? new List())
+ .Where(m => m?.message != null)
+ .Select(ToMessageDto)
+ .Where(m => !onlyMedia || m.HasMedia)
+ .ToList();
+ return OkResult(items);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error reading messages of chat {Id}", id);
+ return ErrorResult("Could not read the chat history", ex);
+ }
+ }
+
+ /// Returns the channel avatar as a PNG/JPEG image.
+ [HttpGet("{id}/image")]
+ [Produces("image/jpeg", "image/png", "application/json")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public async Task Image(long id)
+ {
+ try
+ {
+ var bytes = await _telegram.DownloadChannelPhoto(id);
+ if (bytes == null || bytes.Length == 0)
+ return NotFoundResult("This channel has no avatar");
+ return File(bytes, "image/jpeg");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Could not download the avatar of channel {Id}", id);
+ return NotFoundResult("This channel has no avatar");
+ }
+ }
+
+ /// Returns the invitation link of a channel, generating one if needed.
+ [HttpGet("{id}/invitation")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public async Task Invitation(long id)
+ {
+ try
+ {
+ var info = await _telegram.getInvitationHash(id);
+ if (info == null)
+ return NotFoundResult("No invitation link is available for this channel");
+ return OkResult(info);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error reading the invitation of channel {Id}", id);
+ return ErrorResult("Could not read the channel invitation", ex);
+ }
+ }
+
+ /// Joins a channel using an invitation hash.
+ ///
+ /// The part after t.me/+ or joinchat/ in the invitation link.
+ ///
+ [HttpPost("join")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task Join([FromQuery] string hash)
+ {
+ if (string.IsNullOrWhiteSpace(hash))
+ return BadRequestResult("An invitation hash is required");
+
+ try
+ {
+ await _telegram.joinChatInvitationHash(hash);
+ return OkEmpty("Joined the channel");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error joining with hash {Hash}", hash);
+ return ErrorResult("Could not join the channel", ex);
+ }
+ }
+
+ private bool SafeIsOwner(long channelId)
+ {
+ try { return _telegram.isChannelOwner(channelId); }
+ catch { return false; }
+ }
+
+ private static ApiChatMessageDto ToMessageDto(ChatMessages m)
+ {
+ var dto = new ApiChatMessageDto
+ {
+ Id = m.message.ID,
+ Date = m.message.Date,
+ Text = m.message.message,
+ From = m.user?.ToString()
+ };
+
+ switch (m.message.media)
+ {
+ case MessageMediaPhoto:
+ dto.HasMedia = true;
+ dto.MediaType = "photo";
+ break;
+ case MessageMediaDocument { document: Document doc }:
+ dto.HasMedia = true;
+ dto.FileName = doc.Filename;
+ dto.FileSize = doc.size;
+ dto.MimeType = doc.mime_type;
+ dto.MediaType = doc.mime_type switch
+ {
+ not null when doc.mime_type.StartsWith("video") => "video",
+ not null when doc.mime_type.StartsWith("audio") => "audio",
+ not null when doc.mime_type.StartsWith("image") => "photo",
+ _ => "document"
+ };
+ break;
+ }
+
+ return dto;
+ }
+ }
+}
diff --git a/TelegramDownloader/Controllers/Api/V1/ConfigController.cs b/TelegramDownloader/Controllers/Api/V1/ConfigController.cs
new file mode 100644
index 0000000..221920d
--- /dev/null
+++ b/TelegramDownloader/Controllers/Api/V1/ConfigController.cs
@@ -0,0 +1,160 @@
+using Microsoft.AspNetCore.Mvc;
+using TelegramDownloader.Data.db;
+using TelegramDownloader.Models;
+using TelegramDownloader.Models.Api;
+
+namespace TelegramDownloader.Controllers.Api.V1
+{
+ ///
+ /// Application settings: transfer tuning, streaming behaviour, task
+ /// persistence and the WebDAV bridge.
+ ///
+ /// Settings are global and shared with the web UI: changing them here
+ /// changes them everywhere.
+ ///
+ [Route("api/v1/config")]
+ [Tags("Configuration")]
+ public class ConfigController : ApiV1ControllerBase
+ {
+ private readonly IDbService _db;
+ private readonly ILogger _logger;
+
+ public ConfigController(IDbService db, ILogger logger)
+ {
+ _db = db;
+ _logger = logger;
+ }
+
+ /// Reads the current configuration.
+ [HttpGet]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public IActionResult Get() => OkResult(AppConfigDto.From(GeneralConfigStatic.config));
+
+ ///
+ /// Updates the configuration. Only the fields present in the body are
+ /// applied, so a client can change one setting without reading the rest.
+ ///
+ ///
+ /// A few values are clamped server-side: memorySplitSizeGB is
+ /// capped by the Telegram file-size limit of the account (4 GB for
+ /// Premium, 2 GB otherwise) and by splitSize . The response always
+ /// returns the effective configuration after clamping.
+ ///
+ [HttpPatch]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status400BadRequest)]
+ public async Task Update([FromBody] UpdateConfigRequest request)
+ {
+ if (request == null)
+ return BadRequestResult("A configuration body is required");
+
+ try
+ {
+ var c = GeneralConfigStatic.config;
+
+ if (request.ShouldNotify.HasValue) c.ShouldNotify = request.ShouldNotify.Value;
+ if (request.TimeSleepBetweenTransactions.HasValue) c.TimeSleepBetweenTransactions = request.TimeSleepBetweenTransactions.Value;
+ if (request.SplitSize.HasValue) c.SplitSize = request.SplitSize.Value;
+ if (request.MaxSimultaneousDownloads.HasValue) c.MaxSimultaneousDownloads = Math.Max(1, request.MaxSimultaneousDownloads.Value);
+ if (request.CheckHash.HasValue) c.CheckHash = request.CheckHash.Value;
+ if (request.MaxImageUploadSizeInMb.HasValue) c.MaxImageUploadSizeInMb = request.MaxImageUploadSizeInMb.Value;
+ if (request.MaxPreloadFileSizeInMb.HasValue) c.MaxPreloadFileSizeInMb = request.MaxPreloadFileSizeInMb.Value;
+ if (request.ShouldShowCaptionPath.HasValue) c.ShouldShowCaptionPath = request.ShouldShowCaptionPath.Value;
+ if (request.ShouldShowLogInTerminal.HasValue) c.ShouldShowLogInTerminal = request.ShouldShowLogInTerminal.Value;
+
+ if (!string.IsNullOrWhiteSpace(request.StrmStreamingMode))
+ {
+ if (!Enum.TryParse(request.StrmStreamingMode, true, out var mode))
+ return BadRequestResult("strmStreamingMode must be DirectStream, ProgressiveCache or Preload");
+ c.StrmStreamingMode = mode;
+ }
+
+ if (request.ShouldShowPaginatedFileChannel.HasValue) c.ShouldShowPaginatedFileChannel = request.ShouldShowPaginatedFileChannel.Value;
+ if (request.ShowChannelImages.HasValue) c.ShowChannelImages = request.ShowChannelImages.Value;
+
+ if (request.EnableTaskPersistence.HasValue) c.EnableTaskPersistence = request.EnableTaskPersistence.Value;
+ if (request.TaskPersistenceDebounceSeconds.HasValue) c.TaskPersistenceDebounceSeconds = request.TaskPersistenceDebounceSeconds.Value;
+ if (request.StaleTaskCleanupDays.HasValue) c.StaleTaskCleanupDays = request.StaleTaskCleanupDays.Value;
+ if (request.AutoResumeOnStartup.HasValue) c.AutoResumeOnStartup = request.AutoResumeOnStartup.Value;
+
+ if (request.EnableVideoTranscoding.HasValue) c.EnableVideoTranscoding = request.EnableVideoTranscoding.Value;
+ if (request.EnableRefreshOwnChannels.HasValue) c.EnableRefreshOwnChannels = request.EnableRefreshOwnChannels.Value;
+
+ if (request.EnableMemorySplitUpload.HasValue) c.EnableMemorySplitUpload = request.EnableMemorySplitUpload.Value;
+ if (request.MemorySplitSizeGB.HasValue) c.MemorySplitSizeGB = request.MemorySplitSizeGB.Value;
+ if (request.ParallelTransfers.HasValue) c.ParallelTransfers = Math.Clamp(request.ParallelTransfers.Value, 1, 16);
+
+ if (request.EnableMultiConnectionDownloads.HasValue) c.EnableMultiConnectionDownloads = request.EnableMultiConnectionDownloads.Value;
+ if (request.DownloadConnections.HasValue) c.DownloadConnections = Math.Clamp(request.DownloadConnections.Value, 2, 8);
+ if (request.MultiConnectionPartSizeKB.HasValue) c.MultiConnectionPartSizeKB = request.MultiConnectionPartSizeKB.Value;
+ if (request.MultiConnectionBlockSizeMB.HasValue) c.MultiConnectionBlockSizeMB = Math.Clamp(request.MultiConnectionBlockSizeMB.Value, 1, 16);
+ if (request.MultiConnectionMinFileSizeMB.HasValue) c.MultiConnectionMinFileSizeMB = request.MultiConnectionMinFileSizeMB.Value;
+
+ c.webDav ??= new WebDavModel();
+ if (!string.IsNullOrWhiteSpace(request.WebDavHost)) c.webDav.Host = request.WebDavHost;
+ if (request.WebDavInternalPort.HasValue) c.webDav.PuertoEntrada = request.WebDavInternalPort.Value;
+ if (request.WebDavExternalPort.HasValue) c.webDav.PuertoSalida = request.WebDavExternalPort.Value;
+
+ await GeneralConfigStatic.SaveChanges(_db, c);
+
+ return OkResult(AppConfigDto.From(GeneralConfigStatic.config), "Configuration saved");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error updating the configuration");
+ return ErrorResult("Could not update the configuration", ex);
+ }
+ }
+
+ /// State of the WebDAV bridge.
+ [HttpGet("webdav")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public IActionResult WebDav() => OkResult(AppConfigDto.From(GeneralConfigStatic.config).WebDav);
+
+ /// Starts the WebDAV bridge.
+ ///
+ /// Once running, channels are reachable as WebDAV shares at
+ /// http://<host>:<externalPort>/<channelId>/ ,
+ /// which is how media servers mount a library.
+ ///
+ [HttpPost("webdav/start")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public IActionResult StartWebDav()
+ {
+ try
+ {
+ var webDav = GeneralConfigStatic.config.webDav;
+ if (webDav == null)
+ return BadRequestResult("WebDAV is not configured");
+
+ if (webDav.webDavService?.IsRunning == true)
+ return ConflictResult("The WebDAV bridge is already running", ApiErrorCodes.AlreadyRunning);
+
+ webDav.start();
+ return OkResult(AppConfigDto.From(GeneralConfigStatic.config).WebDav, "WebDAV bridge started");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error starting the WebDAV bridge");
+ return ErrorResult("Could not start the WebDAV bridge", ex);
+ }
+ }
+
+ /// Stops the WebDAV bridge.
+ [HttpPost("webdav/stop")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public IActionResult StopWebDav()
+ {
+ try
+ {
+ GeneralConfigStatic.config.webDav?.stop();
+ return OkResult(AppConfigDto.From(GeneralConfigStatic.config).WebDav, "WebDAV bridge stopped");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error stopping the WebDAV bridge");
+ return ErrorResult("Could not stop the WebDAV bridge", ex);
+ }
+ }
+ }
+}
diff --git a/TelegramDownloader/Controllers/Api/V1/FilesController.cs b/TelegramDownloader/Controllers/Api/V1/FilesController.cs
new file mode 100644
index 0000000..8932ab3
--- /dev/null
+++ b/TelegramDownloader/Controllers/Api/V1/FilesController.cs
@@ -0,0 +1,552 @@
+using Microsoft.AspNetCore.Mvc;
+using Syncfusion.Blazor.FileManager;
+using TelegramDownloader.Data;
+using TelegramDownloader.Data.db;
+using TelegramDownloader.Models;
+using TelegramDownloader.Models.Api;
+using TelegramDownloader.Services;
+using TelegramDownloader.Services.Api;
+
+namespace TelegramDownloader.Controllers.Api.V1
+{
+ ///
+ /// Browsing and managing the files a channel stores in Telegram, through the
+ /// local index. This mirrors the "Remote" tab of the web file manager.
+ ///
+ /// Folders are addressed either by path (/music/rock/ ) or by
+ /// folderId . Both are accepted everywhere; folderId wins when
+ /// both are present.
+ ///
+ [Route("api/v1/channels/{channelId}/files")]
+ [Tags("Files")]
+ [RequireTelegramSession]
+ public class FilesController : ApiV1ControllerBase
+ {
+ private readonly IDbService _db;
+ private readonly IFileService _files;
+ private readonly ChannelFolderResolver _resolver;
+ private readonly ILogger _logger;
+
+ public FilesController(
+ IDbService db,
+ IFileService files,
+ ChannelFolderResolver resolver,
+ ILogger logger)
+ {
+ _db = db;
+ _files = files;
+ _resolver = resolver;
+ _logger = logger;
+ }
+
+ /// Lists the contents of a folder.
+ ///
+ /// Folders are always returned before files. Supported sortBy
+ /// values are name (default), date , size and
+ /// type . filter narrows the result to one category:
+ /// audio , video , photo , document ,
+ /// archive .
+ ///
+ /// Channel id (also the name of its index database).
+ /// Navigation, filtering, sorting and paging.
+ [HttpGet]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public async Task Browse(string channelId, [FromQuery] BrowseQuery query)
+ {
+ try
+ {
+ var folder = await _resolver.ResolveFolder(channelId, query.FolderId, query.Path);
+ if (folder == null)
+ return NotFoundResult("Folder not found");
+ if (folder.IsFile)
+ return BadRequestResult("The requested id refers to a file, not a folder");
+
+ var children = await _resolver.ListChildren(channelId, folder);
+ var dto = BuildContents(channelId, folder, children, query, out var pageInfo);
+ return OkPaged(dto, pageInfo);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error browsing channel {ChannelId}", channelId);
+ return ErrorResult("Could not browse the channel", ex);
+ }
+ }
+
+ /// Searches files by name across a subtree.
+ /// Channel id.
+ /// Text to look for (case-insensitive, substring match).
+ /// Scope (path ), filtering, sorting and paging.
+ [HttpGet("search")]
+ [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)]
+ public async Task Search(string channelId, [FromQuery] string q, [FromQuery] BrowseQuery query)
+ {
+ if (string.IsNullOrWhiteSpace(q))
+ return BadRequestResult("A search term is required");
+
+ try
+ {
+ var scope = ChannelFolderResolver.NormalizeFolderPath(query.Path);
+ var searchRoot = scope == "/" ? "" : scope.TrimEnd('/');
+ var matches = await _db.Search(channelId, searchRoot, q);
+
+ var items = (matches ?? new List())
+ .Select(m => ApiFileDto.FromBson(m, channelId, BaseUrl))
+ .ToList();
+
+ items = ApplyFilter(items, query);
+ items = ApplySort(items, query);
+
+ var (pageItems, info) = Paginate(items, query);
+ return OkPaged(pageItems, info);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error searching channel {ChannelId} for '{Term}'", channelId, q);
+ return ErrorResult("Could not run the search", ex);
+ }
+ }
+
+ /// Details of a single file or folder.
+ [HttpGet("{fileId}")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public async Task Get(string channelId, string fileId)
+ {
+ try
+ {
+ var entry = await _db.getFileById(channelId, fileId);
+ if (entry == null)
+ return NotFoundResult("File not found", ApiErrorCodes.FileNotFound);
+ return OkResult(ApiFileDto.FromBson(entry, channelId, BaseUrl));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error reading file {FileId} of channel {ChannelId}", fileId, channelId);
+ return ErrorResult("Could not read the file", ex);
+ }
+ }
+
+ /// Aggregate size and file-type breakdown of a folder subtree.
+ [HttpGet("stats")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task Stats(string channelId, [FromQuery] string? path, [FromQuery] string? folderId)
+ {
+ try
+ {
+ var folder = await _resolver.ResolveFolder(channelId, folderId, path);
+ if (folder == null)
+ return NotFoundResult("Folder not found");
+
+ var childPath = ChannelFolderResolver.ChildFolderPath(folder);
+ var all = await _db.getAllChildFilesInDirectory(channelId, childPath);
+ var files = (all ?? new List()).Where(f => f.IsFile).ToList();
+
+ var stats = new ApiFolderStatsDto
+ {
+ FileCount = files.Count,
+ FolderCount = (all?.Count ?? 0) - files.Count,
+ AudioCount = files.Count(f => ApiFileDto.CategoryOf(f.Type) == "Audio"),
+ VideoCount = files.Count(f => ApiFileDto.CategoryOf(f.Type) == "Video"),
+ PhotoCount = files.Count(f => ApiFileDto.CategoryOf(f.Type) == "Photo"),
+ DocumentCount = files.Count(f => ApiFileDto.CategoryOf(f.Type) == "Document"),
+ TotalSize = files.Sum(f => f.Size)
+ };
+ stats.TotalSizeText = HelperService.SizeSuffix(stats.TotalSize);
+
+ return OkResult(stats);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error computing stats for channel {ChannelId}", channelId);
+ return ErrorResult("Could not compute the folder statistics", ex);
+ }
+ }
+
+ /// Creates a folder.
+ [HttpPost("folders")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status201Created)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status409Conflict)]
+ public async Task CreateFolder(string channelId, [FromBody] CreateFolderRequest request)
+ {
+ if (request == null || string.IsNullOrWhiteSpace(request.Name))
+ return BadRequestResult("A folder name is required");
+ if (request.Name.Contains('/') || request.Name.Contains('\\'))
+ return BadRequestResult("A folder name cannot contain path separators");
+
+ try
+ {
+ var parent = await _resolver.ResolveFolder(channelId, null, request.Path);
+ if (parent == null)
+ return NotFoundResult("Parent folder not found");
+
+ var created = await _files.createFolder(
+ channelId,
+ ChannelFolderResolver.CreateChildPath(parent),
+ request.Name.Trim(),
+ ChannelFolderResolver.ToContent(parent));
+
+ var first = created?.FirstOrDefault();
+ if (first == null)
+ return ErrorResult("The folder was not created");
+
+ var entry = await _db.getFileById(channelId, first.Id);
+ var dto = entry != null
+ ? ApiFileDto.FromBson(entry, channelId, BaseUrl)
+ : new ApiFileDto { Id = first.Id, Name = request.Name, IsFile = false, Type = "folder", Category = "Folder" };
+
+ return StatusCode(StatusCodes.Status201Created, ApiResult.Ok(dto, "Folder created"));
+ }
+ catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
+ {
+ return ConflictResult("A folder with that name already exists here");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error creating folder in channel {ChannelId}", channelId);
+ return ErrorResult("Could not create the folder", ex);
+ }
+ }
+
+ /// Renames a file or folder.
+ [HttpPut("{fileId}/name")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public async Task Rename(string channelId, string fileId, [FromBody] RenameRequest request)
+ {
+ if (request == null || string.IsNullOrWhiteSpace(request.NewName))
+ return BadRequestResult("A new name is required");
+ if (request.NewName.Contains('/') || request.NewName.Contains('\\'))
+ return BadRequestResult("A name cannot contain path separators");
+
+ try
+ {
+ var entry = await _db.getFileById(channelId, fileId);
+ if (entry == null)
+ return NotFoundResult("File not found", ApiErrorCodes.FileNotFound);
+
+ await _files.RenameFileOrFolder(channelId, ChannelFolderResolver.ToContent(entry), request.NewName.Trim());
+
+ var updated = await _db.getFileById(channelId, fileId);
+ return OkResult(
+ updated != null ? ApiFileDto.FromBson(updated, channelId, BaseUrl) : ApiFileDto.FromBson(entry, channelId, BaseUrl),
+ "Renamed");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error renaming {FileId} in channel {ChannelId}", fileId, channelId);
+ return ErrorResult("Could not rename the entry", ex);
+ }
+ }
+
+ /// Deletes files and folders.
+ ///
+ /// Deleting also removes the underlying Telegram messages when no other
+ /// indexed entry references them, so this frees the channel storage.
+ /// Folders are deleted recursively. The operation is not reversible.
+ ///
+ [HttpPost("delete")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task Delete(string channelId, [FromBody] FileIdsRequest request)
+ {
+ if (request == null || request.Ids.Count == 0)
+ return BadRequestResult("At least one id is required");
+
+ var deleted = 0;
+ var skipped = new List();
+
+ foreach (var id in request.Ids)
+ {
+ try
+ {
+ var entry = await _db.getFileById(channelId, id);
+ if (entry == null)
+ {
+ skipped.Add(id);
+ continue;
+ }
+ await _files.oneItemDeleteAsync(channelId, ChannelFolderResolver.ToContent(entry));
+ deleted++;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Could not delete {FileId} in channel {ChannelId}", id, channelId);
+ skipped.Add(id);
+ }
+ }
+
+ return OkResult(new TransferAcceptedDto { Accepted = deleted, Skipped = skipped },
+ $"{deleted} entries deleted");
+ }
+
+ /// Copies files and folders to another folder of the same channel.
+ ///
+ /// Copies are index-level: the Telegram messages are shared, so a copy
+ /// consumes no extra channel storage.
+ ///
+ [HttpPost("copy")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public Task Copy(string channelId, [FromBody] CopyMoveRequest request) =>
+ CopyOrMove(channelId, request, isCopy: true);
+
+ /// Moves files and folders to another folder of the same channel.
+ [HttpPost("move")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public Task Move(string channelId, [FromBody] CopyMoveRequest request) =>
+ CopyOrMove(channelId, request, isCopy: false);
+
+ private async Task CopyOrMove(string channelId, CopyMoveRequest request, bool isCopy)
+ {
+ if (request == null || request.Ids.Count == 0)
+ return BadRequestResult("At least one id is required");
+
+ try
+ {
+ var target = await _resolver.ResolveFolder(channelId, request.TargetFolderId, request.TargetPath);
+ if (target == null || target.IsFile)
+ return NotFoundResult("Target folder not found");
+
+ var entries = new List();
+ var skipped = new List();
+ foreach (var id in request.Ids)
+ {
+ var entry = await _db.getFileById(channelId, id);
+ if (entry == null) skipped.Add(id);
+ else entries.Add(entry);
+ }
+
+ if (entries.Count > 0)
+ {
+ var contents = entries.Select(ChannelFolderResolver.ToContent).ToArray();
+ await _files.CopyOrMoveItems(
+ channelId,
+ contents,
+ ChannelFolderResolver.ChildFolderPath(target),
+ ChannelFolderResolver.ToContent(target),
+ isCopy);
+ }
+
+ return OkResult(new TransferAcceptedDto { Accepted = entries.Count, Skipped = skipped },
+ isCopy ? "Entries copied" : "Entries moved");
+ }
+ catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
+ {
+ return ConflictResult("An entry with the same name already exists in the target folder");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error on {Operation} in channel {ChannelId}", isCopy ? "copy" : "move", channelId);
+ return ErrorResult(isCopy ? "Could not copy the entries" : "Could not move the entries", ex);
+ }
+ }
+
+ /// Uploads a file directly into a channel folder.
+ ///
+ /// The request must be multipart/form-data with a file
+ /// part. The upload is streamed to Telegram and its progress is
+ /// published on the transfers hub like any other upload.
+ ///
+ /// To push files that already live on the server, use
+ /// POST /api/v1/transfers/uploads instead: it avoids sending the
+ /// bytes twice.
+ ///
+ /// Destination channel.
+ /// File part of the multipart body.
+ /// Destination folder inside the channel. Defaults to the root.
+ [HttpPost("upload")]
+ [RequestSizeLimit(long.MaxValue)]
+ [RequestFormLimits(MultipartBodyLengthLimit = long.MaxValue)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status202Accepted)]
+ public async Task Upload(string channelId, IFormFile file, [FromForm] string? path)
+ {
+ if (file == null || file.Length == 0)
+ return BadRequestResult("A non-empty file part is required");
+
+ try
+ {
+ var folder = ChannelFolderResolver.NormalizeFolderPath(path);
+
+ // Stage the bytes under the local root, then reuse the regular
+ // server-to-Telegram pipeline so the upload shows up in the task
+ // list, is persisted and streams its progress over SignalR.
+ var stagingRelative = $"{ApiUploadStaging.FolderName}/{Guid.NewGuid():N}";
+ var stagingAbsolute = Path.Combine(FileService.LOCALDIR, stagingRelative.Replace('/', Path.DirectorySeparatorChar));
+ Directory.CreateDirectory(stagingAbsolute);
+
+ var safeName = Path.GetFileName(file.FileName);
+ await using (var fs = System.IO.File.Create(Path.Combine(stagingAbsolute, safeName)))
+ await file.CopyToAsync(fs);
+
+ var content = new FileManagerDirectoryContent
+ {
+ Name = safeName,
+ IsFile = true,
+ Size = file.Length,
+ FilterPath = "/" + stagingRelative + "/",
+ Type = Path.GetExtension(safeName)
+ };
+
+ await _files.AddUploadFileFromServer(channelId, folder, new List { content });
+ return Accepted(ApiResult.Done($"Upload of {safeName} started"));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error uploading {FileName} to channel {ChannelId}", file?.FileName, channelId);
+ return ErrorResult("Could not upload the file", ex);
+ }
+ }
+
+ /// Exports the whole channel index as a JSON document.
+ ///
+ /// The export can be re-imported into another instance with
+ /// POST /api/v1/channels/{channelId}/files/import , which is how
+ /// the app moves a library between servers.
+ ///
+ [HttpGet("export")]
+ [Produces("application/json")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task Export(string channelId)
+ {
+ try
+ {
+ var ms = await _files.exportAllData(channelId);
+ ms.Position = 0;
+ return File(ms, "application/json", $"{channelId}.json");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error exporting channel {ChannelId}", channelId);
+ return ErrorResult("Could not export the channel index", ex);
+ }
+ }
+
+ /// Imports a previously exported channel index.
+ ///
+ /// Send the export file as multipart/form-data in a file
+ /// part. Import runs in the background and reports through the
+ /// notification pipeline.
+ ///
+ [HttpPost("import")]
+ [RequestSizeLimit(long.MaxValue)]
+ [RequestFormLimits(MultipartBodyLengthLimit = long.MaxValue)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status202Accepted)]
+ public async Task Import(string channelId, IFormFile file)
+ {
+ if (file == null || file.Length == 0)
+ return BadRequestResult("A non-empty file part is required");
+
+ try
+ {
+ var tempPath = Path.Combine(FileService.TEMPDIR, $"import-{Guid.NewGuid():N}.json");
+ Directory.CreateDirectory(FileService.TEMPDIR);
+ await using (var fs = System.IO.File.Create(tempPath))
+ await file.CopyToAsync(fs);
+
+ var progress = new GenericNotificationProgressModel();
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await _files.importData(channelId, tempPath, progress);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Background import into channel {ChannelId} failed", channelId);
+ }
+ finally
+ {
+ try { System.IO.File.Delete(tempPath); } catch { }
+ }
+ });
+
+ return Accepted(ApiResult.Done("Import started"));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error importing into channel {ChannelId}", channelId);
+ return ErrorResult("Could not import the channel index", ex);
+ }
+ }
+
+ private ApiFolderContentsDto BuildContents(
+ string channelId,
+ BsonFileManagerModel folder,
+ List children,
+ BrowseQuery query,
+ out PageInfo pageInfo)
+ {
+ var all = children.Select(m => ApiFileDto.FromBson(m, channelId, BaseUrl)).ToList();
+
+ var stats = new ApiFolderStatsDto
+ {
+ FolderCount = all.Count(i => !i.IsFile),
+ FileCount = all.Count(i => i.IsFile),
+ AudioCount = all.Count(i => i.Category == "Audio"),
+ VideoCount = all.Count(i => i.Category == "Video"),
+ PhotoCount = all.Count(i => i.Category == "Photo"),
+ DocumentCount = all.Count(i => i.Category == "Document"),
+ TotalSize = all.Where(i => i.IsFile).Sum(i => i.Size)
+ };
+ stats.TotalSizeText = HelperService.SizeSuffix(stats.TotalSize);
+
+ var items = query.FilesOnly ? all.Where(i => i.IsFile).ToList() : all;
+ items = ApplyFilter(items, query);
+
+ if (!string.IsNullOrWhiteSpace(query.Search))
+ items = items.Where(i => i.Name.Contains(query.Search, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ items = ApplySort(items, query);
+
+ var (pageItems, info) = Paginate(items, query);
+ pageInfo = info;
+
+ var crumbs = ChannelFolderResolver.Breadcrumbs(folder);
+ var currentPath = ChannelFolderResolver.ChildFolderPath(folder);
+
+ return new ApiFolderContentsDto
+ {
+ ChannelId = channelId,
+ CurrentPath = currentPath,
+ CurrentFolderId = folder.Id,
+ ParentFolderId = string.IsNullOrEmpty(folder.ParentId) ? null : folder.ParentId,
+ ParentPath = currentPath == "/" ? null : (crumbs.Count > 1 ? crumbs[^2].Path : "/"),
+ FolderName = folder.Name,
+ Items = pageItems,
+ Stats = stats,
+ Breadcrumbs = crumbs.Select(c => new ApiBreadcrumbDto { Name = c.Name, Path = c.Path }).ToList()
+ };
+ }
+
+ private static List ApplyFilter(List items, BrowseQuery query)
+ {
+ if (string.IsNullOrWhiteSpace(query.Filter) || query.Filter.Equals("all", StringComparison.OrdinalIgnoreCase))
+ return items;
+
+ var wanted = query.Filter.Trim().ToLowerInvariant() switch
+ {
+ "audio" => "Audio",
+ "video" => "Video",
+ "photo" or "photos" or "image" or "images" => "Photo",
+ "document" or "documents" or "doc" => "Document",
+ "archive" or "archives" => "Archive",
+ _ => query.Filter
+ };
+
+ return items.Where(i => !i.IsFile || i.Category.Equals(wanted, StringComparison.OrdinalIgnoreCase)).ToList();
+ }
+
+ private static List ApplySort(List items, BrowseQuery query) =>
+ (query.SortBy?.ToLowerInvariant(), query.SortDescending) switch
+ {
+ ("date", true) => items.OrderBy(i => i.IsFile).ThenByDescending(i => i.DateModified).ToList(),
+ ("date", false) => items.OrderBy(i => i.IsFile).ThenBy(i => i.DateModified).ToList(),
+ ("size", true) => items.OrderBy(i => i.IsFile).ThenByDescending(i => i.Size).ToList(),
+ ("size", false) => items.OrderBy(i => i.IsFile).ThenBy(i => i.Size).ToList(),
+ ("type", true) => items.OrderBy(i => i.IsFile).ThenByDescending(i => i.Type).ToList(),
+ ("type", false) => items.OrderBy(i => i.IsFile).ThenBy(i => i.Type).ToList(),
+ (_, true) => items.OrderBy(i => i.IsFile).ThenByDescending(i => i.Name).ToList(),
+ _ => items.OrderBy(i => i.IsFile).ThenBy(i => i.Name).ToList()
+ };
+ }
+}
diff --git a/TelegramDownloader/Controllers/Api/V1/LocalFilesController.cs b/TelegramDownloader/Controllers/Api/V1/LocalFilesController.cs
new file mode 100644
index 0000000..184f250
--- /dev/null
+++ b/TelegramDownloader/Controllers/Api/V1/LocalFilesController.cs
@@ -0,0 +1,407 @@
+using Microsoft.AspNetCore.Mvc;
+using TelegramDownloader.Data;
+using TelegramDownloader.Models.Api;
+using TelegramDownloader.Services;
+using TelegramDownloader.Services.Api;
+
+namespace TelegramDownloader.Controllers.Api.V1
+{
+ ///
+ /// The server's local storage: the folder downloads land in and uploads are
+ /// taken from. This mirrors the "Local" tab of the web file manager.
+ ///
+ /// Every path is relative to the local root and is validated against
+ /// directory traversal; absolute paths and .. segments that escape
+ /// the root are rejected with 400 invalid_request .
+ ///
+ [Route("api/v1/local")]
+ [Tags("Local files")]
+ public class LocalFilesController : ApiV1ControllerBase
+ {
+ private readonly ILogger _logger;
+
+ public LocalFilesController(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ /// Lists a local directory.
+ ///
+ /// Use an empty path for the root. Folders come first; sorting
+ /// and filtering behave exactly like the channel browse endpoint.
+ ///
+ [HttpGet]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public IActionResult Browse([FromQuery] BrowseQuery query)
+ {
+ if (!TryResolve(query.Path, out var absolute, out var relative, out var error))
+ return BadRequestResult(error!);
+
+ if (!Directory.Exists(absolute))
+ return NotFoundResult("Directory not found");
+
+ try
+ {
+ var dir = new DirectoryInfo(absolute);
+ var items = new List();
+
+ foreach (var sub in dir.GetDirectories())
+ items.Add(ApiFileDto.FromLocalDirectory(sub, Join(relative, sub.Name)));
+
+ foreach (var file in dir.GetFiles())
+ items.Add(ApiFileDto.FromLocalFile(file, Join(relative, file.Name), BaseUrl));
+
+ var stats = new ApiFolderStatsDto
+ {
+ FolderCount = items.Count(i => !i.IsFile),
+ FileCount = items.Count(i => i.IsFile),
+ AudioCount = items.Count(i => i.Category == "Audio"),
+ VideoCount = items.Count(i => i.Category == "Video"),
+ PhotoCount = items.Count(i => i.Category == "Photo"),
+ DocumentCount = items.Count(i => i.Category == "Document"),
+ TotalSize = items.Where(i => i.IsFile).Sum(i => i.Size)
+ };
+ stats.TotalSizeText = HelperService.SizeSuffix(stats.TotalSize);
+
+ var filtered = query.FilesOnly ? items.Where(i => i.IsFile).ToList() : items;
+ filtered = ApplyFilter(filtered, query.Filter);
+
+ if (!string.IsNullOrWhiteSpace(query.Search))
+ filtered = filtered.Where(i => i.Name.Contains(query.Search, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ filtered = ApplySort(filtered, query);
+
+ var (pageItems, page) = Paginate(filtered, query);
+
+ var parent = string.IsNullOrEmpty(relative)
+ ? null
+ : (Path.GetDirectoryName(relative)?.Replace("\\", "/") ?? string.Empty);
+
+ var dto = new ApiFolderContentsDto
+ {
+ CurrentPath = "/" + relative,
+ CurrentFolderId = relative,
+ ParentPath = parent,
+ FolderName = string.IsNullOrEmpty(relative) ? "Local" : dir.Name,
+ Items = pageItems,
+ Stats = stats,
+ Breadcrumbs = BuildBreadcrumbs(relative)
+ };
+
+ return OkPaged(dto, page);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error listing local path {Path}", query.Path);
+ return ErrorResult("Could not list the directory", ex);
+ }
+ }
+
+ /// Metadata of one local file or directory.
+ [HttpGet("info")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public IActionResult Info([FromQuery] string path)
+ {
+ if (!TryResolve(path, out var absolute, out var relative, out var error))
+ return BadRequestResult(error!);
+
+ if (System.IO.File.Exists(absolute))
+ return OkResult(ApiFileDto.FromLocalFile(new FileInfo(absolute), relative, BaseUrl));
+
+ if (Directory.Exists(absolute))
+ return OkResult(ApiFileDto.FromLocalDirectory(new DirectoryInfo(absolute), relative));
+
+ return NotFoundResult("Path not found");
+ }
+
+ /// Recursive size and file-type breakdown of a local directory.
+ [HttpGet("size")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task Size([FromQuery] string? path)
+ {
+ if (!TryResolve(path, out var absolute, out _, out var error))
+ return BadRequestResult(error!);
+
+ if (!Directory.Exists(absolute))
+ return NotFoundResult("Directory not found");
+
+ try
+ {
+ return OkResult(await HelperService.GetDirecctorySizeAsync(absolute));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error measuring local path {Path}", path);
+ return ErrorResult("Could not measure the directory", ex);
+ }
+ }
+
+ /// Creates a local directory.
+ [HttpPost("folders")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status201Created)]
+ public IActionResult CreateFolder([FromBody] LocalCreateFolderRequest request)
+ {
+ if (request == null || string.IsNullOrWhiteSpace(request.Name))
+ return BadRequestResult("A folder name is required");
+ if (request.Name.Contains('/') || request.Name.Contains('\\'))
+ return BadRequestResult("A folder name cannot contain path separators");
+
+ if (!TryResolve(request.Path, out var parentAbsolute, out var parentRelative, out var error))
+ return BadRequestResult(error!);
+
+ try
+ {
+ var target = Path.Combine(parentAbsolute, request.Name.Trim());
+ if (Directory.Exists(target))
+ return ConflictResult("A folder with that name already exists");
+
+ var info = Directory.CreateDirectory(target);
+ var dto = ApiFileDto.FromLocalDirectory(info, Join(parentRelative, info.Name));
+ return StatusCode(StatusCodes.Status201Created, ApiResult.Ok(dto, "Folder created"));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error creating local folder under {Path}", request.Path);
+ return ErrorResult("Could not create the folder", ex);
+ }
+ }
+
+ /// Renames a local file or directory.
+ [HttpPost("rename")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public IActionResult Rename([FromBody] LocalRenameRequest request)
+ {
+ if (request == null || string.IsNullOrWhiteSpace(request.NewName))
+ return BadRequestResult("A new name is required");
+ if (request.NewName.Contains('/') || request.NewName.Contains('\\'))
+ return BadRequestResult("A name cannot contain path separators");
+
+ if (!TryResolve(request.Path, out var absolute, out var relative, out var error))
+ return BadRequestResult(error!);
+
+ try
+ {
+ var parentAbsolute = Path.GetDirectoryName(absolute)!;
+ var parentRelative = Path.GetDirectoryName(relative)?.Replace("\\", "/") ?? string.Empty;
+ var target = Path.Combine(parentAbsolute, request.NewName.Trim());
+
+ if (System.IO.File.Exists(absolute))
+ {
+ if (System.IO.File.Exists(target)) return ConflictResult("A file with that name already exists");
+ System.IO.File.Move(absolute, target);
+ return OkResult(ApiFileDto.FromLocalFile(new FileInfo(target), Join(parentRelative, request.NewName.Trim()), BaseUrl), "Renamed");
+ }
+
+ if (Directory.Exists(absolute))
+ {
+ if (Directory.Exists(target)) return ConflictResult("A folder with that name already exists");
+ Directory.Move(absolute, target);
+ return OkResult(ApiFileDto.FromLocalDirectory(new DirectoryInfo(target), Join(parentRelative, request.NewName.Trim())), "Renamed");
+ }
+
+ return NotFoundResult("Path not found");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error renaming local path {Path}", request.Path);
+ return ErrorResult("Could not rename the entry", ex);
+ }
+ }
+
+ /// Deletes local files and directories.
+ /// Directories are deleted recursively and the data is not recoverable.
+ [HttpPost("delete")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public IActionResult Delete([FromBody] LocalDeleteRequest request)
+ {
+ if (request == null || request.Paths.Count == 0)
+ return BadRequestResult("At least one path is required");
+
+ var deleted = 0;
+ var skipped = new List();
+
+ foreach (var path in request.Paths)
+ {
+ if (!TryResolve(path, out var absolute, out _, out _))
+ {
+ skipped.Add(path);
+ continue;
+ }
+
+ try
+ {
+ if (System.IO.File.Exists(absolute)) { System.IO.File.Delete(absolute); deleted++; }
+ else if (Directory.Exists(absolute)) { Directory.Delete(absolute, true); deleted++; }
+ else skipped.Add(path);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Could not delete local path {Path}", path);
+ skipped.Add(path);
+ }
+ }
+
+ return OkResult(new TransferAcceptedDto { Accepted = deleted, Skipped = skipped }, $"{deleted} entries deleted");
+ }
+
+ /// Downloads a local file.
+ ///
+ /// Supports HTTP range requests, so it can be used directly as a media
+ /// source by a player.
+ ///
+ [HttpGet("download")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public IActionResult Download([FromQuery] string path)
+ {
+ if (!TryResolve(path, out var absolute, out _, out var error))
+ return BadRequestResult(error!);
+
+ if (!System.IO.File.Exists(absolute))
+ return NotFoundResult("File not found", ApiErrorCodes.FileNotFound);
+
+ var stream = new FileStream(absolute, FileMode.Open, FileAccess.Read, FileShare.Read);
+ return File(stream, FileService.getMimeType(Path.GetExtension(absolute)) ?? "application/octet-stream",
+ Path.GetFileName(absolute), enableRangeProcessing: true);
+ }
+
+ /// Uploads a file into the local storage.
+ ///
+ /// Send multipart/form-data with a file part. To then push
+ /// it to Telegram, call POST /api/v1/transfers/uploads with the
+ /// returned path.
+ ///
+ [HttpPost("upload")]
+ [RequestSizeLimit(long.MaxValue)]
+ [RequestFormLimits(MultipartBodyLengthLimit = long.MaxValue)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status201Created)]
+ public async Task Upload(IFormFile file, [FromForm] string? path)
+ {
+ if (file == null || file.Length == 0)
+ return BadRequestResult("A non-empty file part is required");
+
+ if (!TryResolve(path, out var absolute, out var relative, out var error))
+ return BadRequestResult(error!);
+
+ try
+ {
+ Directory.CreateDirectory(absolute);
+ var safeName = Path.GetFileName(file.FileName);
+ var target = Path.Combine(absolute, safeName);
+
+ await using (var fs = System.IO.File.Create(target))
+ await file.CopyToAsync(fs);
+
+ var dto = ApiFileDto.FromLocalFile(new FileInfo(target), Join(relative, safeName), BaseUrl);
+ return StatusCode(StatusCodes.Status201Created, ApiResult.Ok(dto, "File stored"));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error storing an upload under {Path}", path);
+ return ErrorResult("Could not store the file", ex);
+ }
+ }
+
+ /// Empties the streaming/temporary cache folder.
+ ///
+ /// The cache holds files pulled from Telegram for playback. Clearing it
+ /// frees disk space; the next playback re-downloads what it needs.
+ ///
+ [HttpPost("cache/clear")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public IActionResult ClearCache([FromServices] IFileService files)
+ {
+ try
+ {
+ files.cleanTempFolder();
+ return OkEmpty("Temporary cache cleared");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error clearing the temporary cache");
+ return ErrorResult("Could not clear the temporary cache", ex);
+ }
+ }
+
+ private static List BuildBreadcrumbs(string relative)
+ {
+ var crumbs = new List { new() { Name = "Local", Path = "", FolderId = "" } };
+ if (string.IsNullOrEmpty(relative)) return crumbs;
+
+ var acc = string.Empty;
+ foreach (var segment in relative.Split('/', StringSplitOptions.RemoveEmptyEntries))
+ {
+ acc = string.IsNullOrEmpty(acc) ? segment : acc + "/" + segment;
+ crumbs.Add(new ApiBreadcrumbDto { Name = segment, Path = acc, FolderId = acc });
+ }
+ return crumbs;
+ }
+
+ private static string Join(string parent, string name) =>
+ string.IsNullOrEmpty(parent) ? name : parent.TrimEnd('/') + "/" + name;
+
+ ///
+ /// Resolves a client path against the local root, refusing anything that
+ /// escapes it.
+ ///
+ private static bool TryResolve(string? path, out string absolute, out string relative, out string? error)
+ {
+ absolute = string.Empty;
+ relative = string.Empty;
+ error = null;
+
+ var candidate = (path ?? string.Empty).Replace("\\", "/").Trim().TrimStart('/');
+ if (Path.IsPathRooted(candidate))
+ {
+ error = "Only paths relative to the local root are accepted";
+ return false;
+ }
+
+ var root = Path.GetFullPath(FileService.LOCALDIR);
+ var full = Path.GetFullPath(Path.Combine(root, candidate));
+
+ if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase))
+ {
+ error = "The path escapes the local root";
+ return false;
+ }
+
+ absolute = full;
+ relative = candidate.Trim('/');
+ return true;
+ }
+
+ private static List ApplyFilter(List items, string? filter)
+ {
+ if (string.IsNullOrWhiteSpace(filter) || filter.Equals("all", StringComparison.OrdinalIgnoreCase))
+ return items;
+
+ var wanted = filter.Trim().ToLowerInvariant() switch
+ {
+ "audio" => "Audio",
+ "video" => "Video",
+ "photo" or "photos" or "image" or "images" => "Photo",
+ "document" or "documents" or "doc" => "Document",
+ "archive" or "archives" => "Archive",
+ _ => filter
+ };
+
+ return items.Where(i => !i.IsFile || i.Category.Equals(wanted, StringComparison.OrdinalIgnoreCase)).ToList();
+ }
+
+ private static List ApplySort(List items, BrowseQuery query) =>
+ (query.SortBy?.ToLowerInvariant(), query.SortDescending) switch
+ {
+ ("date", true) => items.OrderBy(i => i.IsFile).ThenByDescending(i => i.DateModified).ToList(),
+ ("date", false) => items.OrderBy(i => i.IsFile).ThenBy(i => i.DateModified).ToList(),
+ ("size", true) => items.OrderBy(i => i.IsFile).ThenByDescending(i => i.Size).ToList(),
+ ("size", false) => items.OrderBy(i => i.IsFile).ThenBy(i => i.Size).ToList(),
+ ("type", true) => items.OrderBy(i => i.IsFile).ThenByDescending(i => i.Type).ToList(),
+ ("type", false) => items.OrderBy(i => i.IsFile).ThenBy(i => i.Type).ToList(),
+ (_, true) => items.OrderBy(i => i.IsFile).ThenByDescending(i => i.Name).ToList(),
+ _ => items.OrderBy(i => i.IsFile).ThenBy(i => i.Name).ToList()
+ };
+ }
+}
diff --git a/TelegramDownloader/Controllers/Api/V1/PlaylistsController.cs b/TelegramDownloader/Controllers/Api/V1/PlaylistsController.cs
new file mode 100644
index 0000000..42e82b8
--- /dev/null
+++ b/TelegramDownloader/Controllers/Api/V1/PlaylistsController.cs
@@ -0,0 +1,268 @@
+using Microsoft.AspNetCore.Mvc;
+using TelegramDownloader.Data;
+using TelegramDownloader.Data.db;
+using TelegramDownloader.Models;
+using TelegramDownloader.Models.Api;
+
+namespace TelegramDownloader.Controllers.Api.V1
+{
+ ///
+ /// Playlists mixing Telegram-hosted tracks and local files, shared with the
+ /// web player and the audio app.
+ ///
+ /// A track either points at an indexed channel file (channelId +
+ /// fileId ) or at a local file (directUrl ).
+ ///
+ [Route("api/v1/playlists")]
+ [Tags("Playlists")]
+ public class PlaylistsController : ApiV1ControllerBase
+ {
+ private readonly IDbService _db;
+ private readonly IFileService _files;
+ private readonly ILogger _logger;
+
+ public PlaylistsController(IDbService db, IFileService files, ILogger logger)
+ {
+ _db = db;
+ _files = files;
+ _logger = logger;
+ }
+
+ /// Lists every playlist.
+ [HttpGet]
+ [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)]
+ public async Task List([FromQuery] PagedQuery? query = null)
+ {
+ query ??= new PagedQuery();
+ try
+ {
+ var playlists = await _db.GetAllPlaylists() ?? new List();
+ var ordered = (query.SortBy?.ToLowerInvariant(), query.SortDescending) switch
+ {
+ ("date", true) => playlists.OrderByDescending(p => p.DateModified).ToList(),
+ ("date", false) => playlists.OrderBy(p => p.DateModified).ToList(),
+ ("tracks", true) => playlists.OrderByDescending(p => p.TrackCount).ToList(),
+ ("tracks", false) => playlists.OrderBy(p => p.TrackCount).ToList(),
+ (_, true) => playlists.OrderByDescending(p => p.Name).ToList(),
+ _ => playlists.OrderBy(p => p.Name).ToList()
+ };
+
+ var (items, page) = Paginate(ordered, query);
+ return OkPaged(items, page);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error listing playlists");
+ return ErrorResult("Could not list the playlists", ex);
+ }
+ }
+
+ /// One playlist with all of its tracks, in order.
+ [HttpGet("{id}")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public async Task Get(string id)
+ {
+ try
+ {
+ var playlist = await _db.GetPlaylistById(id);
+ if (playlist == null)
+ return NotFoundResult("Playlist not found", ApiErrorCodes.PlaylistNotFound);
+
+ playlist.Tracks = (playlist.Tracks ?? new List()).OrderBy(t => t.Order).ToList();
+ return OkResult(playlist);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error reading playlist {Id}", id);
+ return ErrorResult("Could not read the playlist", ex);
+ }
+ }
+
+ /// Creates a playlist.
+ [HttpPost]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status201Created)]
+ public async Task Create([FromBody] PlaylistModel playlist)
+ {
+ if (playlist == null || string.IsNullOrWhiteSpace(playlist.Name))
+ return BadRequestResult("A playlist name is required");
+
+ try
+ {
+ playlist.DateCreated = DateTime.Now;
+ playlist.DateModified = DateTime.Now;
+ var created = await _db.CreatePlaylist(playlist);
+ return StatusCode(StatusCodes.Status201Created, ApiResult.Ok(created, "Playlist created"));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error creating playlist {Name}", playlist.Name);
+ return ErrorResult("Could not create the playlist", ex);
+ }
+ }
+
+ /// Updates a playlist's name, description or full track list.
+ [HttpPut("{id}")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public async Task Update(string id, [FromBody] PlaylistModel playlist)
+ {
+ if (playlist == null)
+ return BadRequestResult("A playlist body is required");
+
+ try
+ {
+ var existing = await _db.GetPlaylistById(id);
+ if (existing == null)
+ return NotFoundResult("Playlist not found", ApiErrorCodes.PlaylistNotFound);
+
+ existing.Name = string.IsNullOrWhiteSpace(playlist.Name) ? existing.Name : playlist.Name;
+ existing.Description = playlist.Description ?? existing.Description;
+ if (playlist.Tracks != null && playlist.Tracks.Count > 0)
+ existing.Tracks = playlist.Tracks;
+ existing.DateModified = DateTime.Now;
+
+ await _db.UpdatePlaylist(existing);
+ return OkResult(existing, "Playlist updated");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error updating playlist {Id}", id);
+ return ErrorResult("Could not update the playlist", ex);
+ }
+ }
+
+ /// Deletes a playlist.
+ [HttpDelete("{id}")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task Delete(string id)
+ {
+ try
+ {
+ await _db.DeletePlaylist(id);
+ return OkEmpty("Playlist deleted");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error deleting playlist {Id}", id);
+ return ErrorResult("Could not delete the playlist", ex);
+ }
+ }
+
+ /// Appends a track to a playlist.
+ [HttpPost("{id}/tracks")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public async Task AddTrack(string id, [FromBody] PlaylistTrackModel track)
+ {
+ if (track == null || (string.IsNullOrWhiteSpace(track.FileId) && string.IsNullOrWhiteSpace(track.DirectUrl)))
+ return BadRequestResult("A track needs either a fileId or a directUrl");
+
+ try
+ {
+ var playlist = await _db.GetPlaylistById(id);
+ if (playlist == null)
+ return NotFoundResult("Playlist not found", ApiErrorCodes.PlaylistNotFound);
+
+ track.Order = (playlist.Tracks?.Count ?? 0);
+ track.DateAdded = DateTime.Now;
+ await _db.AddTrackToPlaylist(id, track);
+
+ return OkResult(await _db.GetPlaylistById(id) ?? playlist, "Track added");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error adding a track to playlist {Id}", id);
+ return ErrorResult("Could not add the track", ex);
+ }
+ }
+
+ /// Removes a track from a playlist.
+ [HttpDelete("{id}/tracks/{fileId}")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task RemoveTrack(string id, string fileId)
+ {
+ try
+ {
+ var playlist = await _db.GetPlaylistById(id);
+ if (playlist == null)
+ return NotFoundResult("Playlist not found", ApiErrorCodes.PlaylistNotFound);
+
+ await _db.RemoveTrackFromPlaylist(id, fileId);
+ return OkResult(await _db.GetPlaylistById(id) ?? playlist, "Track removed");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error removing track {FileId} from playlist {Id}", fileId, id);
+ return ErrorResult("Could not remove the track", ex);
+ }
+ }
+
+ /// Reorders the tracks of a playlist.
+ /// Playlist id.
+ /// File ids in the desired order.
+ [HttpPut("{id}/tracks/order")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task Reorder(string id, [FromBody] List orderedFileIds)
+ {
+ if (orderedFileIds == null || orderedFileIds.Count == 0)
+ return BadRequestResult("An ordered list of file ids is required");
+
+ try
+ {
+ var playlist = await _db.GetPlaylistById(id);
+ if (playlist == null)
+ return NotFoundResult("Playlist not found", ApiErrorCodes.PlaylistNotFound);
+
+ await _db.ReorderPlaylistTracks(id, orderedFileIds);
+ return OkResult(await _db.GetPlaylistById(id) ?? playlist, "Playlist reordered");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error reordering playlist {Id}", id);
+ return ErrorResult("Could not reorder the playlist", ex);
+ }
+ }
+
+ /// Downloads every track of a playlist to the local storage.
+ ///
+ /// Runs in the background and reports on the transfers hub like
+ /// any other download.
+ ///
+ /// Playlist id.
+ /// Folder relative to the local root.
+ [HttpPost("{id}/download")]
+ [RequireTelegramSession]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status202Accepted)]
+ public async Task Download(string id, [FromQuery] string? destinationFolder)
+ {
+ try
+ {
+ var playlist = await _db.GetPlaylistById(id);
+ if (playlist == null)
+ return NotFoundResult("Playlist not found", ApiErrorCodes.PlaylistNotFound);
+
+ var folder = string.IsNullOrWhiteSpace(destinationFolder) ? playlist.Name : destinationFolder;
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await _files.DownloadPlaylistToLocal(playlist, folder);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Background download of playlist {Id} failed", id);
+ }
+ });
+
+ return Accepted(ApiResult.Done("Playlist download started"));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error starting the download of playlist {Id}", id);
+ return ErrorResult("Could not start the playlist download", ex);
+ }
+ }
+ }
+}
diff --git a/TelegramDownloader/Controllers/Api/V1/RequireTelegramSessionAttribute.cs b/TelegramDownloader/Controllers/Api/V1/RequireTelegramSessionAttribute.cs
new file mode 100644
index 0000000..c2d1588
--- /dev/null
+++ b/TelegramDownloader/Controllers/Api/V1/RequireTelegramSessionAttribute.cs
@@ -0,0 +1,57 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using TelegramDownloader.Data;
+using TelegramDownloader.Models.Api;
+
+namespace TelegramDownloader.Controllers.Api.V1
+{
+ ///
+ /// Rejects the request with 401 not_logged_in (or 503
+ /// setup_required ) when no Telegram session is active.
+ ///
+ /// The API key protects the endpoint; this attribute protects the operation,
+ /// which additionally needs a signed-in Telegram account.
+ ///
+ [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
+ public class RequireTelegramSessionAttribute : Attribute, IAsyncActionFilter
+ {
+ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
+ {
+ var telegram = context.HttpContext.RequestServices.GetService();
+
+ if (telegram == null || !telegram.IsConfigured)
+ {
+ context.Result = new ObjectResult(ApiResult.Fail(
+ ApiErrorCodes.SetupRequired,
+ "The application has not been configured yet. See GET /api/v1/system/setup."))
+ {
+ StatusCode = StatusCodes.Status503ServiceUnavailable
+ };
+ return;
+ }
+
+ bool loggedIn;
+ try
+ {
+ loggedIn = telegram.checkUserLogin();
+ }
+ catch
+ {
+ loggedIn = false;
+ }
+
+ if (!loggedIn)
+ {
+ context.Result = new ObjectResult(ApiResult.Fail(
+ ApiErrorCodes.NotLoggedIn,
+ "No Telegram session is active. Sign in through /api/v1/auth."))
+ {
+ StatusCode = StatusCodes.Status401Unauthorized
+ };
+ return;
+ }
+
+ await next();
+ }
+ }
+}
diff --git a/TelegramDownloader/Controllers/Api/V1/SharesController.cs b/TelegramDownloader/Controllers/Api/V1/SharesController.cs
new file mode 100644
index 0000000..f5938c1
--- /dev/null
+++ b/TelegramDownloader/Controllers/Api/V1/SharesController.cs
@@ -0,0 +1,233 @@
+using Microsoft.AspNetCore.Mvc;
+using TelegramDownloader.Data;
+using TelegramDownloader.Data.db;
+using TelegramDownloader.Models;
+using TelegramDownloader.Models.Api;
+
+namespace TelegramDownloader.Controllers.Api.V1
+{
+ ///
+ /// Sharing a folder of a channel with another TelegramFileManager instance,
+ /// and importing what somebody else shared.
+ ///
+ /// A share is a portable description of the files (names, sizes, Telegram
+ /// message ids) plus an invitation to the channel that holds them. The
+ /// bytes stay in Telegram: importing a share only rebuilds the index and,
+ /// when needed, joins the channel.
+ ///
+ [Route("api/v1/shares")]
+ [Tags("Shares")]
+ [RequireTelegramSession]
+ public class SharesController : ApiV1ControllerBase
+ {
+ private readonly IFileService _files;
+ private readonly IDbService _db;
+ private readonly ITelegramService _telegram;
+ private readonly ILogger _logger;
+
+ public SharesController(
+ IFileService files,
+ IDbService db,
+ ITelegramService telegram,
+ ILogger logger)
+ {
+ _files = files;
+ _db = db;
+ _telegram = telegram;
+ _logger = logger;
+ }
+
+ /// Lists the shared collections stored on this server.
+ [HttpGet]
+ [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)]
+ public async Task List([FromQuery] string? filter, [FromQuery] PagedQuery? query = null)
+ {
+ query ??= new PagedQuery();
+ try
+ {
+ var list = await _db.getSharedInfoList(filter: filter) ?? new List();
+ var items = list.Select(ToDto).OrderByDescending(s => s.DateModified).ToList();
+ var (page, info) = Paginate(items, query);
+ return OkPaged(page, info);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error listing shared collections");
+ return ErrorResult("Could not list the shared collections", ex);
+ }
+ }
+
+ /// Details of one shared collection.
+ [HttpGet("{id}")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public async Task Get(string id)
+ {
+ try
+ {
+ var info = await _files.GetSharedInfoById(id);
+ if (info == null)
+ return NotFoundResult("Shared collection not found");
+ return OkResult(ToDto(info));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error reading shared collection {Id}", id);
+ return ErrorResult("Could not read the shared collection", ex);
+ }
+ }
+
+ /// Builds a share payload for a channel folder.
+ ///
+ /// The returned document is what another instance passes to
+ /// POST /api/v1/shares/import . It contains the file descriptors
+ /// and, when available, an invitation link to the channel, so the
+ /// receiving account can join and read the files.
+ ///
+ /// Channel that holds the files.
+ /// Folder to share. Omit to share the whole channel.
+ /// Label for the share.
+ [HttpGet("export")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task Export(
+ [FromQuery] string channelId,
+ [FromQuery] string? folderId,
+ [FromQuery] string? name)
+ {
+ if (string.IsNullOrWhiteSpace(channelId))
+ return BadRequestResult("A channel id is required");
+
+ try
+ {
+ var share = new ShareFilesModel
+ {
+ id = channelId,
+ name = name,
+ fileName = name,
+ files = await _files.ShareFile(channelId, folderId)
+ };
+
+ try
+ {
+ share.chatName = _telegram.getChatName(Convert.ToInt64(channelId));
+ share.invitation = await _telegram.getInvitationHash(Convert.ToInt64(channelId));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Could not attach an invitation to the share of channel {ChannelId}", channelId);
+ }
+
+ return OkResult(share);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error exporting a share of channel {ChannelId}", channelId);
+ return ErrorResult("Could not export the share", ex);
+ }
+ }
+
+ /// Imports a share published by another instance.
+ ///
+ /// The account joins the channel when it is not a member yet and the
+ /// share carries an invitation hash. Import runs in the background; the
+ /// imported files then appear under the shared collections and can be
+ /// downloaded with POST /api/v1/transfers/downloads using
+ /// sharedCollectionId .
+ ///
+ [HttpPost("import")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status202Accepted)]
+ public IActionResult Import([FromBody] ImportSharedRequest request)
+ {
+ if (request?.Share == null || string.IsNullOrWhiteSpace(request.Share.id))
+ return BadRequestResult("A share payload with a channel id is required");
+
+ var progress = new GenericNotificationProgressModel();
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await _files.importSharedData(request.Share, progress);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Background import of a share failed");
+ }
+ });
+
+ return Accepted(ApiResult.Done("Share import started"));
+ }
+
+ /// Deletes a shared collection from this server.
+ [HttpDelete("{id}")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public async Task Delete(string id)
+ {
+ try
+ {
+ var info = await _files.GetSharedInfoById(id);
+ if (info == null)
+ return NotFoundResult("Shared collection not found");
+
+ await _files.DeleteShared(id, info.CollectionId);
+ return OkEmpty("Shared collection deleted");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error deleting shared collection {Id}", id);
+ return ErrorResult("Could not delete the shared collection", ex);
+ }
+ }
+
+ /// Exports a channel folder as Emby/Kodi .strm files.
+ ///
+ /// Each .strm holds a URL that streams the file straight from
+ /// Telegram, so a media server can present the whole library without
+ /// storing anything. The URL flavour depends on
+ /// strmStreamingMode in the configuration.
+ ///
+ /// With destinationFolder the files are written under the server
+ /// local root; without it, the response carries a relative URL to a zip
+ /// archive.
+ ///
+ [HttpPost("strm")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task CreateStrm([FromQuery] string channelId, [FromBody] CreateStrmRequest request)
+ {
+ if (string.IsNullOrWhiteSpace(channelId))
+ return BadRequestResult("A channel id is required");
+
+ request ??= new CreateStrmRequest();
+ var host = string.IsNullOrWhiteSpace(request.Host) ? BaseUrl : request.Host;
+ var path = string.IsNullOrWhiteSpace(request.Path) ? "/" : request.Path;
+
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(request.DestinationFolder))
+ {
+ await _files.CreateStrmFilesToLocal(path, channelId, host, request.DestinationFolder);
+ return OkResult(request.DestinationFolder, "STRM files written to the local storage");
+ }
+
+ var result = await _files.CreateStrmFiles(path, channelId, host);
+ return OkResult(result, "STRM archive created");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error creating STRM files for channel {ChannelId}", channelId);
+ return ErrorResult("Could not create the STRM files", ex);
+ }
+ }
+
+ private static SharedCollectionDto ToDto(BsonSharedInfoModel m) => new()
+ {
+ Id = m.Id,
+ Name = m.Name,
+ Description = m.Description,
+ ChannelId = m.ChannelId,
+ CollectionId = m.CollectionId,
+ DateCreated = m.DateCreated,
+ DateModified = m.DateModified
+ };
+ }
+}
diff --git a/TelegramDownloader/Controllers/Api/V1/SystemController.cs b/TelegramDownloader/Controllers/Api/V1/SystemController.cs
new file mode 100644
index 0000000..5276c4e
--- /dev/null
+++ b/TelegramDownloader/Controllers/Api/V1/SystemController.cs
@@ -0,0 +1,321 @@
+using Microsoft.AspNetCore.Mvc;
+using TelegramDownloader.Data;
+using TelegramDownloader.Data.db;
+using TelegramDownloader.Models;
+using TelegramDownloader.Models.Api;
+using TelegramDownloader.Services;
+
+namespace TelegramDownloader.Controllers.Api.V1
+{
+ ///
+ /// Server health, resource usage, application logs and maintenance of the
+ /// channel index databases.
+ ///
+ [Route("api/v1/system")]
+ [Tags("System")]
+ public class SystemController : ApiV1ControllerBase
+ {
+ private readonly ITelegramService _telegram;
+ private readonly ISetupService _setup;
+ private readonly ISystemMetricsService _metrics;
+ private readonly ILogQueryService _logs;
+ private readonly IDbService _db;
+ private readonly ILogger _logger;
+
+ public SystemController(
+ ITelegramService telegram,
+ ISetupService setup,
+ ISystemMetricsService metrics,
+ ILogQueryService logs,
+ IDbService db,
+ ILogger logger)
+ {
+ _telegram = telegram;
+ _setup = setup;
+ _metrics = metrics;
+ _logs = logs;
+ _db = db;
+ _logger = logger;
+ }
+
+ /// Liveness probe.
+ ///
+ /// Always answers 200 when the process is up. Use it to verify
+ /// connectivity and, when an API key is configured, that the key works.
+ ///
+ [HttpGet("ping")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public IActionResult Ping() => OkResult("pong");
+
+ /// Server identity, versions and readiness.
+ ///
+ /// The natural first call of a mobile client: it reports whether setup
+ /// is complete, whether a Telegram session is active, and the path of
+ /// the SignalR hub to connect to.
+ ///
+ [HttpGet("info")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task Info()
+ {
+ var dto = new ServerInfoDto
+ {
+ Version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown",
+ TelegramConfigured = _telegram.IsConfigured,
+ RequiresApiKey = !string.IsNullOrEmpty(GeneralConfigStatic.tlconfig?.mobile_api_key),
+ WebDavRunning = GeneralConfigStatic.config?.webDav?.webDavService?.IsRunning ?? false
+ };
+
+ try
+ {
+ dto.TelegramAuthenticated = _telegram.IsConfigured && _telegram.checkUserLogin();
+ }
+ catch
+ {
+ dto.TelegramAuthenticated = false;
+ }
+
+ try
+ {
+ var status = await _setup.GetSetupStatusAsync();
+ dto.SetupComplete = status.CurrentStep == SetupStep.Complete;
+ dto.MongoConnected = status.MongoDbConnected;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Could not read the setup status");
+ }
+
+ return OkResult(dto);
+ }
+
+ /// Progress of the first-run wizard.
+ [HttpGet("setup")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task Setup()
+ {
+ try
+ {
+ var status = await _setup.GetSetupStatusAsync();
+ return OkResult(new SetupStatusDto
+ {
+ CurrentStep = status.CurrentStep.ToString(),
+ MongoDbConfigured = status.MongoDbConfigured,
+ MongoDbConnected = status.MongoDbConnected,
+ TelegramConfigured = status.TelegramConfigured,
+ MongoDbError = status.MongoDbError
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error reading the setup status");
+ return ErrorResult("Could not read the setup status", ex);
+ }
+ }
+
+ /// CPU, memory and disk usage of the server.
+ [HttpGet("metrics")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task Metrics()
+ {
+ try
+ {
+ var metrics = await _metrics.GetMetricsAsync();
+ return OkResult(SystemMetricsDto.From(metrics));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error reading system metrics");
+ return ErrorResult("Could not read the system metrics", ex);
+ }
+ }
+
+ /// Queries the application logs.
+ ///
+ /// Logs live in the TFM_Logs MongoDB database. When MongoDB is
+ /// not configured the endpoint answers 503 .
+ ///
+ [HttpGet("logs")]
+ [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status503ServiceUnavailable)]
+ public async Task Logs([FromQuery] LogQuery query)
+ {
+ if (!_logs.IsInitialized)
+ return UnavailableResult("The log store is not available");
+
+ try
+ {
+ var result = await _logs.GetLogs(new LogQueryRequest
+ {
+ Page = query.Page,
+ PageSize = query.PageSize,
+ FromDate = query.FromDate,
+ ToDate = query.ToDate,
+ Level = query.Level,
+ Logger = query.Logger,
+ Version = query.Version,
+ SearchText = query.Search,
+ DescendingOrder = !query.SortDescending ? true : query.SortDescending
+ });
+
+ var items = (result.Logs ?? new List()).Select(l => new LogEntryDto
+ {
+ Id = l.Id ?? string.Empty,
+ Timestamp = l.Timestamp,
+ Level = l.Level,
+ Message = l.Message,
+ Logger = l.Logger,
+ Exception = l.Exception,
+ Version = l.Version
+ }).ToList();
+
+ return OkPaged(items, PageInfo.Create(result.Page, result.PageSize, result.TotalCount));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error querying the logs");
+ return ErrorResult("Could not query the logs", ex);
+ }
+ }
+
+ /// Distinct logger names present in the log store.
+ [HttpGet("logs/loggers")]
+ [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)]
+ public async Task LogLoggers()
+ {
+ if (!_logs.IsInitialized) return UnavailableResult("The log store is not available");
+ return OkResult(await _logs.GetLoggerNames());
+ }
+
+ /// Application versions present in the log store.
+ [HttpGet("logs/versions")]
+ [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)]
+ public async Task LogVersions()
+ {
+ if (!_logs.IsInitialized) return UnavailableResult("The log store is not available");
+ return OkResult(await _logs.GetVersions());
+ }
+
+ /// Deletes log records older than the given number of days.
+ [HttpDelete("logs")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task DeleteLogs([FromQuery] int daysToKeep = 30)
+ {
+ if (!_logs.IsInitialized) return UnavailableResult("The log store is not available");
+ if (daysToKeep < 0) return BadRequestResult("daysToKeep cannot be negative");
+
+ var deleted = await _logs.DeleteOldLogs(daysToKeep);
+ return OkResult(deleted, $"{deleted} log entries deleted");
+ }
+
+ /// Lists the channel index databases and their size.
+ [HttpGet("databases")]
+ [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)]
+ public async Task Databases()
+ {
+ try
+ {
+ var names = await _db.GetAllChannelDatabaseNames() ?? new List();
+ var result = new List();
+
+ foreach (var name in names)
+ {
+ var dto = new DatabaseStatsDto { ChannelId = name };
+ try
+ {
+ var stats = await _db.GetDatabaseStats(name);
+ dto.SizeInBytes = stats.SizeInBytes;
+ dto.SizeText = HelperService.SizeSuffix(stats.SizeInBytes);
+ dto.DocumentCount = stats.DocumentCount;
+ dto.CreatedAt = stats.CreatedAt;
+ dto.LastModified = stats.LastModified;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Could not read stats of database {Name}", name);
+ }
+
+ if (long.TryParse(name, out var channelId))
+ {
+ try { dto.ChannelName = _telegram.getChatName(channelId); }
+ catch { /* the account may have left the channel */ }
+ }
+
+ result.Add(dto);
+ }
+
+ return OkResult(result);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error listing the channel databases");
+ return ErrorResult("Could not list the channel databases", ex);
+ }
+ }
+
+ /// Checks a channel index for broken folder paths.
+ ///
+ /// Older versions could store inconsistent FilterPath /FilterId
+ /// values, which shows up as folders that look empty. Analyse first, then
+ /// repair with the endpoint below.
+ ///
+ [HttpGet("databases/{channelId}/analysis")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task AnalyzeDatabase(string channelId)
+ {
+ try
+ {
+ var result = await _db.AnalyzeFilterPaths(channelId);
+ return OkResult(new PathAnalysisDto
+ {
+ DatabaseName = result.DatabaseName,
+ TotalItems = result.TotalItems,
+ ItemsWithIssues = result.ItemsWithIssues,
+ FilterPathIssues = result.FilterPathIssues,
+ FilterIdIssues = result.FilterIdIssues,
+ FilePathIssues = result.FilePathIssues,
+ HasIssues = result.HasIssues,
+ Error = result.Error
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error analysing database {ChannelId}", channelId);
+ return ErrorResult("Could not analyse the channel database", ex);
+ }
+ }
+
+ /// Repairs the broken folder paths of a channel index.
+ [HttpPost("databases/{channelId}/repair")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task RepairDatabase(string channelId)
+ {
+ try
+ {
+ var repaired = await _db.RepairFilterPaths(channelId);
+ return OkResult(repaired, $"{repaired} entries repaired");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error repairing database {ChannelId}", channelId);
+ return ErrorResult("Could not repair the channel database", ex);
+ }
+ }
+
+ /// Deletes persisted tasks that are older than the configured limit.
+ [HttpPost("maintenance/cleanup-tasks")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public async Task CleanupTasks([FromServices] ITaskPersistenceService persistence)
+ {
+ try
+ {
+ await persistence.CleanupStaleTasks();
+ return OkEmpty("Stale tasks cleaned up");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error cleaning up stale tasks");
+ return ErrorResult("Could not clean up the stale tasks", ex);
+ }
+ }
+ }
+}
diff --git a/TelegramDownloader/Controllers/Api/V1/TransfersController.cs b/TelegramDownloader/Controllers/Api/V1/TransfersController.cs
new file mode 100644
index 0000000..f946a19
--- /dev/null
+++ b/TelegramDownloader/Controllers/Api/V1/TransfersController.cs
@@ -0,0 +1,574 @@
+using Microsoft.AspNetCore.Mvc;
+using Syncfusion.Blazor.FileManager;
+using TelegramDownloader.Data;
+using TelegramDownloader.Data.db;
+using TelegramDownloader.Models;
+using TelegramDownloader.Models.Api;
+using TelegramDownloader.Services;
+using TelegramDownloader.Services.Api;
+
+namespace TelegramDownloader.Controllers.Api.V1
+{
+ ///
+ /// Everything that moves bytes: pulling files out of Telegram onto the
+ /// server, pushing server files into Telegram, and controlling the queue.
+ ///
+ /// These endpoints only enqueue work and return immediately. Progress is
+ /// published on the /hubs/transfers SignalR hub; the snapshot
+ /// endpoint below returns the very same payload for clients that prefer
+ /// polling or need an initial state.
+ ///
+ [Route("api/v1/transfers")]
+ [Tags("Transfers")]
+ public class TransfersController : ApiV1ControllerBase
+ {
+ private readonly TransactionInfoService _tis;
+ private readonly IFileService _files;
+ private readonly IDbService _db;
+ private readonly ITelegramService _telegram;
+ private readonly ITaskPersistenceService _persistence;
+ private readonly ILogger _logger;
+
+ public TransfersController(
+ TransactionInfoService tis,
+ IFileService files,
+ IDbService db,
+ ITelegramService telegram,
+ ITaskPersistenceService persistence,
+ ILogger logger)
+ {
+ _tis = tis;
+ _files = files;
+ _db = db;
+ _telegram = telegram;
+ _persistence = persistence;
+ _logger = logger;
+ }
+
+ /// Full snapshot of active and queued transfers.
+ ///
+ /// Identical payload to the TransfersSnapshot hub message. Prefer
+ /// the hub for live updates and use this once at startup, or when a
+ /// client reconnects and wants to resynchronise.
+ ///
+ [HttpGet]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public IActionResult Snapshot() => OkResult(TransferSnapshotBuilder.BuildSnapshot(_tis));
+
+ /// Counters and current transfer speeds.
+ /// Identical payload to the TransferSummary hub message.
+ [HttpGet("summary")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public IActionResult Summary() => OkResult(TransferSnapshotBuilder.BuildSummary(_tis));
+
+ /// Retained download/upload speed samples, for charts.
+ [HttpGet("speed-history")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ public IActionResult SpeedHistory() => OkResult(TransferSnapshotBuilder.BuildSpeedHistory(_tis));
+
+ /// Lists downloads.
+ /// List the queue instead of the running downloads.
+ /// Paging.
+ [HttpGet("downloads")]
+ [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)]
+ public IActionResult Downloads([FromQuery] bool queued = false, [FromQuery] PagedQuery? query = null)
+ {
+ query ??= new PagedQuery();
+ var source = (queued ? _tis.pendingDownloadModels : _tis.downloadModels)
+ .ToList()
+ .Select(d => TransferDto.FromDownload(d, queued))
+ .ToList();
+ var (items, page) = Paginate(source, query);
+ return OkPaged(items, page);
+ }
+
+ /// Lists uploads.
+ /// List the queue instead of the running uploads.
+ /// Paging.
+ [HttpGet("uploads")]
+ [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)]
+ public IActionResult Uploads([FromQuery] bool queued = false, [FromQuery] PagedQuery? query = null)
+ {
+ query ??= new PagedQuery();
+ var source = (queued ? _tis.pendingUploadModels : _tis.uploadModels)
+ .ToList()
+ .Select(u => TransferDto.FromUpload(u, queued))
+ .ToList();
+ var (items, page) = Paginate(source, query);
+ return OkPaged(items, page);
+ }
+
+ /// Lists batch tasks (a folder download or upload as a whole).
+ [HttpGet("tasks")]
+ [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)]
+ public IActionResult Tasks([FromQuery] PagedQuery? query = null)
+ {
+ query ??= new PagedQuery();
+ var source = _tis.infoDownloadTaksModel
+ .ToList()
+ .OrderBy(t => t.creationDate)
+ .Select(TransferDto.FromBatch)
+ .ToList();
+ var (items, page) = Paginate(source, query);
+ return OkPaged(items, page);
+ }
+
+ /// Details of a single transfer, whatever its kind.
+ [HttpGet("{id}")]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)]
+ public IActionResult Get(string id)
+ {
+ if (!TransferSnapshotBuilder.TryFind(_tis, id, out var download, out var upload, out var task))
+ return NotFoundResult("Transfer not found", ApiErrorCodes.TaskNotFound);
+
+ if (download != null)
+ return OkResult(TransferDto.FromDownload(download, _tis.pendingDownloadModels.Contains(download)));
+ if (upload != null)
+ return OkResult(TransferDto.FromUpload(upload, _tis.pendingUploadModels.Contains(upload)));
+ return OkResult(TransferDto.FromBatch(task!));
+ }
+
+ /// Downloads channel files onto the server.
+ ///
+ /// Accepts file ids and folder ids; folders are pulled recursively. The
+ /// call returns as soon as the work is queued, and each file then shows
+ /// up as its own entry on the transfers hub.
+ ///
+ /// targetPath is relative to the server local root. When omitted,
+ /// the channel folder structure is reproduced under it.
+ ///
+ [HttpPost("downloads")]
+ [RequireTelegramSession]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status202Accepted)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status400BadRequest)]
+ public async Task StartDownload([FromBody] StartDownloadRequest request)
+ {
+ if (request == null || string.IsNullOrWhiteSpace(request.ChannelId))
+ return BadRequestResult("A channel id is required");
+ if (request.FileIds == null || request.FileIds.Count == 0)
+ return BadRequestResult("At least one file id is required");
+
+ try
+ {
+ var dbName = string.IsNullOrEmpty(request.SharedCollectionId)
+ ? request.ChannelId
+ : DbService.SHARED_DB_NAME;
+
+ var contents = new List();
+ var skipped = new List();
+
+ foreach (var id in request.FileIds)
+ {
+ var entry = string.IsNullOrEmpty(request.SharedCollectionId)
+ ? await _db.getFileById(request.ChannelId, id)
+ : await _db.getFileById(dbName, id, request.SharedCollectionId);
+
+ if (entry == null) skipped.Add(id);
+ else contents.Add(entry.toFileManagerContent());
+ }
+
+ if (contents.Count == 0)
+ return BadRequestResult("None of the supplied ids could be resolved", ApiErrorCodes.FileNotFound);
+
+ var targetPath = string.IsNullOrWhiteSpace(request.TargetPath) ? null : request.TargetPath;
+
+ // The download pipeline is long running; hand it off so the
+ // client is not blocked while files stream in.
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await _files.downloadFile(
+ dbName,
+ contents,
+ targetPath,
+ request.SharedCollectionId,
+ string.IsNullOrEmpty(request.SharedCollectionId) ? null : request.ChannelId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Background download from channel {ChannelId} failed", request.ChannelId);
+ }
+ });
+
+ return Accepted(ApiResult.Ok(
+ new TransferAcceptedDto { Accepted = contents.Count, Skipped = skipped },
+ "Download queued"));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error queuing a download from channel {ChannelId}", request.ChannelId);
+ return ErrorResult("Could not queue the download", ex);
+ }
+ }
+
+ /// Uploads server files into a channel.
+ ///
+ /// localPaths are relative to the server local root; folders are
+ /// pushed recursively. The whole request becomes one batch task, visible
+ /// under tasks in the snapshot, which in turn spawns one upload
+ /// entry per file.
+ ///
+ [HttpPost("uploads")]
+ [RequireTelegramSession]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status202Accepted)]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status400BadRequest)]
+ public async Task StartUpload([FromBody] StartUploadRequest request)
+ {
+ if (request == null || string.IsNullOrWhiteSpace(request.ChannelId))
+ return BadRequestResult("A channel id is required");
+ if (request.LocalPaths == null || request.LocalPaths.Count == 0)
+ return BadRequestResult("At least one local path is required");
+
+ try
+ {
+ var contents = new List();
+ var skipped = new List();
+
+ foreach (var relative in request.LocalPaths)
+ {
+ var content = BuildLocalContent(relative);
+ if (content == null) skipped.Add(relative);
+ else contents.Add(content);
+ }
+
+ if (contents.Count == 0)
+ return BadRequestResult("None of the supplied paths exist under the local root", ApiErrorCodes.FileNotFound);
+
+ var targetPath = ChannelFolderResolver.NormalizeFolderPath(request.TargetPath);
+ await _files.AddUploadFileFromServer(request.ChannelId, targetPath, contents);
+
+ var task = _tis.infoDownloadTaksModel.LastOrDefault(t => t.isUpload);
+ return Accepted(ApiResult.Ok(
+ new TransferAcceptedDto
+ {
+ Accepted = contents.Count,
+ Skipped = skipped,
+ TaskId = task?._internalId
+ },
+ "Upload queued"));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error queuing an upload to channel {ChannelId}", request.ChannelId);
+ return ErrorResult("Could not queue the upload", ex);
+ }
+ }
+
+ /// Downloads the media attached to raw Telegram messages.
+ ///
+ /// Works on any chat, indexed or not: this is how the web UI saves a
+ /// file straight from the message list.
+ ///
+ [HttpPost("messages")]
+ [RequireTelegramSession]
+ [ProducesResponseType(typeof(ApiResult), StatusCodes.Status202Accepted)]
+ public async Task DownloadMessages([FromBody] DownloadMessagesRequest request)
+ {
+ if (request == null || request.MessageIds == null || request.MessageIds.Count == 0)
+ return BadRequestResult("At least one message id is required");
+
+ var accepted = 0;
+ var skipped = new List();
+
+ foreach (var messageId in request.MessageIds)
+ {
+ try
+ {
+ var message = await _telegram.getMessageFile(request.ChatId.ToString(), messageId);
+ if (message == null)
+ {
+ skipped.Add(messageId.ToString());
+ continue;
+ }
+
+ var chatMessage = new ChatMessages { message = message, isDocument = true };
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await _files.DownloadFileFromChat(chatMessage, null, request.TargetPath, null);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Download of message {MessageId} failed", messageId);
+ }
+ });
+ accepted++;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Could not resolve message {MessageId} of chat {ChatId}", messageId, request.ChatId);
+ skipped.Add(messageId.ToString());
+ }
+ }
+
+ return Accepted(ApiResult.Ok(
+ new TransferAcceptedDto { Accepted = accepted, Skipped = skipped },
+ "Message downloads queued"));
+ }
+
+ ///