From a860a8fbc3765f07349573abf057b90d83ceae86 Mon Sep 17 00:00:00 2001 From: HueByte Date: Fri, 20 Feb 2026 20:47:28 +0100 Subject: [PATCH] feat: add audio playback and file download features with corresponding UI updates --- src/EchoHub.Client/AppOrchestrator.cs | 37 ++++++++++ src/EchoHub.Client/Commands/CommandHandler.cs | 2 +- src/EchoHub.Client/Services/ApiClient.cs | 17 +++++ .../Services/AudioPlaybackService.cs | 39 +++++++++++ src/EchoHub.Client/UI/ChatRenderer.cs | 8 +++ src/EchoHub.Client/UI/MainWindow.cs | 68 +++++++++++++++++- src/EchoHub.Core/Models/MessageType.cs | 3 +- src/EchoHub.Server.Irc/IrcMessageFormatter.cs | 4 ++ .../Controllers/ChannelsController.cs | 7 +- .../Controllers/FilesController.cs | 7 ++ src/EchoHub.Server/Program.cs | 1 + .../Services/FileCleanupService.cs | 69 +++++++++++++++++++ .../Services/FileValidationHelper.cs | 14 ++++ .../Setup/DataMigrationService.cs | 37 ++++++++++ src/EchoHub.Server/appsettings.example.json | 7 +- 15 files changed, 313 insertions(+), 7 deletions(-) create mode 100644 src/EchoHub.Client/Services/AudioPlaybackService.cs create mode 100644 src/EchoHub.Server/Services/FileCleanupService.cs diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 40748ac..819c57d 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -22,6 +22,7 @@ public sealed class AppOrchestrator : IDisposable private readonly MainWindow _mainWindow; private readonly CommandHandler _commandHandler; private readonly NotificationSoundService _notificationSound; + private readonly AudioPlaybackService _audioPlayback = new(); private EchoHubConnection? _connection; private ApiClient? _apiClient; @@ -80,6 +81,8 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnSavedServersRequested += HandleSavedServersRequested; _mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested; _mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested; + _mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested; + _mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested; } // ── Command Handler Wiring ───────────────────────────────────────────── @@ -798,6 +801,40 @@ public sealed class AppOrchestrator : IDisposable }, "Failed to delete channel"); } + private void HandleAudioPlayRequested(string attachmentUrl, string fileName) + { + if (!IsAuthenticated) return; + + RunAsync(async () => + { + InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Playing {fileName}...")); + var tempPath = await _apiClient!.DownloadFileToTempAsync(attachmentUrl, fileName); + await _audioPlayback.PlayAsync(tempPath); + }, "Failed to play audio"); + } + + private void HandleFileDownloadRequested(string attachmentUrl, string fileName) + { + if (!IsAuthenticated) return; + + RunAsync(async () => + { + InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}...")); + var tempPath = await _apiClient!.DownloadFileToTempAsync(attachmentUrl, fileName); + + try + { + var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true }; + System.Diagnostics.Process.Start(psi); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath); + InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}")); + } + }, "Failed to download file"); + } + // ── Connection Event Wiring ──────────────────────────────────────────── private void WireConnectionEvents(EchoHubConnection connection) diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index b2304dd..71afd56 100644 --- a/src/EchoHub.Client/Commands/CommandHandler.cs +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -339,7 +339,7 @@ public class CommandHandler /nick - Set display name /color <#hex> - Set nickname color /theme - Switch theme - /send [-s|-m|-l] - Send a file or image (size: small/medium/large) + /send [-s|-m|-l] - Send file/image/audio (size flag for images) /avatar - Set your avatar /profile [username] - View a profile /servers - Open saved servers diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index 825cad0..aa693f0 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -196,6 +196,23 @@ public sealed class ApiClient : IDisposable return await response.Content.ReadFromJsonAsync(); } + public async Task DownloadFileToTempAsync(string relativeUrl, string fileName) + { + EnsureAuthenticated(); + var response = await AuthenticatedGetAsync(relativeUrl); + await EnsureSuccessAsync(response); + + var tempDir = Path.Combine(Path.GetTempPath(), "EchoHub"); + Directory.CreateDirectory(tempDir); + var tempPath = Path.Combine(tempDir, $"{Guid.NewGuid():N}_{fileName}"); + + await using var stream = await response.Content.ReadAsStreamAsync(); + await using var file = File.Create(tempPath); + await stream.CopyToAsync(file); + + return tempPath; + } + public async Task CreateChannelAsync(string name, string? topic = null, bool isPublic = true) { EnsureAuthenticated(); diff --git a/src/EchoHub.Client/Services/AudioPlaybackService.cs b/src/EchoHub.Client/Services/AudioPlaybackService.cs new file mode 100644 index 0000000..add0dbe --- /dev/null +++ b/src/EchoHub.Client/Services/AudioPlaybackService.cs @@ -0,0 +1,39 @@ +using NetCoreAudio; +using Serilog; + +namespace EchoHub.Client.Services; + +public class AudioPlaybackService +{ + private readonly Player _player = new(); + + public bool IsPlaying => _player.Playing; + + public async Task PlayAsync(string filePath) + { + try + { + if (_player.Playing) + await _player.Stop(); + + await _player.Play(filePath); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to play audio file: {Path}", filePath); + } + } + + public async Task StopAsync() + { + try + { + if (_player.Playing) + await _player.Stop(); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to stop audio playback"); + } + } +} diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index d4727fc..2549415 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Collections.Specialized; using System.Text.RegularExpressions; +using EchoHub.Core.Models; using Terminal.Gui.Drawing; using Terminal.Gui.Text; using Terminal.Gui.Views; @@ -22,6 +23,9 @@ public partial class ChatLine public int TextLength { get; } public Guid? MessageId { get; set; } public bool IsMention { get; set; } + public string? AttachmentUrl { get; set; } + public string? AttachmentFileName { get; set; } + public MessageType? Type { get; set; } public ChatLine(string plainText) { @@ -217,6 +221,8 @@ public class ChatListSource : IListDataSource RaiseCollectionChanged(); } + public ChatLine? GetLine(int index) => index >= 0 && index < _lines.Count ? _lines[index] : null; + public bool IsMarked(int item) => false; public void SetMark(int item, bool value) { } public IList ToList() => _lines.Select(l => l.ToString()).ToList(); @@ -465,6 +471,8 @@ public static partial class ChatColors public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Black); public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.Black); public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.Black); + public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.Black); + public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.Black); /// /// Split text around @mentions, giving each @word the MentionTextAttr accent color. diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 804ef21..280adc1 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -113,6 +113,16 @@ public sealed class MainWindow : Runnable /// public event Action? OnDeleteChannelRequested; + /// + /// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName. + /// + public event Action? OnAudioPlayRequested; + + /// + /// Fired when the user activates (Enter/click) a file message. Parameters: attachmentUrl, fileName. + /// + public event Action? OnFileDownloadRequested; + public MainWindow(IApplication app) { _app = app; @@ -175,6 +185,7 @@ public sealed class MainWindow : Runnable Height = Dim.Fill() }; _messageList.Source = new ChatListSource(); + _messageList.Accepting += OnMessageListAccepting; _chatFrame.Add(_messageList); Add(_chatFrame); @@ -360,6 +371,31 @@ public sealed class MainWindow : Runnable } } + private void OnMessageListAccepting(object? sender, CommandEventArgs e) + { + if (_messageList.Source is not ChatListSource source) + return; + + var index = _messageList.SelectedItem; + if (!index.HasValue || index.Value < 0 || index.Value >= source.Count) + return; + + var line = source.GetLine(index.Value); + if (line?.AttachmentUrl is null || line.AttachmentFileName is null) + return; + + if (line.Type == MessageType.Audio) + { + OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); + e.Handled = true; + } + else if (line.Type == MessageType.File) + { + OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); + e.Handled = true; + } + } + private void OnInputKeyDown(object? sender, Key e) { if (e.KeyCode == TabKey.KeyCode) @@ -900,10 +936,24 @@ public sealed class MainWindow : Runnable } break; + case MessageType.Audio: + var audioName = message.AttachmentFileName ?? "unknown"; + var audioLine = BuildChatLineColored(time, senderName, senderColor, + $" \u266a [Audio: {audioName}] (Enter to play)", ChatColors.AudioAttr); + audioLine.AttachmentUrl = message.AttachmentUrl; + audioLine.AttachmentFileName = audioName; + audioLine.Type = MessageType.Audio; + lines.Add(audioLine); + break; + case MessageType.File: var fileName = message.AttachmentFileName ?? "unknown"; - var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : ""; - lines.Add(BuildChatLine(time, senderName, senderColor, $" [File: {fileName}]{fileContent}")); + var fileLine = BuildChatLineColored(time, senderName, senderColor, + $" [File: {fileName}] (Enter to download)", ChatColors.FileAttr); + fileLine.AttachmentUrl = message.AttachmentUrl; + fileLine.AttachmentFileName = fileName; + fileLine.Type = MessageType.File; + lines.Add(fileLine); break; case MessageType.Text: @@ -962,6 +1012,20 @@ public sealed class MainWindow : Runnable return new ChatLine(segments); } + /// + /// Build a chat line with a colored suffix (used for audio/file indicators). + /// + private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor) + { + var segments = new List + { + new($"[{time}] ", ChatColors.TimestampAttr), + new(senderName, senderColor), + new(suffix, suffixColor) + }; + return new ChatLine(segments); + } + /// /// Build a chat line with @mention highlighting in the suffix text. /// diff --git a/src/EchoHub.Core/Models/MessageType.cs b/src/EchoHub.Core/Models/MessageType.cs index 6957785..4ffb036 100644 --- a/src/EchoHub.Core/Models/MessageType.cs +++ b/src/EchoHub.Core/Models/MessageType.cs @@ -4,5 +4,6 @@ public enum MessageType { Text, Image, - File + File, + Audio } diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs index fb017c5..c1a91ab 100644 --- a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs +++ b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs @@ -48,6 +48,10 @@ public static partial class IrcMessageFormatter case MessageType.File: lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}"); break; + + case MessageType.Audio: + lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {message.AttachmentFileName}] {message.AttachmentUrl}"); + break; } return lines; diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 753da7f..e9ace7f 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -202,13 +202,16 @@ public class ChannelsController : ControllerBase if (file.Length > HubConstants.MaxFileSizeBytes) return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB.")); - // Detect if file is an image by checking magic bytes + // Detect file type: image (magic bytes), audio (extension), or generic file using var stream = file.OpenReadStream(); var isImage = FileValidationHelper.IsValidImage(stream); + var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName); var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName); - var messageType = isImage ? MessageType.Image : MessageType.File; + var messageType = isImage ? MessageType.Image + : isAudio ? MessageType.Audio + : MessageType.File; string content; if (isImage) diff --git a/src/EchoHub.Server/Controllers/FilesController.cs b/src/EchoHub.Server/Controllers/FilesController.cs index 2bcacf9..42db03f 100644 --- a/src/EchoHub.Server/Controllers/FilesController.cs +++ b/src/EchoHub.Server/Controllers/FilesController.cs @@ -36,6 +36,13 @@ public class FilesController : ControllerBase ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", + ".mp3" => "audio/mpeg", + ".wav" => "audio/wav", + ".ogg" => "audio/ogg", + ".flac" => "audio/flac", + ".aac" => "audio/aac", + ".m4a" => "audio/mp4", + ".wma" => "audio/x-ms-wma", ".pdf" => "application/pdf", ".txt" => "text/plain", _ => "application/octet-stream" diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 0e830a5..4a00acd 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -108,6 +108,7 @@ while (true) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); // ── Encryption ───────────────────────────────────────────────────── builder.Services.AddSingleton(); diff --git a/src/EchoHub.Server/Services/FileCleanupService.cs b/src/EchoHub.Server/Services/FileCleanupService.cs new file mode 100644 index 0000000..c5d9cca --- /dev/null +++ b/src/EchoHub.Server/Services/FileCleanupService.cs @@ -0,0 +1,69 @@ +namespace EchoHub.Server.Services; + +public sealed class FileCleanupService : BackgroundService +{ + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public FileCleanupService(IConfiguration configuration, ILogger logger) + { + _configuration = configuration; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var intervalHours = _configuration.GetValue("Storage:CleanupIntervalHours", 1); + var retentionDays = _configuration.GetValue("Storage:RetentionDays", 30); + var storagePath = _configuration["Storage:Path"] + ?? Path.Combine(AppContext.BaseDirectory, "uploads"); + + _logger.LogInformation( + "File cleanup service started — interval: {Hours}h, retention: {Days}d, path: {Path}", + intervalHours, retentionDays, storagePath); + + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken); + + try + { + CleanupOldFiles(storagePath, retentionDays); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during file cleanup"); + } + } + } + + private void CleanupOldFiles(string storagePath, int retentionDays) + { + if (!Directory.Exists(storagePath)) + return; + + var cutoff = DateTime.UtcNow.AddDays(-retentionDays); + var files = Directory.GetFiles(storagePath); + var deleted = 0; + + foreach (var file in files) + { + var createdAt = File.GetCreationTimeUtc(file); + if (createdAt < cutoff) + { + try + { + File.Delete(file); + deleted++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete old file: {File}", file); + } + } + } + + if (deleted > 0) + _logger.LogInformation("File cleanup: deleted {Count} files older than {Days} days", deleted, retentionDays); + } +} diff --git a/src/EchoHub.Server/Services/FileValidationHelper.cs b/src/EchoHub.Server/Services/FileValidationHelper.cs index adf7330..f1d3b40 100644 --- a/src/EchoHub.Server/Services/FileValidationHelper.cs +++ b/src/EchoHub.Server/Services/FileValidationHelper.cs @@ -52,6 +52,20 @@ public static class FileValidationHelper } } + private static readonly HashSet AudioExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma" + }; + + /// + /// Checks whether the file name has a recognized audio extension. + /// + public static bool IsAudioFile(string fileName) + { + var ext = Path.GetExtension(fileName); + return !string.IsNullOrEmpty(ext) && AudioExtensions.Contains(ext); + } + private static bool StartsWith(byte[] buffer, int length, byte[] magic) { if (length < magic.Length) diff --git a/src/EchoHub.Server/Setup/DataMigrationService.cs b/src/EchoHub.Server/Setup/DataMigrationService.cs index a61e00b..e1e1902 100644 --- a/src/EchoHub.Server/Setup/DataMigrationService.cs +++ b/src/EchoHub.Server/Setup/DataMigrationService.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; using EchoHub.Core.Constants; using EchoHub.Core.DTOs; +using EchoHub.Core.Models; using EchoHub.Server.Data; using Microsoft.EntityFrameworkCore; @@ -12,12 +13,14 @@ public static partial class DataMigrationService { using var scope = services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); + var config = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService() .CreateLogger("EchoHub.Server.Setup.DataMigration"); await EnsureDefaultChannelsPublicAsync(db, logger); await MigrateAnsiMessagesAsync(db, logger); await MigrateEmbedJsonToArrayAsync(db, logger); + await EnsureConfiguredAdminsAsync(db, config, logger); } /// @@ -94,6 +97,40 @@ public static partial class DataMigrationService [GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")] private static partial Regex AnsiColorRegex(); + /// + /// Ensure usernames listed in Server:Admins config are at least Admin role. + /// Acts as a safety net in case the first registered user didn't get Owner role. + /// + private static async Task EnsureConfiguredAdminsAsync(EchoHubDbContext db, IConfiguration config, ILogger logger) + { + var adminUsernames = config.GetSection("Server:Admins").Get(); + if (adminUsernames is not { Length: > 0 }) + return; + + var promoted = 0; + foreach (var username in adminUsernames) + { + var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); + if (user is null) + { + logger.LogWarning("Configured admin '{Username}' not found in database (not registered yet).", username); + continue; + } + + if (user.Role < ServerRole.Admin) + { + var oldRole = user.Role; + user.Role = ServerRole.Admin; + promoted++; + logger.LogInformation("Promoted '{Username}' from {OldRole} to Admin (configured in Server:Admins).", + username, oldRole); + } + } + + if (promoted > 0) + await db.SaveChangesAsync(); + } + /// /// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]"). /// diff --git a/src/EchoHub.Server/appsettings.example.json b/src/EchoHub.Server/appsettings.example.json index ff89ee0..5d21c45 100644 --- a/src/EchoHub.Server/appsettings.example.json +++ b/src/EchoHub.Server/appsettings.example.json @@ -12,7 +12,12 @@ "Name": "My EchoHub Server", "Description": "A self-hosted EchoHub chat server", "PublicServer": false, - "PublicHost": "" + "PublicHost": "", + "Admins": [] + }, + "Storage": { + "CleanupIntervalHours": 1, + "RetentionDays": 30 }, "Encryption": { "Key": "",