diff --git a/TelegramDownloader/Configuration/config.a.json b/TelegramDownloader/Configuration/config.a.json new file mode 100644 index 0000000..f407ea8 --- /dev/null +++ b/TelegramDownloader/Configuration/config.a.json @@ -0,0 +1,6 @@ +{ + "api_id": "62071", + "hash_id": "2204b5c6075aeb2e1cdc81e68e2f9256", + "mongo_connection_string": "mongodb://mateo:mateoMongo@192.168.0.22:27017", + "avoid_checking_certificate": true +} \ No newline at end of file diff --git a/TelegramDownloader/Controllers/FileController.cs b/TelegramDownloader/Controllers/FileController.cs index dfe6bdc..895d357 100644 --- a/TelegramDownloader/Controllers/FileController.cs +++ b/TelegramDownloader/Controllers/FileController.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Http.Features; +#nullable disable +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc; using MongoDB.Driver; @@ -526,8 +527,8 @@ public async Task GetFileStream(string idChannel, string idFile, Response.StatusCode = StatusCodes.Status206PartialContent; // StatusCodes.Status206PartialContent; Response.ContentType = mimeType; Response.Headers["Content-Disposition"] = $"inline; filename=\"{HttpUtility.UrlEncode(fileName)}\""; - Response.Headers.Add("Content-Range", $"bytes {(initialFrom >= totalLength ? totalLength - 1 : initialFrom)}-{(initialFrom >= totalLength ? totalLength - 1 : initialFrom + Response.ContentLength - 1)}/{totalLength}"); - Response.Headers.Add("Accept-Ranges", "bytes"); + Response.Headers["Content-Range"] = $"bytes {(initialFrom >= totalLength ? totalLength - 1 : initialFrom)}-{(initialFrom >= totalLength ? totalLength - 1 : initialFrom + Response.ContentLength - 1)}/{totalLength}"; + Response.Headers["Accept-Ranges"] = "bytes"; //if (Request.Headers.ContainsKey("Range")) //{ diff --git a/TelegramDownloader/Data/FileService.cs b/TelegramDownloader/Data/FileService.cs index 5d6098c..9cd1395 100644 --- a/TelegramDownloader/Data/FileService.cs +++ b/TelegramDownloader/Data/FileService.cs @@ -1,4 +1,5 @@ -using BlazorBootstrap; +#nullable disable +using BlazorBootstrap; using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.StaticFiles; using MongoDB.Driver; @@ -873,7 +874,7 @@ public virtual async Task downloadFromTelegram(string dbName, int messageId, str try { model.channelName = _ts.getChatName(Convert.ToInt64(dbName)); - } catch(Exception ex) { + } catch { model.channelName = "Public or Shared"; } var tcs = new TaskCompletionSource(); @@ -1280,10 +1281,10 @@ public async Task CreateStrmFiles(string path, string dbName, string hos { Directory.Delete(basePath, true); } - catch (Exception ex) + catch { } - + Directory.CreateDirectory(basePath); List filesAndFolders = await _db.getAllChildFilesInDirectory(dbName, path); foreach (BsonFileManagerModel file in filesAndFolders) @@ -1314,6 +1315,45 @@ public async Task CreateStrmFiles(string path, string dbName, string hos Directory.Delete(basePath, true); return zipPath; } + + public async Task CreateStrmFilesToLocal(string path, string dbName, string host, string destinationFolder) + { + String folderPathName = Path.GetFileName(path.TrimEnd('/')); + String basePath = Path.Combine(LOCALDIR, destinationFolder, folderPathName); + + // Create destination directory + Directory.CreateDirectory(basePath); + + List filesAndFolders = await _db.getAllChildFilesInDirectory(dbName, path); + foreach (BsonFileManagerModel file in filesAndFolders) + { + String filePath = Path.Combine(basePath, file.FilePath.Substring(path.Length)); + if (file.IsFile) + { + if (FileExtensionTypeTest.isVideoExtension(file.Type) || FileExtensionTypeTest.isAudioExtension(file.Type)) + { + string contenido = Path.Combine(host, "api/file/GetFileStream", dbName, file.Id, "file" + file.Type).Replace("\\", "/"); + if (GeneralConfigStatic.config.PreloadFilesOnStream || HelperService.bytesToMegaBytes(file.Size) < GeneralConfigStatic.config.MaxPreloadFileSizeInMb) + { + contenido = Path.Combine(host, "api/file/GetFileByTfmId", Uri.EscapeDataString(file.Name)).Replace("\\", "/") + $"?idChannel={dbName}&idFile={file.Id}"; + } + string pattern = $@"\.({file.Type.Replace(".", "")})$"; + // Ensure parent directory exists + var parentDir = Path.GetDirectoryName(Regex.Replace(filePath, pattern, ".strm")); + if (!string.IsNullOrEmpty(parentDir)) + { + Directory.CreateDirectory(parentDir); + } + File.WriteAllText(Regex.Replace(filePath, pattern, ".strm"), contenido); + } + } + else + { + Directory.CreateDirectory(filePath); + } + } + } + public async Task UploadFileFromServer(string dbName, string currentPath, List files, InfoDownloadTaksModel dm = null) // ItemsUploadedEventArgs args) { _logger.LogInformation("Starting upload from server - DbName: {DbName}, Path: {Path}, FilesCount: {Count}", @@ -1797,7 +1837,7 @@ public static string getMimeType(string extension) { return MIMETypesDictionary[extension.Replace(".", "")] ?? "application/octet-stream"; } - catch (Exception e) + catch { return "application/octet-stream"; } @@ -1941,6 +1981,28 @@ private async Task> AddChildRecords(string id, string parent var matchingFolder = Data.FirstOrDefault(x => x.FilePath + "/" == path) ?? Data.FirstOrDefault(x => x.FilePath == path.TrimEnd('/')); + // If not found in Data, try searching by name and parent FilterPath + // This handles cases where FilePath format doesn't match (e.g., first-level folders) + if (matchingFolder == null) + { + // Extract folder name and parent path from the navigation path + // e.g., "/FolderA/" -> name = "FolderA", parentPath = "/" + // e.g., "/Parent/Child/" -> name = "Child", parentPath = "/Parent/" + var pathParts = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + if (pathParts.Length > 0) + { + var folderName = pathParts[pathParts.Length - 1]; + var parentPath = pathParts.Length == 1 + ? "/" + : "/" + string.Join("/", pathParts.Take(pathParts.Length - 1)) + "/"; + + // Search for folder by name and parent FilterPath + matchingFolder = collectionName == null + ? await _db.getFolderByNameAndParentPath(dbName, folderName, parentPath) + : await _db.getFolderByNameAndParentPath(dbName, folderName, parentPath, collectionName); + } + } + if (matchingFolder != null) { childItem = matchingFolder.toFileManagerContent(); diff --git a/TelegramDownloader/Data/FileServiceV2.cs b/TelegramDownloader/Data/FileServiceV2.cs index 6d324ca..539ba87 100644 --- a/TelegramDownloader/Data/FileServiceV2.cs +++ b/TelegramDownloader/Data/FileServiceV2.cs @@ -1,4 +1,5 @@ -using BlazorBootstrap; +#nullable disable +using BlazorBootstrap; using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.StaticFiles; using MongoDB.Driver; @@ -129,7 +130,7 @@ public async Task downloadFromTelegramV2(string dbName, int messageId, string de { model.channelName = _ts.getChatName(Convert.ToInt64(dbName)); } - catch (Exception ex) + catch { model.channelName = "Public or Shared"; } diff --git a/TelegramDownloader/Data/IFileService.cs b/TelegramDownloader/Data/IFileService.cs index c53ab55..adee9d4 100644 --- a/TelegramDownloader/Data/IFileService.cs +++ b/TelegramDownloader/Data/IFileService.cs @@ -1,4 +1,5 @@ -using Syncfusion.Blazor.FileManager; +#nullable disable +using Syncfusion.Blazor.FileManager; using Syncfusion.Blazor.Inputs; using System.Dynamic; using TelegramDownloader.Models; @@ -30,6 +31,7 @@ public interface IFileService Task> getTelegramFoldersByParentId(string dbName, string? parentId); Task> GetTelegramFoldersExpando(string id, string parentId); Task CreateStrmFiles(string path, string dbName, string host); + Task CreateStrmFilesToLocal(string path, string dbName, string host, string destinationFolder); Task importData(string dbName, string path, GenericNotificationProgressModel gnp); Task importSharedData(ShareFilesModel sfm, GenericNotificationProgressModel gnp); Task> itemDeleteAsync(string dbName, ItemsDeleteEventArgs args); diff --git a/TelegramDownloader/Data/ITelegramService.cs b/TelegramDownloader/Data/ITelegramService.cs index c237920..7adc9f4 100644 --- a/TelegramDownloader/Data/ITelegramService.cs +++ b/TelegramDownloader/Data/ITelegramService.cs @@ -1,4 +1,5 @@ -using BlazorBootstrap; +#nullable disable +using BlazorBootstrap; using TelegramDownloader.Models; using TL; using WTelegram; @@ -7,6 +8,8 @@ namespace TelegramDownloader.Data { public interface ITelegramService { + bool IsConfigured { get; } + void InitializeClient(); Task checkAuth(string number, bool isPhone = false); Task GetUser(); bool checkChannelExist(string id); @@ -40,6 +43,10 @@ public interface ITelegramService Task uploadFile(string chatId, Stream file, string fileName, string mimeType = null, UploadModel um = null, string caption = null); Task> searchAllChannelFiles(long id, int lastId); bool isMyChat(long id); + bool isChannelOwner(long id); Task CreateChannel(string title, string about); + Task LeaveChannel(long id); + Task DeleteChannel(long id); + (string? name, bool exists) GetChannelInfo(long id); } } \ No newline at end of file diff --git a/TelegramDownloader/Data/TelegramService.cs b/TelegramDownloader/Data/TelegramService.cs index 2aa13f5..290fbce 100644 --- a/TelegramDownloader/Data/TelegramService.cs +++ b/TelegramDownloader/Data/TelegramService.cs @@ -1,4 +1,5 @@ -using BlazorBootstrap; +#nullable disable +using BlazorBootstrap; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Forms; using Syncfusion.Blazor.Inputs; @@ -45,12 +46,73 @@ public TelegramService(TransactionInfoService tis, IDbService db, ILogger HasValidCredentials() && client != null; + + /// + /// Initializes or reinitializes the Telegram client. + /// Call this after setup is complete to create the client with new credentials. + /// + public void InitializeClient() + { + if (client != null) + { + _logger.LogInformation("Client already initialized"); + return; + } + + if (!HasValidCredentials()) + { + _logger.LogWarning("Cannot initialize client - credentials not configured"); + return; } - mut.ReleaseMutex(); + mut.WaitOne(); + try + { + if (client == null) // Double-check after acquiring lock + { + _logger.LogInformation("Initializing Telegram client after setup"); + newClient(); + _logger.LogInformation("Telegram client initialized successfully"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize Telegram client: {Message}", ex.Message); + throw; + } + finally + { + mut.ReleaseMutex(); + } } private void createDownloadFolder() @@ -156,8 +218,15 @@ public bool checkChannelExist(string id) public async Task checkAuth(string number, bool isPhone = false) { + // Return early if client is not initialized (setup required) + if (client == null) + { + _logger.LogWarning("checkAuth called but client is null - setup required"); + return "setup_required"; + } + _logger.LogInformation("Checking authentication - IsPhone: {IsPhone}", isPhone); - if (client.UserId != null && number == null) + if (client.UserId != 0 && number == null) { UserData ud = await UserService.getUserDataFromFile(); if (ud != null) @@ -290,6 +359,97 @@ public bool isMyChat(long id) return true; } + public bool isChannelOwner(long id) + { + if (chats == null) + return false; + var peer = chats.chats[id]; + if (peer is TL.Channel channel) + { + return channel.IsActive && channel.flags.HasFlag(TL.Channel.Flags.creator); + } + return false; + } + + public async Task LeaveChannel(long id) + { + try + { + _logger.LogInformation("Leaving channel with ID: {Id}", id); + var peer = chats.chats[id]; + if (peer is TL.Channel channel) + { + var inputChannel = new InputChannel(channel.id, channel.access_hash); + await client.Channels_LeaveChannel(inputChannel); + _logger.LogInformation("Successfully left channel: {Id}", id); + } + else + { + throw new Exception("The specified chat is not a channel"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error leaving channel: {Id}", id); + throw; + } + } + + public async Task DeleteChannel(long id) + { + try + { + _logger.LogInformation("Deleting channel with ID: {Id}", id); + var peer = chats.chats[id]; + if (peer is TL.Channel channel) + { + if (!channel.flags.HasFlag(TL.Channel.Flags.creator)) + { + throw new Exception("You are not the owner of this channel"); + } + var inputChannel = new InputChannel(channel.id, channel.access_hash); + await client.Channels_DeleteChannel(inputChannel); + _logger.LogInformation("Successfully deleted channel: {Id}", id); + } + else + { + throw new Exception("The specified chat is not a channel"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting channel: {Id}", id); + throw; + } + } + + public (string? name, bool exists) GetChannelInfo(long id) + { + try + { + if (chats == null || !chats.chats.ContainsKey(id)) + { + return (null, false); + } + + var peer = chats.chats[id]; + if (peer is TL.Channel channel) + { + return (channel.title, true); + } + else if (peer is TL.Chat chat) + { + return (chat.title, true); + } + + return (peer.ToString(), true); + } + catch + { + return (null, false); + } + } + public async Task> GetFouriteChannels(bool mustRefresh = true) { @@ -820,7 +980,6 @@ public async Task DownloadFileStream(Message message, long offset, int l _logger.LogDebug("DownloadFileStream - Offset: {Offset}, Limit: {Limit}", offset, limit); int totalLimit = limit; long currentOffset = offset; - Byte[] response; if (message is Message msg && msg.media is MessageMediaDocument doc) { @@ -885,7 +1044,7 @@ public async Task DownloadFileStream(Message message, long offset, int l return memoryStream.ToArray(); } } - catch (Exception e) + catch { throw new InvalidOperationException("Unexpected file type returned."); } diff --git a/TelegramDownloader/Data/db/DbService.cs b/TelegramDownloader/Data/db/DbService.cs index 9920566..3561a2d 100644 --- a/TelegramDownloader/Data/db/DbService.cs +++ b/TelegramDownloader/Data/db/DbService.cs @@ -27,10 +27,67 @@ public class DbService : IDbService public DbService(ILogger logger) { _logger = logger; - client = new MongoClient(GeneralConfigStatic.tlconfig?.mongo_connection_string ?? Environment.GetEnvironmentVariable("connectionString")); - currentDatabase = getDatabase("default"); - this.dbName = "default"; - _logger.LogInformation("DbService initialized - Connected to MongoDB"); + var connectionString = GeneralConfigStatic.tlconfig?.mongo_connection_string + ?? Environment.GetEnvironmentVariable("connectionString"); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + _logger.LogWarning("DbService: MongoDB connection string not configured - setup required"); + // Use a default that will fail gracefully when actually used + connectionString = "mongodb://localhost:27017"; + } + + try + { + var settings = MongoClientSettings.FromConnectionString(connectionString); + settings.ServerSelectionTimeout = TimeSpan.FromSeconds(5); + settings.ConnectTimeout = TimeSpan.FromSeconds(5); + client = new MongoClient(settings); + currentDatabase = getDatabase("default"); + this.dbName = "default"; + _logger.LogInformation("DbService initialized - Connected to MongoDB"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "DbService: Could not connect to MongoDB - setup may be required"); + // Create client anyway for later use after setup + client = new MongoClient(connectionString); + currentDatabase = client.GetDatabase("default"); + this.dbName = "default"; + } + } + + /// + /// Reinitializes the MongoDB connection with a new connection string. + /// Call this after setup when the connection string has been configured. + /// + public void ReinitializeConnection(string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + _logger.LogWarning("ReinitializeConnection: Connection string is empty"); + return; + } + + try + { + _logger.LogInformation("Reinitializing MongoDB connection..."); + var settings = MongoClientSettings.FromConnectionString(connectionString); + settings.ServerSelectionTimeout = TimeSpan.FromSeconds(5); + settings.ConnectTimeout = TimeSpan.FromSeconds(5); + + // Create new client with updated connection string + client = new MongoClient(settings); + currentDatabase = getDatabase("default"); + this.dbName = "default"; + + _logger.LogInformation("DbService reinitialized - Connected to MongoDB with new connection string"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to reinitialize MongoDB connection: {Message}", ex.Message); + throw; + } } public async Task getSession() @@ -410,6 +467,27 @@ public async Task> getFilesByParentId(string dbName, .ToListAsync(); } + /// + /// Find a folder by its name and parent FilterPath. + /// This is useful for finding folders when FilePath format doesn't match the navigation path. + /// + public async Task getFolderByNameAndParentPath(string dbName, string folderName, string parentFilterPath, string collectionName = "directory") + { + if (collectionName == null) + collectionName = "directory"; + + // For first-level folders, FilterPath is "/" + // For nested folders, FilterPath is the parent path (e.g., "/Parent/") + var filter = Builders.Filter.Where(x => + !x.IsFile && + x.Name == folderName && + x.FilterPath == parentFilterPath); + + return await (await getDatabase(dbName).GetCollection(collectionName) + .FindAsync(filter)) + .FirstOrDefaultAsync(); + } + public async Task> getAllIdsFromChannel(string dbName, string collectionName = "directory") { if (collectionName == null) @@ -491,8 +569,9 @@ public async Task copyItem(string dbName, string sourceId, result.DateModified = DateTime.Now; result.FilterId = (target.FilterId ?? "") + target.Id + "/"; result.ParentId = target.Id; - // Both empty string and "/" indicate root folder - var isRootTarget = string.IsNullOrEmpty(target.FilterPath) || target.FilterPath == "/"; + // Only empty string indicates root folder (the "Files" folder) + // FilterPath "/" means a first-level folder, not the root itself + var isRootTarget = string.IsNullOrEmpty(target.FilterPath); result.FilterPath = isRootTarget ? "/" : target.FilterPath + target.Name + "/"; result.FilePath = isFile ? targetPath + result.Name : targetPath.TrimEnd('/'); await getDatabase(dbName).GetCollection(collectionName).InsertOneAsync(result); @@ -846,5 +925,261 @@ public async Task ClearAllTasks() #endregion + #region Maintenance Operations + + /// + /// Get all database names that represent Telegram channels (numeric IDs) + /// Excludes system databases like TCCONFIG, TFM-SHARED, admin, local, config + /// + public async Task> GetAllChannelDatabaseNames() + { + var excludedDatabases = new HashSet(StringComparer.OrdinalIgnoreCase) + { + CONFIG_DB_NAME, + SHARED_DB_NAME, + "admin", + "local", + "config", + "default" + }; + + var databaseNames = new List(); + + using (var cursor = await client.ListDatabaseNamesAsync()) + { + while (await cursor.MoveNextAsync()) + { + foreach (var dbName in cursor.Current) + { + // Only include databases that are numeric (channel IDs) or start with "-" (group IDs) + if (!excludedDatabases.Contains(dbName) && + (long.TryParse(dbName, out _) || (dbName.StartsWith("-") && long.TryParse(dbName, out _)))) + { + databaseNames.Add(dbName); + } + } + } + } + + _logger.LogInformation("Found {Count} channel databases", databaseNames.Count); + return databaseNames; + } + + /// + /// Get statistics for a specific database including size, document count, and dates + /// + public async Task GetDatabaseStats(string dbName) + { + var stats = new DatabaseStats(); + + try + { + var database = getDatabase(dbName); + + // Get database stats using command + var command = new BsonDocument { { "dbStats", 1 } }; + var result = await database.RunCommandAsync(command); + + if (result.Contains("dataSize")) + { + stats.SizeInBytes = result["dataSize"].ToInt64(); + } + + // Get document count from the directory collection + var collection = database.GetCollection("directory"); + stats.DocumentCount = await collection.CountDocumentsAsync(Builders.Filter.Empty); + + // Get creation date (oldest document) and last modified (newest document) + var oldestDoc = await collection + .Find(Builders.Filter.Empty) + .Sort(Builders.Sort.Ascending(x => x.DateCreated)) + .Limit(1) + .FirstOrDefaultAsync(); + + var newestDoc = await collection + .Find(Builders.Filter.Empty) + .Sort(Builders.Sort.Descending(x => x.DateModified)) + .Limit(1) + .FirstOrDefaultAsync(); + + if (oldestDoc != null) + { + stats.CreatedAt = oldestDoc.DateCreated; + } + + if (newestDoc != null) + { + stats.LastModified = newestDoc.DateModified; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error getting stats for database {DbName}", dbName); + } + + return stats; + } + + /// + /// Analyze FilterPath issues without repairing them. + /// Returns detailed information about items that need repair. + /// + public async Task AnalyzeFilterPaths(string dbName, string collectionName = "directory") + { + if (collectionName == null) + collectionName = "directory"; + + var result = new FilterPathAnalysisResult { DatabaseName = dbName }; + + try + { + var collection = getDatabase(dbName).GetCollection(collectionName); + var allItems = await (await collection.FindAsync(Builders.Filter.Empty)).ToListAsync(); + + result.TotalItems = allItems.Count; + + // Build a dictionary for quick lookup by Id + var itemsById = allItems.ToDictionary(x => x.Id, x => x); + + foreach (var item in allItems) + { + // Skip the root folder (no ParentId) + if (string.IsNullOrEmpty(item.ParentId)) + continue; + + // Calculate the correct FilterPath and FilterId + var correctFilterPath = CalculateFilterPath(item.ParentId, itemsById); + var correctFilterId = CalculateFilterId(item.ParentId, itemsById); + var normalizedFilePath = item.FilePath?.Replace("\\", "/"); + + bool hasIssue = false; + + if (item.FilterPath != correctFilterPath) + { + result.FilterPathIssues++; + hasIssue = true; + } + + if (item.FilterId != correctFilterId) + { + result.FilterIdIssues++; + hasIssue = true; + } + + if (normalizedFilePath != item.FilePath) + { + result.FilePathIssues++; + hasIssue = true; + } + + if (hasIssue) + { + result.ItemsWithIssues++; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error analyzing FilterPaths for database {DbName}", dbName); + result.Error = ex.Message; + } + + return result; + } + + /// + /// Repair FilterPath and FilterId for all items in a database. + /// This fixes data corruption caused by incorrect path calculations during move operations. + /// + public async Task RepairFilterPaths(string dbName, string collectionName = "directory") + { + if (collectionName == null) + collectionName = "directory"; + + var collection = getDatabase(dbName).GetCollection(collectionName); + var allItems = await (await collection.FindAsync(Builders.Filter.Empty)).ToListAsync(); + + // Build a dictionary for quick lookup by Id + var itemsById = allItems.ToDictionary(x => x.Id, x => x); + + int repairedCount = 0; + + foreach (var item in allItems) + { + // Skip the root folder (no ParentId) + if (string.IsNullOrEmpty(item.ParentId)) + continue; + + // Calculate the correct FilterPath and FilterId by walking up the parent chain + var correctFilterPath = CalculateFilterPath(item.ParentId, itemsById); + var correctFilterId = CalculateFilterId(item.ParentId, itemsById); + + // Also normalize FilePath (replace backslashes with forward slashes) + var normalizedFilePath = item.FilePath?.Replace("\\", "/"); + + bool needsUpdate = false; + var updateDef = Builders.Update.Combine(); + + if (item.FilterPath != correctFilterPath) + { + updateDef = updateDef.Set(x => x.FilterPath, correctFilterPath); + needsUpdate = true; + } + + if (item.FilterId != correctFilterId) + { + updateDef = updateDef.Set(x => x.FilterId, correctFilterId); + needsUpdate = true; + } + + if (normalizedFilePath != item.FilePath) + { + updateDef = updateDef.Set(x => x.FilePath, normalizedFilePath); + needsUpdate = true; + } + + if (needsUpdate) + { + await collection.UpdateOneAsync( + Builders.Filter.Eq(x => x.Id, item.Id), + updateDef); + repairedCount++; + } + } + + _logger.LogInformation("Repaired {Count} items in database {DbName}", repairedCount, dbName); + return repairedCount; + } + + private string CalculateFilterPath(string parentId, Dictionary itemsById) + { + if (string.IsNullOrEmpty(parentId) || !itemsById.TryGetValue(parentId, out var parent)) + return "/"; + + // If parent is root (no ParentId), return "/" + if (string.IsNullOrEmpty(parent.ParentId)) + return "/"; + + // Otherwise, build the path recursively + var parentPath = CalculateFilterPath(parent.ParentId, itemsById); + return parentPath + parent.Name + "/"; + } + + private string CalculateFilterId(string parentId, Dictionary itemsById) + { + if (string.IsNullOrEmpty(parentId) || !itemsById.TryGetValue(parentId, out var parent)) + return ""; + + // If parent is root (no ParentId), return just the parent's Id + if (string.IsNullOrEmpty(parent.ParentId)) + return parent.Id + "/"; + + // Otherwise, build the FilterId recursively + var parentFilterId = CalculateFilterId(parent.ParentId, itemsById); + return parentFilterId + parent.Id + "/"; + } + + #endregion + } } diff --git a/TelegramDownloader/Data/db/IDbService.cs b/TelegramDownloader/Data/db/IDbService.cs index 69e5797..0dc04d8 100644 --- a/TelegramDownloader/Data/db/IDbService.cs +++ b/TelegramDownloader/Data/db/IDbService.cs @@ -7,6 +7,7 @@ namespace TelegramDownloader.Data.db { public interface IDbService { + void ReinitializeConnection(string connectionString); Task getSession(); Task InsertSharedInfo(BsonSharedInfoModel sim, string dbName = DbService.SHARED_DB_NAME, string collection = "info"); Task> getSharedInfoList(string dbName = DbService.SHARED_DB_NAME, string collection = "info", string? filter = null); @@ -35,6 +36,7 @@ public interface IDbService Task> getAllFolders(string dbName, string? parentId = null, string collectionName = "directory"); Task> getFoldersByParentId(string dbName, string? parentId, string collectionName = "directory"); Task> getFilesByParentId(string dbName, string parentId, string collectionName = "directory"); + Task getFolderByNameAndParentPath(string dbName, string folderName, string parentFilterPath, string collectionName = "directory"); Task> getAllIdsFromChannel(string dbName, string collectionName = "directory"); IMongoDatabase getDatabase(string dbName); Task getEntry(string dbName, string filterId, string name, string collectionName = "directory"); @@ -66,5 +68,36 @@ public interface IDbService Task MarkTaskAsError(string internalId, string errorMessage); Task CleanupStaleTasks(int maxAgeDays = 7); Task ClearAllTasks(); + + // Maintenance operations + Task> GetAllChannelDatabaseNames(); + Task GetDatabaseStats(string dbName); + Task AnalyzeFilterPaths(string dbName, string collectionName = "directory"); + Task RepairFilterPaths(string dbName, string collectionName = "directory"); + } + + public class DatabaseStats + { + public long SizeInBytes { get; set; } + public long DocumentCount { get; set; } + public DateTime? CreatedAt { get; set; } + public DateTime? LastModified { get; set; } + } + + public class FilterPathAnalysisResult + { + public string DatabaseName { get; set; } = ""; + public int TotalItems { get; set; } + public int ItemsWithIssues { get; set; } + public int FilterPathIssues { get; set; } + public int FilterIdIssues { get; set; } + public int FilePathIssues { get; set; } + public string? Error { get; set; } + public bool HasIssues => ItemsWithIssues > 0; + public bool IsSelected { get; set; } + public bool IsAnalyzed { get; set; } + public bool IsRepairing { get; set; } + public bool IsRepaired { get; set; } + public int RepairedCount { get; set; } } } \ No newline at end of file diff --git a/TelegramDownloader/Models/DownloadModel.cs b/TelegramDownloader/Models/DownloadModel.cs index 8f14a2a..5a3d848 100644 --- a/TelegramDownloader/Models/DownloadModel.cs +++ b/TelegramDownloader/Models/DownloadModel.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.Tracing; +#nullable disable +using System.Diagnostics.Tracing; using System.Xml.Linq; using System; using TelegramDownloader.Data; diff --git a/TelegramDownloader/Models/FileModel.cs b/TelegramDownloader/Models/FileModel.cs index e769438..e16cbd0 100644 --- a/TelegramDownloader/Models/FileModel.cs +++ b/TelegramDownloader/Models/FileModel.cs @@ -1,4 +1,5 @@ -using MongoDB.Bson.Serialization.Attributes; +#nullable disable +using MongoDB.Bson.Serialization.Attributes; using MongoDB.Bson; using Syncfusion.Blazor.FileManager; using TelegramDownloader.Data.db; @@ -61,8 +62,16 @@ public BsonFileManagerModel(string Path,string FolderName, FileManagerDirectoryC this.ParentId = parent.Id; this.CaseSensitive = false; this.Name = FolderName; - this.FilePath = System.IO.Path.Combine(Path, FolderName); - this.FilterId = (string.IsNullOrEmpty(parent.FilterId) ? String.Concat(parent.Id, "/") : String.Concat(System.IO.Path.Combine(parent.FilterId, parent.Id), "/")); + // Use forward slashes consistently (not System.IO.Path.Combine which uses backslashes on Windows) + var normalizedPath = Path?.Replace("\\", "/") ?? ""; + if (!string.IsNullOrEmpty(normalizedPath) && !normalizedPath.EndsWith("/")) + normalizedPath += "/"; + this.FilePath = normalizedPath + FolderName; + // Use forward slashes for FilterId as well + var parentFilterId = parent.FilterId?.Replace("\\", "/") ?? ""; + this.FilterId = string.IsNullOrEmpty(parentFilterId) + ? parent.Id + "/" + : parentFilterId + parent.Id + "/"; this.FilterPath = (string.IsNullOrEmpty(parent.FilterId) ? "/" : String.Concat(parent.FilterPath, parent.Name, "/")); this.ShowHiddenItems = false; this.IsFile = false; diff --git a/TelegramDownloader/Models/GeneralConfig.cs b/TelegramDownloader/Models/GeneralConfig.cs index 67b23f3..470a395 100644 --- a/TelegramDownloader/Models/GeneralConfig.cs +++ b/TelegramDownloader/Models/GeneralConfig.cs @@ -105,6 +105,13 @@ public class GeneralConfig /// public bool EnableVideoTranscoding { get; set; } = false; + // Refresh Data Settings + /// + /// Enable refresh data option for channels where you are admin/owner. + /// By default, refresh is only available for channels you don't own. + /// + public bool EnableRefreshOwnChannels { get; set; } = false; + } public class TLConfig diff --git a/TelegramDownloader/Models/GitHub/GithubVersionModel.cs b/TelegramDownloader/Models/GitHub/GithubVersionModel.cs index db16c27..ddeed0f 100644 --- a/TelegramDownloader/Models/GitHub/GithubVersionModel.cs +++ b/TelegramDownloader/Models/GitHub/GithubVersionModel.cs @@ -1,4 +1,5 @@ -namespace TelegramDownloader.Models.GitHub +#nullable disable +namespace TelegramDownloader.Models.GitHub { public class GithubVersionModel { diff --git a/TelegramDownloader/Models/LoginModel.cs b/TelegramDownloader/Models/LoginModel.cs index c9f21d5..6d0862e 100644 --- a/TelegramDownloader/Models/LoginModel.cs +++ b/TelegramDownloader/Models/LoginModel.cs @@ -1,4 +1,5 @@ -using TL; +#nullable disable +using TL; namespace TelegramDownloader.Models { diff --git a/TelegramDownloader/Models/Persistence/PersistedTaskModel.cs b/TelegramDownloader/Models/Persistence/PersistedTaskModel.cs index b56d9ae..36b7541 100644 --- a/TelegramDownloader/Models/Persistence/PersistedTaskModel.cs +++ b/TelegramDownloader/Models/Persistence/PersistedTaskModel.cs @@ -1,3 +1,4 @@ +#nullable disable using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; diff --git a/TelegramDownloader/Models/ShareFilesModel.cs b/TelegramDownloader/Models/ShareFilesModel.cs index ca025b3..bf5cc5b 100644 --- a/TelegramDownloader/Models/ShareFilesModel.cs +++ b/TelegramDownloader/Models/ShareFilesModel.cs @@ -1,4 +1,5 @@ -using MongoDB.Bson.Serialization.Attributes; +#nullable disable +using MongoDB.Bson.Serialization.Attributes; using MongoDB.Bson; using System.ComponentModel.DataAnnotations; diff --git a/TelegramDownloader/Pages/Config.razor b/TelegramDownloader/Pages/Config.razor index 0a390c8..051d73c 100644 --- a/TelegramDownloader/Pages/Config.razor +++ b/TelegramDownloader/Pages/Config.razor @@ -10,6 +10,8 @@ @inject ToastService toastService @inject TransactionInfoService tis @inject IHostApplicationLifetime applicationLifetime +@inject ITelegramService ts +@using TelegramDownloader.Shared

- File Manager + File Manager @chatName Export Import - @if (!isMyChannel) + @if (canShowRefresh) { @if (isRefreshing) { @@ -40,7 +40,7 @@ } else if (showRefresh) { - + Beta Beta function @@ -77,9 +77,32 @@ + +
+

Are you sure you want to refresh data for @chatName?

+
+
+ + This will: +
    +
  • Re-download all file metadata from Telegram
  • +
  • Sync any changes made in the channel
  • +
  • May take a long time for large channels
  • +
+
+
+ @code { private Modals.ImportDataModal ImportModal { get; set; } private TelegramDownloader.Pages.Partials.impl.FileManagerImpl fileManagerImpl { get; set; } = default!; + private Modals.ConfirmationModal refreshConfirmModal { get; set; } = default!; [Parameter] public string id { get; set; } private MediaUrlModal mediaUrlModal { get; set; } = default!; @@ -94,6 +117,9 @@ private bool showRefresh = true; private bool isRefreshing = false; + // Show refresh button if: not my channel OR (is my channel AND EnableRefreshOwnChannels is enabled) + private bool canShowRefresh => !isMyChannel || GeneralConfigStatic.config.EnableRefreshOwnChannels; + NotificationModel nm = new NotificationModel(); @@ -139,7 +165,13 @@ { await ImportModal.Open(); } - public async Task refreshData() + + private async Task showRefreshConfirmation() + { + refreshConfirmModal.Show(); + } + + private async Task executeRefreshData() { isRefreshing = true; showRefresh = false; diff --git a/TelegramDownloader/Pages/Index.razor b/TelegramDownloader/Pages/Index.razor index 76badbc..8da395c 100644 --- a/TelegramDownloader/Pages/Index.razor +++ b/TelegramDownloader/Pages/Index.razor @@ -6,7 +6,9 @@ @using QRCoder @using TelegramDownloader.Data @using TelegramDownloader.Models +@using TelegramDownloader.Services @inject ITelegramService ts +@inject ISetupService SetupService @inject NavigationManager NavManager @inject IJSRuntime JSRuntime; @inject PreloadService preloadService @@ -153,6 +155,7 @@ @code { +#nullable disable public LoginModel? Model { get; set; } = null; private CancellationToken cTokenQr { get; set; } CancellationTokenSource source { get; set; } @@ -163,8 +166,40 @@ { try { + // Check if setup is complete using async method + var status = await SetupService.GetSetupStatusAsync(); + if (status.CurrentStep != SetupStep.Complete) + { + NavManager.NavigateTo("/setup", forceLoad: true); + return; + } + + // If Telegram client is not configured, try to initialize it + if (!ts.IsConfigured) + { + try + { + ts.InitializeClient(); + } + catch (Exception ex) + { + // If initialization fails, redirect to setup + Console.WriteLine($"Failed to initialize Telegram client: {ex.Message}"); + NavManager.NavigateTo("/setup", forceLoad: true); + return; + } + } + Model ??= new(); Model.type = await ts.checkAuth(Model.value); + + // Handle setup_required response - only redirect if actually not configured + if (Model.type == "setup_required" && !ts.IsConfigured) + { + NavManager.NavigateTo("/setup", forceLoad: true); + return; + } + StateHasChanged(); await isLogin(); } diff --git a/TelegramDownloader/Pages/InfoPage.razor b/TelegramDownloader/Pages/InfoPage.razor index f31ed1b..9b7786a 100644 --- a/TelegramDownloader/Pages/InfoPage.razor +++ b/TelegramDownloader/Pages/InfoPage.razor @@ -7,6 +7,7 @@ @inject IFileService fs @inject IJSRuntime JSRuntime @inject ISystemMetricsService metricsService +@inject NavigationManager NavigationManager @implements IDisposable @if(user != null) @@ -112,6 +113,10 @@ }
+ +
+ +
+ @if (isLoading) + { +
+
+ Loading... +
+

Scanning databases...

+ @if (scanProgress > 0) + { +
+
+
+ @scanProgress% complete + } +
+ } + else if (orphanedDatabases == null || orphanedDatabases.Count == 0) + { +
+ +

No orphaned databases found.

+ All databases are linked to active Telegram channels. +
+ } + else + { +
+
+ + Found @orphanedDatabases.Count database(s) without active Telegram channels. Total size: @FormatSize(totalSize) +
+
+ +
+ + + @selectedCount selected (@FormatSize(selectedSize)) +
+ +
+ @foreach (var item in orphanedDatabases) + { +
+
+ +
+
+
+ @if (!string.IsNullOrEmpty(item.ChannelName)) + { + + @item.ChannelName + } + else + { + + Unknown Channel + } +
+
+ ID: @item.DatabaseName + + Orphaned + +
+
+
+ + @FormatSize(item.SizeInBytes) +
+
+ + @item.DocumentCount records +
+ @if (item.CreatedAt.HasValue) + { +
+ + Created: @item.CreatedAt.Value.ToString("dd/MM/yyyy") +
+ } + @if (item.LastModified.HasValue) + { +
+ + Modified: @item.LastModified.Value.ToString("dd/MM/yyyy HH:mm") +
+ } +
+
+
+ +
+
+ } +
+ } +
+ + @if (orphanedDatabases != null && orphanedDatabases.Count > 0) + { + + } + + +} + + + +@code { + private bool isVisible = false; + private bool isLoading = false; + private bool isDeleting = false; + private int scanProgress = 0; + private List? orphanedDatabases; + + private int selectedCount => orphanedDatabases?.Count(x => x.IsSelected) ?? 0; + private long selectedSize => orphanedDatabases?.Where(x => x.IsSelected).Sum(x => x.SizeInBytes) ?? 0; + private long totalSize => orphanedDatabases?.Sum(x => x.SizeInBytes) ?? 0; + + public class OrphanedDatabaseInfo + { + public string DatabaseName { get; set; } = ""; + public string? ChannelName { get; set; } + public bool HasTelegramAccess { get; set; } + public bool IsSelected { get; set; } + public long SizeInBytes { get; set; } + public long DocumentCount { get; set; } + public DateTime? CreatedAt { get; set; } + public DateTime? LastModified { get; set; } + } + + public async Task Open() + { + isVisible = true; + isLoading = true; + scanProgress = 0; + orphanedDatabases = null; + StateHasChanged(); + + await ScanDatabases(); + + isLoading = false; + StateHasChanged(); + } + + public void Close() + { + if (isDeleting) return; + isVisible = false; + StateHasChanged(); + } + + private async Task ScanDatabases() + { + try + { + var allDatabases = await db.GetAllChannelDatabaseNames(); + orphanedDatabases = new List(); + + int processed = 0; + int total = allDatabases.Count; + + foreach (var dbName in allDatabases) + { + if (long.TryParse(dbName, out var channelId)) + { + var (name, exists) = ts.GetChannelInfo(channelId); + + // Only show databases where we don't have access to the channel + if (!exists) + { + var stats = await db.GetDatabaseStats(dbName); + + orphanedDatabases.Add(new OrphanedDatabaseInfo + { + DatabaseName = dbName, + ChannelName = name, + HasTelegramAccess = exists, + IsSelected = false, + SizeInBytes = stats.SizeInBytes, + DocumentCount = stats.DocumentCount, + CreatedAt = stats.CreatedAt, + LastModified = stats.LastModified + }); + } + } + + processed++; + scanProgress = (int)((double)processed / total * 100); + StateHasChanged(); + await Task.Delay(1); // Allow UI to update + } + } + catch (Exception ex) + { + toastService.Notify(new(ToastType.Danger, $"Error scanning databases: {ex.Message}") { Title = "Error", AutoHide = true }); + } + } + + private void ToggleSelection(OrphanedDatabaseInfo item) + { + item.IsSelected = !item.IsSelected; + StateHasChanged(); + } + + private void SelectAll() + { + if (orphanedDatabases != null) + { + foreach (var item in orphanedDatabases) + { + item.IsSelected = true; + } + StateHasChanged(); + } + } + + private void DeselectAll() + { + if (orphanedDatabases != null) + { + foreach (var item in orphanedDatabases) + { + item.IsSelected = false; + } + StateHasChanged(); + } + } + + private async Task DeleteSingle(OrphanedDatabaseInfo item) + { + isDeleting = true; + StateHasChanged(); + + try + { + await db.deleteDatabase(item.DatabaseName); + orphanedDatabases?.Remove(item); + toastService.Notify(new(ToastType.Success, $"Database {item.DatabaseName} deleted") { Title = "Success", AutoHide = true }); + } + catch (Exception ex) + { + toastService.Notify(new(ToastType.Danger, $"Error deleting database: {ex.Message}") { Title = "Error", AutoHide = true }); + } + + isDeleting = false; + StateHasChanged(); + } + + private async Task DeleteSelected() + { + if (orphanedDatabases == null) return; + + var selected = orphanedDatabases.Where(x => x.IsSelected).ToList(); + if (selected.Count == 0) return; + + isDeleting = true; + StateHasChanged(); + + int successCount = 0; + int errorCount = 0; + + foreach (var item in selected) + { + try + { + await db.deleteDatabase(item.DatabaseName); + orphanedDatabases.Remove(item); + successCount++; + } + catch + { + errorCount++; + } + } + + isDeleting = false; + + if (successCount > 0) + { + toastService.Notify(new(ToastType.Success, $"Deleted {successCount} database(s)") { Title = "Success", AutoHide = true }); + } + if (errorCount > 0) + { + toastService.Notify(new(ToastType.Warning, $"Failed to delete {errorCount} database(s)") { Title = "Warning", AutoHide = true }); + } + + StateHasChanged(); + } + + private string FormatSize(long bytes) + { + string[] sizes = { "B", "KB", "MB", "GB", "TB" }; + int order = 0; + double size = bytes; + while (size >= 1024 && order < sizes.Length - 1) + { + order++; + size /= 1024; + } + return $"{size:0.##} {sizes[order]}"; + } +} diff --git a/TelegramDownloader/Pages/Modals/DeleteLeaveChannelModal.razor b/TelegramDownloader/Pages/Modals/DeleteLeaveChannelModal.razor new file mode 100644 index 0000000..513779b --- /dev/null +++ b/TelegramDownloader/Pages/Modals/DeleteLeaveChannelModal.razor @@ -0,0 +1,561 @@ +@using TelegramDownloader.Data +@using TelegramDownloader.Data.db +@using TelegramDownloader.Models +@inject ITelegramService ts +@inject IDbService DbService +@inject NavigationManager NavigationManager + + + +
+
+
+
+ +
+
+
@(IsOwner ? "Delete Channel" : "Leave Channel")
+ This action cannot be undone +
+
+ +
+
+ +
+ @if (IsOwner) + { + Warning! You are the owner of this channel. Deleting it will permanently remove the channel from Telegram and delete all associated data. + } + else + { + Warning! You will leave this channel and all local data associated with it will be deleted. + } +
+
+ +
+
+ + + Channel ID + + @ChannelId +
+
+ + + Channel Name + + @ChannelName +
+
+ + + Ownership + + + @if (IsOwner) + { + + Owner + } + else + { + + Member + } + +
+
+ +
+
+ + What will happen: +
+
    + @if (IsOwner) + { +
  • The channel will be permanently deleted from Telegram
  • +
  • All messages and files in the channel will be lost
  • +
  • All members will be removed from the channel
  • +
  • The local database (files/folders) will be deleted
  • + } + else + { +
  • You will leave the channel
  • +
  • The local database (files/folders) will be deleted
  • +
  • The channel will remain on Telegram for other members
  • + } +
+
+ +
+ + +
+
+ + +
+
+ +@code { + [Parameter] public EventCallback OnChannelRemoved { get; set; } + [Inject] protected ToastService ToastService { get; set; } = default!; + + private bool isVisible = false; + private bool isProcessing = false; + private string ConfirmationInput { get; set; } = string.Empty; + + public string ChannelId { get; set; } = string.Empty; + public string ChannelName { get; set; } = string.Empty; + public bool IsOwner { get; set; } = false; + + private bool IsInputValid => !string.IsNullOrEmpty(ConfirmationInput) && + ConfirmationInput.Trim() == ChannelId; + + public void Open(string channelId, string channelName, bool isOwner) + { + ChannelId = channelId; + ChannelName = channelName; + IsOwner = isOwner; + ConfirmationInput = string.Empty; + isProcessing = false; + isVisible = true; + StateHasChanged(); + } + + public void Close() + { + if (isProcessing) return; + isVisible = false; + StateHasChanged(); + } + + private string GetInputValidationClass() + { + if (string.IsNullOrEmpty(ConfirmationInput)) + return ""; + return IsInputValid ? "valid" : "invalid"; + } + + private async Task OnConfirmClick() + { + if (!IsInputValid || isProcessing) + return; + + isProcessing = true; + StateHasChanged(); + + try + { + if (IsOwner) + { + // Delete the channel from Telegram + await ts.DeleteChannel(Convert.ToInt64(ChannelId)); + } + else + { + // Leave the channel + await ts.LeaveChannel(Convert.ToInt64(ChannelId)); + } + + // Delete the local database + await DbService.deleteDatabase(ChannelId); + + isVisible = false; + + ToastService.Notify(new ToastMessage( + ToastType.Success, + IsOwner + ? $"Channel '{ChannelName}' has been deleted successfully." + : $"You have left channel '{ChannelName}' successfully.") + { + Title = IsOwner ? "Channel Deleted" : "Left Channel", + AutoHide = true + }); + + // Notify parent to refresh + await OnChannelRemoved.InvokeAsync(); + + // Navigate to home + NavigationManager.NavigateTo("/fetchdata", forceLoad: true); + } + catch (Exception ex) + { + ToastService.Notify(new ToastMessage(ToastType.Danger, $"Error: {ex.Message}") + { + Title = "Error", + AutoHide = true + }); + isProcessing = false; + StateHasChanged(); + } + } +} diff --git a/TelegramDownloader/Pages/Modals/DowloadFromTelegram.razor b/TelegramDownloader/Pages/Modals/DowloadFromTelegram.razor index 8f5ad2b..5104faf 100644 --- a/TelegramDownloader/Pages/Modals/DowloadFromTelegram.razor +++ b/TelegramDownloader/Pages/Modals/DowloadFromTelegram.razor @@ -521,6 +521,7 @@ @code { +#nullable disable private bool isVisible = false; public NewDownloadModel? model { get; set; } public Ddtree ddtree { get; set; } diff --git a/TelegramDownloader/Pages/Modals/ExportStrmModal.razor b/TelegramDownloader/Pages/Modals/ExportStrmModal.razor new file mode 100644 index 0000000..f34ff88 --- /dev/null +++ b/TelegramDownloader/Pages/Modals/ExportStrmModal.razor @@ -0,0 +1,287 @@ +@using TelegramDownloader.Data +@using TelegramDownloader.Models +@using TelegramDownloader.Shared + +@inject IFileService fs +@inject NavigationManager MyNavigationManager + + + +@if (ShowBackdrop) +{ + +} + + + +@code { + public enum ExportOption + { + DownloadZip, + ExportToLocal + } + + public Ddtree? ddtree { get; set; } + private ExportOption selectedOption = ExportOption.DownloadZip; + private bool isExporting = false; + + public string ModalDisplay = "none;"; + public string ModalClass = ""; + public bool ShowBackdrop = false; + + private string channelId = ""; + private string path = ""; + private string folderName = ""; + private string host = ""; + + [Parameter] + public EventCallback OnExportCompleted { get; set; } + + public void Open(string channelId, string path, string host) + { + this.channelId = channelId; + this.path = path; + this.host = host; + this.folderName = Path.GetFileName(path.TrimEnd('/')); + this.selectedOption = ExportOption.DownloadZip; + this.isExporting = false; + + ModalDisplay = "block;"; + ModalClass = "Show"; + ShowBackdrop = true; + StateHasChanged(); + } + + public void Close() + { + ModalDisplay = "none"; + ModalClass = ""; + ShowBackdrop = false; + StateHasChanged(); + } + + private void SelectOption(ExportOption option) + { + selectedOption = option; + StateHasChanged(); + } + + private async Task Submit() + { + if (selectedOption == ExportOption.DownloadZip) + { + // Navigate to download ZIP (existing behavior) + MyNavigationManager.NavigateTo($"/api/file/strm?idChannel={channelId}&path={path}&host={host}", true); + Close(); + } + else + { + // Export to local folder + var selectedFolder = ddtree?.selectedNode?.FirstOrDefault(); + if (string.IsNullOrEmpty(selectedFolder)) + { + return; + } + + isExporting = true; + StateHasChanged(); + + try + { + await fs.CreateStrmFilesToLocal(path, channelId, host, selectedFolder); + await OnExportCompleted.InvokeAsync($"STRM files exported successfully to {selectedFolder}"); + } + catch (Exception ex) + { + await OnExportCompleted.InvokeAsync($"Error exporting STRM files: {ex.Message}"); + } + finally + { + isExporting = false; + Close(); + } + } + } +} diff --git a/TelegramDownloader/Pages/Modals/FileUploadModal.razor b/TelegramDownloader/Pages/Modals/FileUploadModal.razor index e824ee9..900cdba 100644 --- a/TelegramDownloader/Pages/Modals/FileUploadModal.razor +++ b/TelegramDownloader/Pages/Modals/FileUploadModal.razor @@ -10,8 +10,6 @@ public bool ShowBackdrop = false; public string ModalDisplay = "none;"; public string ModalClass = ""; - private static bool uniqueLoad = false; - public async Task Open() { diff --git a/TelegramDownloader/Pages/Modals/FileUploadModalTemplate.razor b/TelegramDownloader/Pages/Modals/FileUploadModalTemplate.razor index 089fa7d..9b9fc1c 100644 --- a/TelegramDownloader/Pages/Modals/FileUploadModalTemplate.razor +++ b/TelegramDownloader/Pages/Modals/FileUploadModalTemplate.razor @@ -1,85 +1,106 @@ @* Styles moved to site.css *@ -
-
-
-
+
+
+
+
Upload Files
-
-
+
-
-
+
-
Drop files here or click to browse
-
Supports any file type
+
Drop files here or click to browse
+
Supports any file type
+ +
+ +
+ +
+ + {{ activeUploads > 0 ? `Uploading ${activeUploads} of ${concurrentUploads}...` : `Upload ${concurrentUploads} files at a time` }} + +
+ -
-
+
-
-
+
+
-
-
{{ fileItem.file.name }}
-
{{ bytes(fileItem.file.size) }}
+
+
{{ fileItem.file.name }}
+
{{ bytes(fileItem.file.size) }}
-
-
-
+
+
-
- {{ fileItem.progress }}% - +
+ {{ fileItem.progress }}% + Completed - {{ bytesProgress(fileItem.sended) }} / {{ bytes(fileItem.file.size) }} + {{ bytesProgress(fileItem.sended) }} / {{ bytes(fileItem.file.size) }}
-
+
No files selected
-
- diff --git a/TelegramDownloader/Pages/Modals/PathRepairModal.razor b/TelegramDownloader/Pages/Modals/PathRepairModal.razor new file mode 100644 index 0000000..e0ff416 --- /dev/null +++ b/TelegramDownloader/Pages/Modals/PathRepairModal.razor @@ -0,0 +1,664 @@ +@using TelegramDownloader.Data +@using TelegramDownloader.Data.db +@inject IDbService db +@inject ITelegramService ts +@inject ToastService toastService + +@if (isVisible) +{ +
+
+
+
+ + Path Repair Tool +
+ +
+ +
+ @if (isLoading) + { +
+
+ Loading... +
+

Loading databases...

+
+ } + else if (databases == null || databases.Count == 0) + { +
+ +

No databases found.

+
+ } + else + { +
+
+ + This tool analyzes and repairs corrupted file paths in your databases. Select databases to analyze first, then repair if issues are found. +
+
+ +
+ + + + @selectedCount selected +
+ +
+ @foreach (var item in databases) + { +
+
+ +
+
+
+ + @GetChannelName(item.DatabaseName) +
+
+ ID: @item.DatabaseName + @if (item.IsAnalyzed) + { + @if (item.HasIssues) + { + + @item.ItemsWithIssues issues + + } + else + { + + OK + + } + } + @if (item.IsRepaired) + { + + Repaired (@item.RepairedCount) + + } +
+ @if (item.IsAnalyzed && item.HasIssues) + { +
+
+ + Total: @item.TotalItems +
+
+ + FilterPath: @item.FilterPathIssues +
+
+ + FilterId: @item.FilterIdIssues +
+
+ + FilePath: @item.FilePathIssues +
+
+ } + @if (!string.IsNullOrEmpty(item.Error)) + { +
+ @item.Error +
+ } +
+
+ @if (item.IsRepairing) + { + + } + else if (item.IsAnalyzed && item.HasIssues && !item.IsRepaired) + { + + } +
+
+ } +
+ } +
+ + @if (databases != null && databases.Count > 0) + { + + } +
+
+} + + + +@code { + private bool isVisible = false; + private bool isLoading = false; + private bool isAnalyzing = false; + private bool isRepairing = false; + private bool isProcessing => isAnalyzing || isRepairing; + + private List? databases; + + private int selectedCount => databases?.Count(x => x.IsSelected) ?? 0; + private int analyzedWithIssues => databases?.Count(x => x.IsAnalyzed && x.HasIssues && !x.IsRepaired) ?? 0; + private int totalIssues => databases?.Where(x => x.IsAnalyzed && x.HasIssues && !x.IsRepaired).Sum(x => x.ItemsWithIssues) ?? 0; + + public async Task Open() + { + isVisible = true; + isLoading = true; + databases = null; + StateHasChanged(); + + await LoadDatabases(); + + isLoading = false; + StateHasChanged(); + } + + public void Close() + { + if (isProcessing) return; + isVisible = false; + StateHasChanged(); + } + + private async Task LoadDatabases() + { + try + { + var allDatabases = await db.GetAllChannelDatabaseNames(); + databases = allDatabases + .Where(dbName => long.TryParse(dbName, out _)) + .Select(dbName => new FilterPathAnalysisResult { DatabaseName = dbName }) + .ToList(); + } + catch (Exception ex) + { + toastService.Notify(new(ToastType.Danger, $"Error loading databases: {ex.Message}") { Title = "Error", AutoHide = true }); + } + } + + private string GetChannelName(string dbName) + { + if (long.TryParse(dbName, out var channelId)) + { + var (name, _) = ts.GetChannelInfo(channelId); + return !string.IsNullOrEmpty(name) ? name : $"Channel {dbName}"; + } + return dbName; + } + + private void ToggleSelection(FilterPathAnalysisResult item) + { + if (isProcessing) return; + item.IsSelected = !item.IsSelected; + StateHasChanged(); + } + + private void SelectAll() + { + if (databases != null && !isProcessing) + { + foreach (var item in databases) + { + item.IsSelected = true; + } + StateHasChanged(); + } + } + + private void DeselectAll() + { + if (databases != null && !isProcessing) + { + foreach (var item in databases) + { + item.IsSelected = false; + } + StateHasChanged(); + } + } + + private async Task AnalyzeSelected() + { + if (databases == null || isProcessing) return; + + var selected = databases.Where(x => x.IsSelected).ToList(); + if (selected.Count == 0) return; + + isAnalyzing = true; + StateHasChanged(); + + foreach (var item in selected) + { + try + { + var result = await db.AnalyzeFilterPaths(item.DatabaseName); + item.TotalItems = result.TotalItems; + item.ItemsWithIssues = result.ItemsWithIssues; + item.FilterPathIssues = result.FilterPathIssues; + item.FilterIdIssues = result.FilterIdIssues; + item.FilePathIssues = result.FilePathIssues; + item.Error = result.Error; + item.IsAnalyzed = true; + StateHasChanged(); + } + catch (Exception ex) + { + item.Error = ex.Message; + item.IsAnalyzed = true; + } + } + + isAnalyzing = false; + StateHasChanged(); + + var withIssues = selected.Count(x => x.HasIssues); + if (withIssues > 0) + { + toastService.Notify(new(ToastType.Warning, $"Found {withIssues} database(s) with path issues") { Title = "Analysis Complete", AutoHide = true }); + } + else + { + toastService.Notify(new(ToastType.Success, "All selected databases are OK") { Title = "Analysis Complete", AutoHide = true }); + } + } + + private async Task RepairSingle(FilterPathAnalysisResult item) + { + if (isProcessing) return; + + item.IsRepairing = true; + StateHasChanged(); + + try + { + var repairedCount = await db.RepairFilterPaths(item.DatabaseName); + item.RepairedCount = repairedCount; + item.IsRepaired = true; + item.ItemsWithIssues = 0; + toastService.Notify(new(ToastType.Success, $"Repaired {repairedCount} items in {GetChannelName(item.DatabaseName)}") { Title = "Repair Complete", AutoHide = true }); + } + catch (Exception ex) + { + toastService.Notify(new(ToastType.Danger, $"Error repairing: {ex.Message}") { Title = "Error", AutoHide = true }); + } + + item.IsRepairing = false; + StateHasChanged(); + } + + private async Task RepairAllWithIssues() + { + if (databases == null || isProcessing) return; + + var toRepair = databases.Where(x => x.IsAnalyzed && x.HasIssues && !x.IsRepaired).ToList(); + if (toRepair.Count == 0) return; + + isRepairing = true; + StateHasChanged(); + + int totalRepaired = 0; + int successCount = 0; + + foreach (var item in toRepair) + { + item.IsRepairing = true; + StateHasChanged(); + + try + { + var repairedCount = await db.RepairFilterPaths(item.DatabaseName); + item.RepairedCount = repairedCount; + item.IsRepaired = true; + item.ItemsWithIssues = 0; + totalRepaired += repairedCount; + successCount++; + } + catch (Exception ex) + { + item.Error = ex.Message; + } + + item.IsRepairing = false; + StateHasChanged(); + } + + isRepairing = false; + StateHasChanged(); + + toastService.Notify(new(ToastType.Success, $"Repaired {totalRepaired} items in {successCount} database(s)") { Title = "Repair Complete", AutoHide = true }); + } +} diff --git a/TelegramDownloader/Pages/Partials/LocalFileManager.razor b/TelegramDownloader/Pages/Partials/LocalFileManager.razor index a8b6617..2284766 100644 --- a/TelegramDownloader/Pages/Partials/LocalFileManager.razor +++ b/TelegramDownloader/Pages/Partials/LocalFileManager.razor @@ -15,7 +15,7 @@ } -

File Manager @chatName

+

File Manager @chatName

@code { +#nullable disable [Parameter] public string DbName { get; set; } diff --git a/TelegramDownloader/Shared/Ddtree.razor b/TelegramDownloader/Shared/Ddtree.razor index 6dd9178..6a69ceb 100644 --- a/TelegramDownloader/Shared/Ddtree.razor +++ b/TelegramDownloader/Shared/Ddtree.razor @@ -17,7 +17,7 @@ @code { - +#nullable disable [Parameter] public string id { get; set; } [Parameter] @@ -46,7 +46,7 @@ private Task> LoadLocalFolders(string parentPath) { var result = new List(); - string basePath = FileService.TEMPDIR; + string basePath = FileService.LOCALDIR; // If parentPath is null, load root (temp folder) string targetPath = string.IsNullOrEmpty(parentPath) ? basePath : parentPath; diff --git a/TelegramDownloader/Shared/MainLayout.razor b/TelegramDownloader/Shared/MainLayout.razor index 9067902..b48f769 100644 --- a/TelegramDownloader/Shared/MainLayout.razor +++ b/TelegramDownloader/Shared/MainLayout.razor @@ -6,6 +6,7 @@ @using TelegramDownloader.Services @using TelegramDownloader.Services.GitHub @using TelegramDownloader.Shared +@inject ISetupService SetupService @inject ITelegramService ts @inject NavigationManager NavManager @inject IJSRuntime JS @@ -189,6 +190,15 @@ @* Upload to Telegram Modal - rendered outside .page to avoid stacking context issues *@ +@* Delete/Leave Channel Modal - rendered outside .page to avoid stacking context issues *@ + + +@* Database Maintenance Modal - rendered outside .page to avoid stacking context issues *@ + + +@* Path Repair Modal - rendered outside .page to avoid stacking context issues *@ + + @* Vue Upload Modal - rendered outside .page to avoid stacking context issues *@
@@ -292,12 +302,16 @@ } @code { - +#nullable disable +#pragma warning disable BL0005 // Setting component parameters from code for modal pattern private Offcanvas offcanvas = default!; private Pages.Modals.ImportDataModal ImportModal { get; set; } private Pages.Modals.CreateChannelModal CreateChannelModal { get; set; } private Pages.Modals.AudioPlayerModal audioPlayer { get; set; } = default!; private Pages.Modals.UploadToTelegramModal uploadToTelegramModal { get; set; } = default!; + private Pages.Modals.DeleteLeaveChannelModal deleteLeaveChannelModal { get; set; } = default!; + private Pages.Modals.DatabaseMaintenanceModal databaseMaintenanceModal { get; set; } = default!; + private Pages.Modals.PathRepairModal pathRepairModal { get; set; } = default!; private NavMenu menu { get; set; } private VersionBadge versionBadge { get; set; } private bool active { get; set; } = true; @@ -315,41 +329,67 @@ [JSInvokable] public static async Task OpenAudioPlayer(string url, string type, string title) { - if (_instance?.audioPlayer != null) + try { - await _instance.audioPlayer.ShowModal(url, type, title); + if (_instance?.audioPlayer != null) + { + await _instance.audioPlayer.ShowModal(url, type, title); + } } + catch { } } [JSInvokable] public static async Task OpenAudioPlayerCurrent() { - if (_instance?.audioPlayer != null) + try { - await _instance.audioPlayer.ShowCurrentModal(); + if (_instance?.audioPlayer != null) + { + await _instance.audioPlayer.ShowCurrentModal(); + } } + catch { } } [JSInvokable] - public static void AddToAudioPlaylist(string url, string type, string title) + public static async Task AddToAudioPlaylist(string url, string type, string title) { - _instance?.audioPlayer?.AddToPlaylist(url, type, title); + try + { + if (_instance?.audioPlayer != null) + { + await _instance.audioPlayer.AddToPlaylist(url, type, title); + } + } + catch { } } [JSInvokable] - public static void AddToAudioPlaylistAndPlay(string url, string type, string title) + public static async Task AddToAudioPlaylistAndPlay(string url, string type, string title) { - _instance?.audioPlayer?.AddToPlaylistAndPlay(url, type, title); + try + { + if (_instance?.audioPlayer != null) + { + await _instance.audioPlayer.AddToPlaylistAndPlay(url, type, title); + } + } + catch { } } [JSInvokable] - public static void AddMultipleToAudioPlaylist(PlaylistItemDto[] items) + public static async Task AddMultipleToAudioPlaylist(PlaylistItemDto[] items) { - if (_instance?.audioPlayer != null && items != null) + try { - var playlistItems = items.Select(i => (i.url, i.type ?? "audio/mpeg", i.title ?? "")).ToList(); - _instance.audioPlayer.AddMultipleToPlaylist(playlistItems); + if (_instance?.audioPlayer != null && items != null) + { + var playlistItems = items.Select(i => (i.url, i.type ?? "audio/mpeg", i.title ?? "")).ToList(); + await _instance.audioPlayer.AddMultipleToPlaylist(playlistItems); + } } + catch { } } public class PlaylistItemDto @@ -363,44 +403,56 @@ [JSInvokable] public static async Task MediaSessionAction(string action) { - if (_instance?.audioPlayer == null) return; - - switch (action) + try { - case "play": - await _instance.audioPlayer.ResumeFromMediaSession(); - break; - case "pause": - await _instance.audioPlayer.PauseFromMediaSession(); - break; - case "previoustrack": - await _instance.audioPlayer.PlayPreviousFromMediaSession(); - break; - case "nexttrack": - await _instance.audioPlayer.PlayNextFromMediaSession(); - break; - case "stop": - await _instance.audioPlayer.StopFromMediaSession(); - break; + if (_instance?.audioPlayer == null) return; + + switch (action) + { + case "play": + await _instance.audioPlayer.ResumeFromMediaSession(); + break; + case "pause": + await _instance.audioPlayer.PauseFromMediaSession(); + break; + case "previoustrack": + await _instance.audioPlayer.PlayPreviousFromMediaSession(); + break; + case "nexttrack": + await _instance.audioPlayer.PlayNextFromMediaSession(); + break; + case "stop": + await _instance.audioPlayer.StopFromMediaSession(); + break; + } } + catch { } } [JSInvokable] public static async Task MediaSessionSeek(double offsetSeconds) { - if (_instance?.audioPlayer != null) + try { - await _instance.audioPlayer.SeekRelativeFromMediaSession(offsetSeconds); + if (_instance?.audioPlayer != null) + { + await _instance.audioPlayer.SeekRelativeFromMediaSession(offsetSeconds); + } } + catch { } } [JSInvokable] public static async Task MediaSessionSeekTo(double positionSeconds) { - if (_instance?.audioPlayer != null) + try { - await _instance.audioPlayer.SeekToFromMediaSession(positionSeconds); + if (_instance?.audioPlayer != null) + { + await _instance.audioPlayer.SeekToFromMediaSession(positionSeconds); + } } + catch { } } // Método público para acceder al reproductor desde otros componentes @@ -418,6 +470,33 @@ public static Pages.Modals.UploadToTelegramModal? GetUploadToTelegramModal() => _instance?.uploadToTelegramModal; + // Delete/Leave Channel Modal - static methods + public static void OpenDeleteLeaveChannelModal(string channelId, string channelName, bool isOwner) + { + if (_instance != null && _instance.deleteLeaveChannelModal != null) + { + _instance.InvokeAsync(() => _instance.deleteLeaveChannelModal.Open(channelId, channelName, isOwner)); + } + } + + // Database Maintenance Modal - static methods + public static void OpenDatabaseMaintenanceModal() + { + if (_instance != null && _instance.databaseMaintenanceModal != null) + { + _instance.InvokeAsync(async () => await _instance.databaseMaintenanceModal.Open()); + } + } + + // Path Repair Modal - static methods + public static void OpenPathRepairModal() + { + if (_instance != null && _instance.pathRepairModal != null) + { + _instance.InvokeAsync(async () => await _instance.pathRepairModal.Open()); + } + } + protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) @@ -435,7 +514,15 @@ protected override async Task OnInitializedAsync() { - checkWorkingTasks(); + // Check if setup is complete using async method to avoid deadlock + var status = await SetupService.GetSetupStatusAsync(); + if (status.CurrentStep != SetupStep.Complete) + { + NavManager.NavigateTo("/setup", forceLoad: true); + return; + } + + await checkWorkingTasks(); tis.TaskEventChanged += eventChangedWorkingTasks; } @@ -487,9 +574,13 @@ private async void Logout() { - await ts.logOff(); - NavManager.NavigateTo("/"); - StateHasChanged(); + try + { + await ts.logOff(); + NavManager.NavigateTo("/"); + await InvokeAsync(StateHasChanged); + } + catch { } } private async Task LogoutAndClose() @@ -501,9 +592,13 @@ } private async Task checkWorkingTasks() { - if (_disposed) return; - active = tis.isWorking(); - await InvokeAsync(StateHasChanged); + try + { + if (_disposed) return; + active = tis.isWorking(); + await InvokeAsync(StateHasChanged); + } + catch { } } private int GetDownloadProgress() @@ -518,9 +613,13 @@ return activeUpload?.progress ?? 0; } - private void eventChangedWorkingTasks(object sender, System.EventArgs e) + private async void eventChangedWorkingTasks(object sender, System.EventArgs e) { - checkWorkingTasks(); + try + { + await checkWorkingTasks(); + } + catch { } } private void ToggleSidebar() @@ -528,10 +627,14 @@ sidebarCollapsed = !sidebarCollapsed; } - private void OnNavMenuCollapseChanged(bool isCollapsed) + private async void OnNavMenuCollapseChanged(bool isCollapsed) { - navMenuCollapsed = isCollapsed; - StateHasChanged(); + try + { + navMenuCollapsed = isCollapsed; + await InvokeAsync(StateHasChanged); + } + catch { } } private async Task CloseVersionModal() diff --git a/TelegramDownloader/Shared/MobileFileManager/MobileFileItem.razor b/TelegramDownloader/Shared/MobileFileManager/MobileFileItem.razor index 6ec5590..bcb2725 100644 --- a/TelegramDownloader/Shared/MobileFileManager/MobileFileItem.razor +++ b/TelegramDownloader/Shared/MobileFileManager/MobileFileItem.razor @@ -160,7 +160,7 @@ var ext = File.Type?.ToLower() ?? ""; var color = GetFileColor(ext); - return @ + return @ @GetExtensionLabel(ext) diff --git a/TelegramDownloader/Shared/MobileFileManager/MobileFileManager.razor b/TelegramDownloader/Shared/MobileFileManager/MobileFileManager.razor index fee57d4..1d623ea 100644 --- a/TelegramDownloader/Shared/MobileFileManager/MobileFileManager.razor +++ b/TelegramDownloader/Shared/MobileFileManager/MobileFileManager.razor @@ -483,7 +483,8 @@

Rename

- +
diff --git a/TelegramDownloader/Shared/MobileFileManager/MobileFileManager.razor.cs b/TelegramDownloader/Shared/MobileFileManager/MobileFileManager.razor.cs index 2b2b9a4..06d9fd1 100644 --- a/TelegramDownloader/Shared/MobileFileManager/MobileFileManager.razor.cs +++ b/TelegramDownloader/Shared/MobileFileManager/MobileFileManager.razor.cs @@ -737,21 +737,47 @@ private void CloseDeleteConfirmDialog() #region Rename - private void RenameSelected() + private async Task RenameSelected() { if (SelectedItems.Count != 1) return; RenameItem = SelectedItems.First(); RenameText = RenameItem.Name; ShowRenameDialog = true; ClearSelection(); + await FocusAndSelectRenameInput(); } - private void StartRenameItem(FileManagerDirectoryContent item) + private async Task StartRenameItem(FileManagerDirectoryContent item) { RenameItem = item; RenameText = item.Name; ShowRenameDialog = true; CloseContextMenu(); + await FocusAndSelectRenameInput(); + } + + private async Task FocusAndSelectRenameInput() + { + StateHasChanged(); + await Task.Delay(50); + try + { + await renameInput.FocusAsync(); + await JSRuntime.InvokeVoidAsync("eval", "document.activeElement.select()"); + } + catch { } + } + + private async Task OnRenameKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") + { + await ConfirmRename(); + } + else if (e.Key == "Escape") + { + CloseRenameDialog(); + } } private async Task ConfirmRename() diff --git a/TelegramDownloader/Shared/NavMenu.razor b/TelegramDownloader/Shared/NavMenu.razor index ce832f6..0e563cd 100644 --- a/TelegramDownloader/Shared/NavMenu.razor +++ b/TelegramDownloader/Shared/NavMenu.razor @@ -354,6 +354,16 @@ color: white; } + .channel-action-btn-danger { + opacity: 0.5; + } + + .channel-action-btn-danger:hover { + background: rgba(220, 53, 69, 0.4) !important; + color: #ff6b6b !important; + opacity: 1; + } + /* Shared Tab Buttons */ .shared-actions { display: flex; @@ -558,9 +568,6 @@ -
@@ -837,15 +844,6 @@ PreloadService.Hide(); } - private async Task HandleThreeDots() - { - ToggleNavMenu(); - if (OnClickCallback.HasDelegate) - { - await OnClickCallback.InvokeAsync(null); - } - } - private async Task HandleImportMenu() { ToggleNavMenu(); @@ -1016,6 +1014,12 @@ +
}; @@ -1029,6 +1033,12 @@ return title.Length >= 2 ? title.Substring(0, 2).ToUpper() : title.ToUpper(); } + private void OpenDeleteLeaveModal(long channelId, string channelName) + { + var isOwner = ts.isChannelOwner(channelId); + MainLayout.OpenDeleteLeaveChannelModal(channelId.ToString(), channelName, isOwner); + } + protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) diff --git a/TelegramDownloader/Shared/SetupLayout.razor b/TelegramDownloader/Shared/SetupLayout.razor new file mode 100644 index 0000000..724fc91 --- /dev/null +++ b/TelegramDownloader/Shared/SetupLayout.razor @@ -0,0 +1,5 @@ +@inherits LayoutComponentBase + +
+ @Body +
diff --git a/TelegramDownloader/TelegramDownloader.csproj b/TelegramDownloader/TelegramDownloader.csproj index 7f0efe7..975d008 100644 --- a/TelegramDownloader/TelegramDownloader.csproj +++ b/TelegramDownloader/TelegramDownloader.csproj @@ -1,7 +1,7 @@  - 3.1.0.0 + 3.2.0.0 Mateo TelegramFileManager net10.0 @@ -10,6 +10,15 @@ 528ba20b-8482-4c0f-9c2f-7ecc8dc30410 Linux . + + + + + + + + CS1998;CS4014;CA2024;CA2200;CS0618;SYSLIB0014;ASPDEPR005;CS0649;CS8600;CS8602;CS8603;CS8604;CS8605;CS8618;CS8622;CS8625;CS8632;CS8669 + @@ -28,6 +37,7 @@ + diff --git a/TelegramDownloader/wwwroot/css/site.css b/TelegramDownloader/wwwroot/css/site.css index adb1520..933c6e6 100644 --- a/TelegramDownloader/wwwroot/css/site.css +++ b/TelegramDownloader/wwwroot/css/site.css @@ -385,66 +385,78 @@ a, .btn-link { position: fixed; top: 0; left: 0; - width: 0; - height: 0; + width: 100%; + height: 100%; z-index: 10500; pointer-events: none; } -.upload-modal-overlay { +#modalFileUploadVue > * { + pointer-events: auto; +} + +.vue-upload-overlay { position: fixed; top: 0; left: 0; + right: 0; + bottom: 0; width: 100%; height: 100%; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(4px); + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(8px); z-index: 10500; opacity: 0; visibility: hidden; - transition: opacity 0.3s ease, visibility 0.3s ease; - pointer-events: auto; + transition: all 0.3s ease; } -.upload-modal-overlay.modalshow { +.vue-upload-overlay.modalshow { opacity: 1; visibility: visible; } -.upload-modal-overlay.modalhide { +.vue-upload-overlay.modalhide { opacity: 0; visibility: hidden; } -.upload-modal-container { +.vue-upload-container { position: fixed; top: 50%; left: 50%; - transform: translate(-50%, -50%) scale(0.9); + transform: translate(-50%, -50%) scale(0.95); width: calc(100% - 2rem); max-width: 550px; + max-height: 85vh; background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); border-radius: 1rem; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7), + 0 0 0 1px rgba(233, 69, 96, 0.1); overflow: hidden; - transition: transform 0.3s ease; + transition: transform 0.3s ease, opacity 0.3s ease; z-index: 10501; + display: flex; + flex-direction: column; + opacity: 0; } -.upload-modal-overlay.modalshow .upload-modal-container { +.vue-upload-overlay.modalshow .vue-upload-container { transform: translate(-50%, -50%) scale(1); + opacity: 1; } -.upload-modal-header { +.vue-upload-header { background: linear-gradient(135deg, #0f3460 0%, #1a1a2e 100%); padding: 1rem 1.25rem; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(255,255,255,0.1); + flex-shrink: 0; } -.upload-modal-title { +.vue-upload-title { color: white; font-weight: 600; font-size: 1.1rem; @@ -454,11 +466,11 @@ a, .btn-link { margin: 0; } -.upload-modal-title i { +.vue-upload-title i { color: #e94560; } -.upload-modal-close { +.vue-upload-close { width: 32px; height: 32px; border-radius: 50%; @@ -472,20 +484,21 @@ a, .btn-link { transition: all 0.2s ease; } -.upload-modal-close:hover { +.vue-upload-close:hover { background: rgba(233, 69, 96, 0.3); color: #fff; transform: rotate(90deg); } -.upload-modal-body { +.vue-upload-body { padding: 1.25rem; - max-height: 60vh; + flex: 1; overflow-y: auto; + min-height: 0; } /* Drop Zone */ -.upload-drop-zone { +.vue-upload-drop-zone { border: 2px dashed rgba(233, 69, 96, 0.4); border-radius: 0.75rem; padding: 2rem; @@ -496,18 +509,18 @@ a, .btn-link { margin-bottom: 1rem; } -.upload-drop-zone:hover { +.vue-upload-drop-zone:hover { border-color: #e94560; background: rgba(233, 69, 96, 0.1); } -.upload-drop-zone.dragover { +.vue-upload-drop-zone.dragover { border-color: #e94560; background: rgba(233, 69, 96, 0.15); transform: scale(1.02); } -.upload-drop-icon { +.vue-upload-drop-icon { width: 64px; height: 64px; background: linear-gradient(135deg, #e94560 0%, #c73659 100%); @@ -518,34 +531,98 @@ a, .btn-link { margin: 0 auto 1rem; } -.upload-drop-icon i { +.vue-upload-drop-icon i { font-size: 1.75rem; color: white; } -.upload-drop-text { +.vue-upload-drop-text { color: rgba(255,255,255,0.8); font-size: 1rem; margin-bottom: 0.5rem; } -.upload-drop-hint { +.vue-upload-drop-hint { color: rgba(255,255,255,0.5); font-size: 0.8rem; } -.upload-drop-input { +.vue-upload-drop-input { display: none; } +/* Concurrent uploads selector */ +.vue-upload-concurrent { + background: rgba(0, 0, 0, 0.2); + border-radius: 0.75rem; + padding: 1rem; + margin-bottom: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.vue-upload-concurrent-label { + font-size: 0.85rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.vue-upload-concurrent-label i { + color: #e94560; +} + +.vue-upload-concurrent-selector { + display: flex; + gap: 0.5rem; +} + +.vue-upload-concurrent-btn { + width: 40px; + height: 40px; + border-radius: 0.5rem; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.7); + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.vue-upload-concurrent-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.3); + color: #fff; +} + +.vue-upload-concurrent-btn.active { + background: linear-gradient(135deg, #e94560 0%, #c73659 100%); + border-color: transparent; + color: #fff; +} + +.vue-upload-concurrent-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.vue-upload-concurrent-hint { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.5); +} + /* File List */ -.upload-file-list { +.vue-upload-file-list { display: flex; flex-direction: column; gap: 0.75rem; } -.upload-file-item { +.vue-upload-file-item { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 0.75rem; @@ -553,23 +630,23 @@ a, .btn-link { transition: all 0.2s ease; } -.upload-file-item:hover { +.vue-upload-file-item:hover { background: rgba(255,255,255,0.08); } -.upload-file-item.completed { +.vue-upload-file-item.completed { border-color: rgba(40, 167, 69, 0.3); background: rgba(40, 167, 69, 0.1); } -.upload-file-header { +.vue-upload-file-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; } -.upload-file-icon { +.vue-upload-file-icon { width: 40px; height: 40px; background: linear-gradient(135deg, #e94560 0%, #c73659 100%); @@ -580,21 +657,21 @@ a, .btn-link { flex-shrink: 0; } -.upload-file-item.completed .upload-file-icon { +.vue-upload-file-item.completed .vue-upload-file-icon { background: linear-gradient(135deg, #28a745 0%, #5dd879 100%); } -.upload-file-icon i { +.vue-upload-file-icon i { font-size: 1.1rem; color: white; } -.upload-file-info { +.vue-upload-file-info { flex: 1; min-width: 0; } -.upload-file-name { +.vue-upload-file-name { color: white; font-weight: 500; font-size: 0.9rem; @@ -604,12 +681,12 @@ a, .btn-link { margin-bottom: 0.125rem; } -.upload-file-size { +.vue-upload-file-size { color: rgba(255,255,255,0.5); font-size: 0.8rem; } -.upload-file-remove { +.vue-upload-file-remove { width: 28px; height: 28px; border-radius: 50%; @@ -624,17 +701,17 @@ a, .btn-link { flex-shrink: 0; } -.upload-file-remove:hover { +.vue-upload-file-remove:hover { background: rgba(220, 53, 69, 0.4); transform: scale(1.1); } -.upload-file-remove i { +.vue-upload-file-remove i { font-size: 0.8rem; } /* Progress */ -.upload-file-progress { +.vue-upload-file-progress { height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; @@ -642,39 +719,39 @@ a, .btn-link { margin-bottom: 0.5rem; } -.upload-file-progress-bar { +.vue-upload-file-progress-bar { height: 100%; background: linear-gradient(90deg, #e94560 0%, #ff6b6b 100%); border-radius: 3px; transition: width 0.3s ease; } -.upload-file-item.completed .upload-file-progress-bar { +.vue-upload-file-item.completed .vue-upload-file-progress-bar { background: linear-gradient(90deg, #28a745 0%, #5dd879 100%); } -.upload-file-status { +.vue-upload-file-status { display: flex; justify-content: space-between; align-items: center; } -.upload-file-percent { +.vue-upload-file-percent { color: #e94560; font-weight: 600; font-size: 0.85rem; } -.upload-file-item.completed .upload-file-percent { +.vue-upload-file-item.completed .vue-upload-file-percent { color: #5dd879; } -.upload-file-bytes { +.vue-upload-file-bytes { color: rgba(255,255,255,0.5); font-size: 0.75rem; } -.upload-completed-badge { +.vue-upload-completed-badge { display: inline-flex; align-items: center; gap: 0.25rem; @@ -687,16 +764,17 @@ a, .btn-link { } /* Footer */ -.upload-modal-footer { +.vue-upload-footer { padding: 1rem 1.25rem; background: rgba(0,0,0,0.2); border-top: 1px solid rgba(255,255,255,0.1); display: flex; justify-content: flex-end; gap: 0.75rem; + flex-shrink: 0; } -.upload-btn { +.vue-upload-btn { padding: 0.625rem 1.5rem; border-radius: 0.5rem; font-weight: 500; @@ -708,34 +786,34 @@ a, .btn-link { transition: all 0.2s ease; } -.upload-btn-primary { +.vue-upload-btn-primary { background: linear-gradient(135deg, #e94560 0%, #c73659 100%); color: white; } -.upload-btn-primary:hover { +.vue-upload-btn-primary:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(233, 69, 96, 0.4); } -.upload-btn-primary:disabled { +.vue-upload-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; } -.upload-btn-secondary { +.vue-upload-btn-secondary { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.8); } -.upload-btn-secondary:hover { +.vue-upload-btn-secondary:hover { background: rgba(255,255,255,0.15); } /* Empty State */ -.upload-empty-state { +.vue-upload-empty-state { text-align: center; padding: 1rem; color: rgba(255,255,255,0.5); @@ -743,51 +821,101 @@ a, .btn-link { } /* Scrollbar */ -.upload-modal-body::-webkit-scrollbar { +.vue-upload-body::-webkit-scrollbar { width: 6px; } -.upload-modal-body::-webkit-scrollbar-track { +.vue-upload-body::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); border-radius: 3px; } -.upload-modal-body::-webkit-scrollbar-thumb { +.vue-upload-body::-webkit-scrollbar-thumb { background: rgba(233, 69, 96, 0.4); border-radius: 3px; } -.upload-modal-body::-webkit-scrollbar-thumb:hover { +.vue-upload-body::-webkit-scrollbar-thumb:hover { background: rgba(233, 69, 96, 0.6); } -/* Responsive */ +/* Responsive - Full screen on mobile */ @media (max-width: 576px) { - .upload-modal-container { - max-width: calc(100% - 1rem); - margin: 0.5rem; + .vue-upload-overlay { + padding: 0; + } + + .vue-upload-container { + top: 0; + left: 0; + transform: none; + width: 100%; + max-width: 100%; + height: 100%; + max-height: 100%; + border-radius: 0; + } + + .vue-upload-overlay.modalshow .vue-upload-container { + transform: none; } - .upload-drop-zone { + .vue-upload-header { + padding: 1rem; + border-radius: 0; + } + + .vue-upload-body { + padding: 1rem; + flex: 1; + overflow-y: auto; + } + + .vue-upload-drop-zone { padding: 1.5rem 1rem; } - .upload-drop-icon { - width: 48px; - height: 48px; + .vue-upload-drop-icon { + width: 56px; + height: 56px; + } + + .vue-upload-drop-icon i { + font-size: 1.5rem; + } + + .vue-upload-drop-text { + font-size: 0.95rem; + } + + .vue-upload-drop-hint { + font-size: 0.8rem; + } + + .vue-upload-file-item { + padding: 0.75rem; } - .upload-drop-icon i { - font-size: 1.25rem; + .vue-upload-file-icon { + width: 36px; + height: 36px; } - .upload-modal-footer { + .vue-upload-file-name { + font-size: 0.85rem; + } + + .vue-upload-footer { + padding: 1rem; flex-direction: column; + gap: 0.5rem; + border-radius: 0; } - .upload-btn { + .vue-upload-btn { width: 100%; justify-content: center; + padding: 0.75rem 1rem; } } @@ -1796,21 +1924,24 @@ table.table tbody td, .info-page .stat-action { width: 100%; + display: flex; + gap: 0.5rem; } .info-page .stat-action .btn { - width: 100%; - padding: 0.75rem 1.25rem; + flex: 1; + padding: 0.75rem 1rem; font-weight: 600; - font-size: 0.9rem; + font-size: 0.85rem; display: flex; align-items: center; justify-content: center; - gap: 0.5rem; + gap: 0.4rem; border-radius: 0.625rem; transition: all 0.25s ease; border: none; cursor: pointer; + white-space: nowrap; } .info-page .stat-action .btn:hover { @@ -1860,6 +1991,29 @@ table.table tbody td, color: #3b82f6 !important; } +/* Outline secondary button - Open Folder style */ +.info-page .stat-action .btn-outline-secondary { + background: transparent !important; + color: #6b7280 !important; + border: 2px solid #d1d5db !important; + box-shadow: none; +} + +.info-page .stat-action .btn-outline-secondary:hover { + background: #f3f4f6 !important; + color: #374151 !important; + border-color: #9ca3af !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.info-page .stat-action .btn-outline-secondary i { + color: #6b7280 !important; +} + +.info-page .stat-action .btn-outline-secondary:hover i { + color: #374151 !important; +} + /* Chart section */ .info-page .chart-section { background: #ffffff !important; diff --git a/TelegramDownloader/wwwroot/js/fileUploadVue.js b/TelegramDownloader/wwwroot/js/fileUploadVue.js index ca5ca53..ae54ac9 100644 --- a/TelegramDownloader/wwwroot/js/fileUploadVue.js +++ b/TelegramDownloader/wwwroot/js/fileUploadVue.js @@ -56,7 +56,9 @@ function initFileUploadVue() { path: "", url: "", isDragging: false, - isUploading: false + isUploading: false, + concurrentUploads: 3, + activeUploads: 0 }; }, methods: { @@ -100,34 +102,72 @@ function initFileUploadVue() { if (this.files.length === 0 || this.isUploading) return; this.isUploading = true; - console.log("Enviando...."); + this.activeUploads = 0; + console.log(`Enviando ${this.files.length} archivos (${this.concurrentUploads} simultáneos)...`); + + // Get files that haven't been uploaded yet + const pendingFiles = this.files.filter(f => !f.completed); + + // Upload with concurrency limit + await this.uploadWithConcurrency(pendingFiles, this.concurrentUploads); + + this.isUploading = false; + }, + + async uploadWithConcurrency(files, limit) { + const queue = [...files]; + const executing = []; + + const uploadFile = async (fileItem) => { + this.activeUploads++; - const uploadPromises = this.files.map((value) => { return new Promise((resolve) => { let xhr = new XMLHttpRequest(); xhr.open("POST", this.url); let data = new FormData(); - data.set('file', value.file); + data.set('file', fileItem.file); data.set('id', this.id); data.set('path', this.path); data.set('action', 'save'); xhr.upload.addEventListener("progress", ({ loaded, total }) => { - value.progress = Math.floor((loaded / total) * 100); - value.sended = loaded; + fileItem.progress = Math.floor((loaded / total) * 100); + fileItem.sended = loaded; if (loaded == total) { - value.completed = true; + fileItem.completed = true; } }); - xhr.onloadend = () => resolve(); + xhr.onloadend = () => { + this.activeUploads--; + resolve(); + }; + + xhr.onerror = () => { + this.activeUploads--; + resolve(); + }; + xhr.send(data); }); - }); + }; - await Promise.all(uploadPromises); - this.isUploading = false; + while (queue.length > 0 || executing.length > 0) { + // Start new uploads while under limit and queue has items + while (executing.length < limit && queue.length > 0) { + const fileItem = queue.shift(); + const promise = uploadFile(fileItem).then(() => { + executing.splice(executing.indexOf(promise), 1); + }); + executing.push(promise); + } + + // Wait for at least one to complete before continuing + if (executing.length > 0) { + await Promise.race(executing); + } + } }, closeModal() { const hadFiles = this.files.length > 0; @@ -139,7 +179,7 @@ function initFileUploadVue() { // Notificar a Blazor para refrescar el FileManager si se subieron archivos if (hadFiles) { try { - DotNet.invokeMethodAsync('TelegramDownloader', 'RefreshFileManagerStatic'); + DotNet.invokeMethodAsync('TelegramDownloader', 'RefreshFileManagerStatic').catch(() => {}); } catch (e) { console.log('[FileUpload] No se pudo refrescar FileManager:', e); } @@ -359,12 +399,16 @@ window.openAudioPlayerModal = (file, type = "audio/mpeg", title = "") => { if (type === null) { type = "audio/mpeg"; } - DotNet.invokeMethodAsync('TelegramDownloader', 'OpenAudioPlayer', file, type, title); + try { + DotNet.invokeMethodAsync('TelegramDownloader', 'OpenAudioPlayer', file, type, title).catch(() => {}); + } catch (e) { console.log('OpenAudioPlayer error:', e); } } window.openAudioModal = () => { // Abrir el modal con la canción actual (si hay una) - DotNet.invokeMethodAsync('TelegramDownloader', 'OpenAudioPlayerCurrent'); + try { + DotNet.invokeMethodAsync('TelegramDownloader', 'OpenAudioPlayerCurrent').catch(() => {}); + } catch (e) { console.log('OpenAudioPlayerCurrent error:', e); } } window.playAudioPlayer = (url, type) => { @@ -460,19 +504,170 @@ window.closeAudioModal = () => { // El audio sigue reproduciéndose en segundo plano } +// ===== Audio Visualizer with Web Audio API ===== +window._audioVisualizer = { + audioContext: null, + analyser: null, + source: null, + dataArray: null, + animationId: null, + isInitialized: false +}; + +window.initAudioVisualizer = () => { + const audio = document.getElementById('audioPlayer'); + if (!audio) return false; + + try { + // Only create context once + if (!window._audioVisualizer.audioContext) { + window._audioVisualizer.audioContext = new (window.AudioContext || window.webkitAudioContext)(); + } + + const ctx = window._audioVisualizer.audioContext; + + // Resume context if suspended (browser autoplay policy) + if (ctx.state === 'suspended') { + ctx.resume(); + } + + // Only connect source once per audio element + if (!window._audioVisualizer.isInitialized) { + // Create analyser with better resolution + window._audioVisualizer.analyser = ctx.createAnalyser(); + window._audioVisualizer.analyser.fftSize = 256; // 128 frequency bins for better resolution + window._audioVisualizer.analyser.smoothingTimeConstant = 0.4; // Lower = more responsive + window._audioVisualizer.analyser.minDecibels = -90; + window._audioVisualizer.analyser.maxDecibels = -10; + + // Connect audio element to analyser + window._audioVisualizer.source = ctx.createMediaElementSource(audio); + window._audioVisualizer.source.connect(window._audioVisualizer.analyser); + window._audioVisualizer.analyser.connect(ctx.destination); + + // Create data array for frequency data + const bufferLength = window._audioVisualizer.analyser.frequencyBinCount; + window._audioVisualizer.dataArray = new Uint8Array(bufferLength); + + window._audioVisualizer.isInitialized = true; + } + + return true; + } catch (e) { + console.error('Error initializing audio visualizer:', e); + return false; + } +}; + +window.getVisualizerData = () => { + if (!window._audioVisualizer.analyser || !window._audioVisualizer.dataArray) { + return null; + } + + try { + window._audioVisualizer.analyser.getByteFrequencyData(window._audioVisualizer.dataArray); + + const data = window._audioVisualizer.dataArray; + const bars = []; + const numBars = 13; + const dataLength = data.length; + + // Use logarithmic frequency distribution (more bins for bass, fewer for treble) + // This better matches human hearing perception + const frequencyBands = [ + { start: 0, end: 2 }, // Sub-bass (very low) + { start: 2, end: 4 }, // Bass + { start: 4, end: 8 }, // Low-mid bass + { start: 8, end: 12 }, // Mid-bass + { start: 12, end: 20 }, // Low-mids + { start: 20, end: 30 }, // Mids + { start: 30, end: 42 }, // Upper-mids + { start: 42, end: 56 }, // Presence + { start: 56, end: 72 }, // High-mids + { start: 72, end: 88 }, // Highs + { start: 88, end: 104 }, // High treble + { start: 104, end: 118 }, // Air + { start: 118, end: 128 } // Ultra-high + ]; + + // Frequency-dependent scaling to compensate for bass dominance + // Lower values = more attenuation, higher values = more boost + const frequencyScaling = [0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7]; + + for (let i = 0; i < numBars; i++) { + const band = frequencyBands[i]; + let sum = 0; + let count = 0; + + for (let j = band.start; j < band.end && j < dataLength; j++) { + sum += data[j]; + count++; + } + + if (count > 0) { + let avg = sum / count; + // Apply frequency-dependent scaling + avg *= frequencyScaling[i]; + // Normalize to 0-100 + let normalized = (avg / 255) * 100; + // Apply slight boost for visual impact + normalized = Math.min(100, normalized * 1.2); + bars.push(normalized); + } else { + bars.push(0); + } + } + + return bars; + } catch (e) { + console.error('Error getting visualizer data:', e); + return null; + } +}; + +window.startVisualizerAnimation = (callback) => { + // Initialize if not already done + if (!window._audioVisualizer.isInitialized) { + window.initAudioVisualizer(); + } + + const animate = () => { + const data = window.getVisualizerData(); + if (data && callback) { + callback(data); + } + window._audioVisualizer.animationId = requestAnimationFrame(animate); + }; + + animate(); +}; + +window.stopVisualizerAnimation = () => { + if (window._audioVisualizer.animationId) { + cancelAnimationFrame(window._audioVisualizer.animationId); + window._audioVisualizer.animationId = null; + } +}; + window.addToAudioPlaylist = (url, type = "audio/mpeg", title = "") => { if (type === null) type = "audio/mpeg"; - DotNet.invokeMethodAsync('TelegramDownloader', 'AddToAudioPlaylist', url, type, title); + try { + DotNet.invokeMethodAsync('TelegramDownloader', 'AddToAudioPlaylist', url, type, title).catch(() => {}); + } catch (e) { console.log('AddToAudioPlaylist error:', e); } } window.addToAudioPlaylistAndPlay = (url, type = "audio/mpeg", title = "") => { if (type === null) type = "audio/mpeg"; - DotNet.invokeMethodAsync('TelegramDownloader', 'AddToAudioPlaylistAndPlay', url, type, title); + try { + DotNet.invokeMethodAsync('TelegramDownloader', 'AddToAudioPlaylistAndPlay', url, type, title).catch(() => {}); + } catch (e) { console.log('AddToAudioPlaylistAndPlay error:', e); } } window.addMultipleToAudioPlaylist = (items) => { // items is an array of {url, type, title} objects - DotNet.invokeMethodAsync('TelegramDownloader', 'AddMultipleToAudioPlaylist', items); + try { + DotNet.invokeMethodAsync('TelegramDownloader', 'AddMultipleToAudioPlaylist', items).catch(() => {}); + } catch (e) { console.log('AddMultipleToAudioPlaylist error:', e); } } // ===== Media Session API for mobile/Bluetooth integration ===== @@ -499,43 +694,59 @@ window.initMediaSession = (title, artist, album, artworkUrl) => { ] : [] }); - // Set up action handlers + // Set up action handlers with error handling navigator.mediaSession.setActionHandler('play', () => { - DotNet.invokeMethodAsync('TelegramDownloader', 'MediaSessionAction', 'play'); + try { + DotNet.invokeMethodAsync('TelegramDownloader', 'MediaSessionAction', 'play').catch(() => {}); + } catch (e) { console.log('Media session play error:', e); } }); navigator.mediaSession.setActionHandler('pause', () => { - DotNet.invokeMethodAsync('TelegramDownloader', 'MediaSessionAction', 'pause'); + try { + DotNet.invokeMethodAsync('TelegramDownloader', 'MediaSessionAction', 'pause').catch(() => {}); + } catch (e) { console.log('Media session pause error:', e); } }); navigator.mediaSession.setActionHandler('previoustrack', () => { - DotNet.invokeMethodAsync('TelegramDownloader', 'MediaSessionAction', 'previoustrack'); + try { + DotNet.invokeMethodAsync('TelegramDownloader', 'MediaSessionAction', 'previoustrack').catch(() => {}); + } catch (e) { console.log('Media session previoustrack error:', e); } }); navigator.mediaSession.setActionHandler('nexttrack', () => { - DotNet.invokeMethodAsync('TelegramDownloader', 'MediaSessionAction', 'nexttrack'); + try { + DotNet.invokeMethodAsync('TelegramDownloader', 'MediaSessionAction', 'nexttrack').catch(() => {}); + } catch (e) { console.log('Media session nexttrack error:', e); } }); navigator.mediaSession.setActionHandler('stop', () => { - DotNet.invokeMethodAsync('TelegramDownloader', 'MediaSessionAction', 'stop'); + try { + DotNet.invokeMethodAsync('TelegramDownloader', 'MediaSessionAction', 'stop').catch(() => {}); + } catch (e) { console.log('Media session stop error:', e); } }); // Seek handlers (for progress bar on lock screen) try { navigator.mediaSession.setActionHandler('seekbackward', (details) => { - const skipTime = details.seekOffset || 10; - DotNet.invokeMethodAsync('TelegramDownloader', 'MediaSessionSeek', -skipTime); + try { + const skipTime = details.seekOffset || 10; + DotNet.invokeMethodAsync('TelegramDownloader', 'MediaSessionSeek', -skipTime).catch(() => {}); + } catch (e) { console.log('Media session seekbackward error:', e); } }); navigator.mediaSession.setActionHandler('seekforward', (details) => { - const skipTime = details.seekOffset || 10; - DotNet.invokeMethodAsync('TelegramDownloader', 'MediaSessionSeek', skipTime); + try { + const skipTime = details.seekOffset || 10; + DotNet.invokeMethodAsync('TelegramDownloader', 'MediaSessionSeek', skipTime).catch(() => {}); + } catch (e) { console.log('Media session seekforward error:', e); } }); navigator.mediaSession.setActionHandler('seekto', (details) => { - if (details.seekTime !== undefined) { - DotNet.invokeMethodAsync('TelegramDownloader', 'MediaSessionSeekTo', details.seekTime); - } + try { + if (details.seekTime !== undefined) { + DotNet.invokeMethodAsync('TelegramDownloader', 'MediaSessionSeekTo', details.seekTime).catch(() => {}); + } + } catch (e) { console.log('Media session seekto error:', e); } }); } catch (e) { console.log('Seek handlers not supported:', e); @@ -621,6 +832,16 @@ window.extractAudioArtwork = (audioUrl) => { return; } + // Skip artwork extraction for FLAC files - jsmediatags has issues with them + // and they require downloading too much data + const urlLower = audioUrl.toLowerCase(); + if (urlLower.includes('.flac') || urlLower.includes('flac')) { + console.log('Skipping artwork extraction for FLAC file'); + window._artworkCache[audioUrl] = null; + resolve(null); + return; + } + // Check if jsmediatags is available if (typeof jsmediatags === 'undefined') { console.log('jsmediatags library not loaded'); @@ -628,9 +849,17 @@ window.extractAudioArtwork = (audioUrl) => { return; } + // Add timeout to prevent hanging + const timeoutId = setTimeout(() => { + console.log('Artwork extraction timed out'); + window._artworkCache[audioUrl] = null; + resolve(null); + }, 10000); // 10 second timeout + try { jsmediatags.read(audioUrl, { onSuccess: function(tag) { + clearTimeout(timeoutId); try { const picture = tag.tags.picture; if (picture) { @@ -655,12 +884,14 @@ window.extractAudioArtwork = (audioUrl) => { } }, onError: function(error) { + clearTimeout(timeoutId); console.log('Error reading tags:', error.type, error.info); window._artworkCache[audioUrl] = null; resolve(null); } }); } catch (e) { + clearTimeout(timeoutId); console.error('Error extracting artwork:', e); resolve(null); } @@ -723,7 +954,7 @@ window.moveToBody = (elementSelector) => { } window.showUploadModal = () => { - const modal = document.querySelector('.upload-modal-overlay'); + const modal = document.querySelector('.vue-upload-overlay'); if (modal) { // Move to body if not already there if (modal.parentElement !== document.body) {